feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup

Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7):
- sessions.lane_id: a workspace lists only the sessions its panes held,
  stamped by bindRunSession (first writer wins, nested `claude -p`
  excluded); GET /api/sessions?lane=&archived=
- session row state dot, double-click rename, archive (sessions.archived_at,
  hides only); the activity bar's duplicate Sessions tab removed
- Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live
  session opens it in a second pane, a diff line's comment is pasted into
  the workspace's last-focused pane (lib/panePaste.ts)

dashboard_runs stability:
- a pane's previous run is archived as <pane>@<started_at> instead of being
  overwritten by INSERT OR REPLACE
- a run is flagged abandoned only once its tmux session is gone, at boot and
  on the run-heal beat (panes outlive a server restart)

UI:
- sidebar: no footer, language or theme block; small collapse button in the
  brand corner; version + update check in the connection details modal, a
  status-bar dot while a release is out; language in Settings -> General
- one Switch component for every switch, the themed Checkbox for every
  checkbox (no native checkboxes)

Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes
tested through fixtures), and the workspace plans under docs/plans/ (the
multi-user server plan lives in the AgentHub-Server repo).
This commit is contained in:
2026-10-02 12:05:00 +07:00
parent 5b269698f6
commit a6bd33bcff
121 changed files with 2699 additions and 1552 deletions
+147
View File
@@ -0,0 +1,147 @@
# 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=<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) | 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 <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 = 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=<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 (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
`<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.