# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app _Status: written 2026-10-02. Phases 1–5 and 7 implemented 2026-10-02 (uncommitted); phase 6 waits on decision D4. Phases run in order, each one change-set on `main`. Comes BEFORE `docs/plans/multi-user-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=&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-[-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) | A **child workspace**: a worktree lane with `parent_lane_id`, listed under its parent in the sidebar. Reuses every lane rule (git surface, destroy guard) instead of giving a session its own cwd. Confirm with the user before phase 6. | | 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 --fork-session`) | small | | 5 | Ctrl/Cmd+click a live session in the sidebar opens it in a second pane | small | | 6 | Worktree per session = child workspace (D4) — **ask first** | 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=` → `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=`; 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 ` (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=&run=&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 (needs D4 confirmed) - `lanes.parent_lane_id INTEGER` (nullable). - Home prompt box: a **Worktree** toggle (git repos only). Run → `POST /api/lanes/worktree` with a generated branch + `parent_lane_id`, then start the prompt in the child's slot 0. - Sidebar: a child lane is listed under its parent's sessions, not as a top-level row. Archive on it = the existing Remove (record only — the worktree stays the user's, per the remove rule) plus a hint to delete it. ### Phase 7 — diff comment to prompt (optional) A **Comment** action on a diff line in `LaneDiffView.tsx` builds `: — ` 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.