c507d37baf
AI Router is a session's choice, not a workspace's: - an AI Router select beside the model picker in the workspace home and a pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only Native; last pick remembered in agenthub.session.router) - `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets --settings=<data dir>/router-settings/<name>.json (0600, outranks project and user settings, key never in argv), Codex -p <name> + key via tmux -e - dashboard_runs.router; a resume naming none goes back to it - profile PATCH rewrites the session files, DELETE removes them - gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace router radio, the claude-/codex-home `routers` answers; clearLaneRouters takes the old keys out of each workspace's settings.local.json at start Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked on the rail) instead of in the brand's corner. Icons: shipped SVG marks for claude, codex, openclaw and hermes (a black/white pair swapped by theme) in client/public/ai-tools/, used by AiToolIcon before the server's favicon.
182 lines
10 KiB
Markdown
182 lines
10 KiB
Markdown
# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app
|
||
|
||
_Status: written 2026-10-02. Phases 1–5 and 7 implemented and committed
|
||
(a6bd33b); phase 6 implemented 2026-10-02 the Claude way (D4). Phases run in order, each one change-set on `main`.
|
||
Comes BEFORE the multi-user server plan (now in the AgentHub-Server repo,
|
||
https://git.smartgift.io.vn/Smartgift-AI/AgentHub-Server) — that plan's owner column hangs
|
||
off the session ownership built in phase 1 here._
|
||
|
||
## Where we stand
|
||
|
||
The workspace-sessions plan gave AgentHub Claude Desktop's *surface*: a sidebar
|
||
of workspaces that expand to sessions, a project home with a prompt box,
|
||
Recents, Instructions / Memory / Context / Folder. Its *data model* is still
|
||
"a workspace is a folder":
|
||
|
||
- Every session list (sidebar `WorkspaceSessionList.tsx`, home Recents in
|
||
`WorkspaceHome.tsx`, the activity bar's `WorkspaceSessionsTab.tsx`) asks
|
||
`GET /api/sessions?cwd=<lane.cwd>&tool=`, which filters `s.cwd = ?` exactly.
|
||
A session typed into a terminal in that folder, or one from months before the
|
||
workspace existed, is listed as the workspace's own; a session that `cd`'d
|
||
into a subfolder is not.
|
||
- Nothing durable records which sessions a workspace started.
|
||
`dashboard_runs` has `lane_id`, but its key is the pane name
|
||
(`agenthub-lane-<id>[-slot]`) and `server/lib/dashboard-runs.js` writes it
|
||
with `INSERT OR REPLACE`, so each Start overwrites the last; `bindRunSession`
|
||
(`server/routes/hooks.js`) keeps only the pane's CURRENT session, so a
|
||
`/clear` forgets the previous one.
|
||
|
||
Claude Desktop (Code tab) and the Codex app both treat a project as the owner
|
||
of the sessions/threads started in it, show each one's state, let one be
|
||
archived, and give a session — not a project — its own worktree.
|
||
|
||
## Decisions
|
||
|
||
| # | Decision | Value |
|
||
| --- | --- | --- |
|
||
| D1 | Sessions in the folder that the workspace did not start | **Not listed** (user's call, 2026-10-02): a workspace shows only its own sessions; the rest stay on the Sessions page. The fold and `?unowned=` / `PATCH {lane_id}` built first were removed again. |
|
||
| D2 | What "belongs to a workspace" means | `sessions.lane_id` = the lane whose pane held the session (stamped from the hook's `run_id`), or one the user added by hand. First writer wins; only the user moves it. |
|
||
| D3 | Old history | Not guessed. Only the sessions `dashboard_runs` still names are backfilled; everything older is not listed in any workspace. |
|
||
| D4 | Per-session worktree (phase 6) | **Claude's model** (user's call, 2026-10-02): the pane runs `claude --worktree=<name>`, so Claude Code itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. The session stays the workspace's own (no child workspace). Archive removes a CLEAN worktree, like Claude Desktop. |
|
||
| D5 | Tool chosen per session instead of per workspace | **Out of scope here.** `lanes.tool` stays immutable. Revisit with the user after phase 6. |
|
||
| D6 | Environments (SSH/cloud), Routines/Automations, cross-session messaging | **Not built.** AgentHub runs locally and does not orchestrate (`CLAUDE.md`). |
|
||
|
||
## Phases
|
||
|
||
| # | Scope | Size |
|
||
| --- | --- | --- |
|
||
| 1 | Session ownership: `sessions.lane_id`, `GET /api/sessions?lane=`, the "other sessions" fold, **Add to workspace** | medium |
|
||
| 2 | A session row's state (running / needs you / error), rename, archive | medium |
|
||
| 3 | Drop the activity bar's **Sessions** tab (third copy of the same list) | small |
|
||
| 4 | **Fork** a Claude session into a free pane (`--resume <id> --fork-session`) | small |
|
||
| 5 | Ctrl/Cmd+click a live session in the sidebar opens it in a second pane | small |
|
||
| 6 | Worktree per session, Claude's way (D4) | large |
|
||
| 7 | Comment on a diff line → text pasted into the session's prompt | medium, optional |
|
||
|
||
### Phase 1 — session ownership
|
||
|
||
Schema (`server/db.js`, probe-then-ALTER like every other column, inside the
|
||
migration lock):
|
||
- `sessions.lane_id INTEGER` (nullable, no FK — a removed lane leaves its
|
||
sessions, which are the user's history) + `idx_sessions_lane`.
|
||
- Backfill once: `UPDATE sessions SET lane_id = r.lane_id FROM dashboard_runs r
|
||
WHERE r.session_id = sessions.id AND sessions.lane_id IS NULL AND r.lane_id IS NOT NULL`.
|
||
|
||
Stamping (`server/routes/hooks.js`):
|
||
- `bindRunSession` already decides when a pane HOLDS a session (a nested
|
||
`claude -p` the agent runs through Bash inherits `AGENTHUB_RUN_ID` but does
|
||
not take the pane over). Whenever the run's session is — or becomes — this
|
||
session, stamp `sessions.lane_id = dashboard_runs.lane_id` where it is null.
|
||
- A session started from the dashboard with no hooks (Codex without hooks, any
|
||
catalog tool) is not stamped, so no workspace lists it. Known limit.
|
||
|
||
API (`server/routes/sessions.js`):
|
||
- `GET /api/sessions?lane=<id>` → `s.lane_id = ?`;
|
||
`?unowned=1` → `s.lane_id IS NULL` (combined with `cwd`+`tool` for the fold).
|
||
- `PATCH /api/sessions/:id {lane_id}` (number or `null`), `sameOriginGuard`:
|
||
the lane must exist and have the session's `tool` (`EBADLANE`).
|
||
Existing `name`/`status` patching is unchanged.
|
||
|
||
Client:
|
||
- `WorkspaceSessionList` and home Recents list `?lane=<id>`; below them a folded
|
||
"Other sessions in this folder (N)" (`?cwd=&tool=&unowned=1`), each row with
|
||
**Add to workspace**.
|
||
- `SessionResumeButton`/`owningLane` unchanged (resume still needs the folder).
|
||
|
||
Tests first: server — a hook carrying `run_id` stamps the lane, a nested
|
||
`claude -p` session does not, the backfill, `?lane=`, `?unowned=1`, PATCH
|
||
validation. Client — the list asks `lane=`, the fold, Add to workspace.
|
||
|
||
### Phase 2 — row state, rename, archive
|
||
|
||
- Row dot: live pane (green), the session `awaiting_input_since` set (amber,
|
||
"needs you"), `status = 'error'` (red), else hollow. Re-read when the lane's
|
||
`needs_action` moves too.
|
||
- Rename: double-click a session row → `PATCH /api/sessions/:id {name}`.
|
||
`syncSessionName` already lets only a Claude `/rename` (custom title)
|
||
override a non-placeholder name.
|
||
- Archive: `sessions.archived_at TEXT`; `PATCH {archived: true|false}`; lists
|
||
for a workspace skip archived rows unless `?archived=1`; the row's hover
|
||
shows an archive button, the home Recents a "Show archived" toggle.
|
||
Archiving deletes nothing.
|
||
|
||
### Phase 3 — one session list fewer
|
||
|
||
`WorkspaceSessionsTab.tsx` and the `sessions` activity-bar tab go; a stored
|
||
`agenthub.workspace.panelTab = "sessions"` falls back to `changes`. Resume stays
|
||
on the transcript page (`SessionResumeButton`). `CLAUDE.md`'s "Opening a
|
||
session never resumes it" rule loses the Sessions-tab mention.
|
||
|
||
### Phase 4 — fork
|
||
|
||
- `buildClaudeArgv` gains `forkSession` → `--fork-session` after `--resume <id>`
|
||
(Claude only; refused elsewhere, `EUNSUPPORTED`). `POST /api/lanes/:id/start`
|
||
passes `forkSession: true` through like `resumeSessionId`.
|
||
- Transcript page: **Fork** beside **Resume**, same warning, same first-free-slot
|
||
logic (`freeSlot`). The new session id arrives by hook and is stamped to the
|
||
workspace by phase 1.
|
||
|
||
### Phase 5 — open in a second pane
|
||
|
||
Ctrl/Cmd+click on a live session row → `/workspace?lane=<id>&run=<runId>&pane=1`;
|
||
`Workspace.tsx` switches a 1-pane layout to 2 and hands `attachRequest` to that
|
||
pane instead of pane 0. A plain click is unchanged.
|
||
|
||
### Phase 6 — worktree per session, Claude's way
|
||
|
||
Verified 2026-10-02 on Claude Code 2.1.286: `claude -w probe1` makes
|
||
`<repo>/.claude/worktrees/probe1` on `worktree-probe1` (from origin/HEAD, or
|
||
`worktree.baseRef`), locks it with `claude session probe1 (pid …)`, and its
|
||
hooks report that worktree as `cwd`. It does NOT exclude `.claude/worktrees/`,
|
||
so the main repo shows `?? .claude/`.
|
||
|
||
- **Start:** the home prompt box gets a **Worktree** checkbox (Claude
|
||
workspaces in a git repo). `POST /api/lanes/:id/start {slot, worktree: true}`
|
||
→ `spawnRun` puts `--worktree=<name>` in the argv (`=` form: `-w [name]`
|
||
takes an optional value and would eat the prompt as the name); the name is
|
||
server-made (`ah-<lane>-<base36 time>`), never from the request. Claude only
|
||
(`EUNSUPPORTED` elsewhere). `.claude/worktrees/` goes into the common git
|
||
dir's `info/exclude` at the repo root (`excludeFromGit`).
|
||
`dashboard_runs.worktree` = 1 marks the run.
|
||
- **Which worktree a session is in:** `server/lib/session-worktree.js`
|
||
`sessionWorktree(lane, sessionId)` → `{path, branch, locked}` only when the
|
||
session is OWNED by the lane, its `cwd` is not the lane's own folder, and
|
||
`git worktree list` of the lane's repo registers that path. Nothing else
|
||
turns a request into a path.
|
||
- **Git panel:** every `/:id/git*` route takes `?session=<id>`; with it the
|
||
route works in that session's worktree, and answers `400 ENOWORKTREE`
|
||
instead of falling back to the main folder (a commit must never land in the
|
||
wrong tree). The session view passes the session of the attached run when
|
||
that run is a worktree run.
|
||
- **Resume / Fork** of a worktree session start the pane in its worktree;
|
||
one whose worktree is gone is refused (`ENOWORKTREE`) — Claude finds a
|
||
session by the folder it ran in.
|
||
- **Sidebar / Recents:** a worktree session shows a branch icon and its
|
||
branch in the tooltip.
|
||
- **Archive = remove the worktree** (Claude Desktop): for a worktree session
|
||
with no live pane the archive button asks first, then
|
||
`POST /api/lanes/:id/session-worktree/remove {session, confirm: true}` runs
|
||
`git worktree remove <path>` WITHOUT force (git refuses a dirty tree) and
|
||
`git branch -d` (refuses unmerged work — the branch is then kept and
|
||
reported); a lock held by a live process is refused (`ELOCKED`), a stale
|
||
one (dead pid) unlocked first. Only after that succeeds is the session
|
||
archived. The workspace's own folder and any worktree no session of this
|
||
workspace ran in are never touched.
|
||
- **Not built:** auto-archive on PR merge (no PR integration), a branch prefix
|
||
setting (the CLI has none), worktrees for Codex.
|
||
|
||
### Phase 7 — diff comment to prompt (optional)
|
||
|
||
A **Comment** action on a diff line in `LaneDiffView.tsx` builds
|
||
`<path>:<line> — <text>` and pastes it into the pane the Changes panel belongs
|
||
to, through the same client-side `term.paste()` path the image drop uses. No
|
||
server route.
|
||
|
||
## Ground rules
|
||
|
||
`CLAUDE.md` binds every phase: one console render site, opening a session never
|
||
resumes it, migrations probe-then-ALTER under the lock, headers on every
|
||
touched file, `npm run test:server` + `npm run test:client` (snapshots
|
||
regenerated only after reviewing the diff), docs + the `CLAUDE.md` marker in the
|
||
same change-set.
|