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
+24 -1
View File
@@ -271,6 +271,7 @@ All fields are optional. The tool (CLI invoked) comes from the workspace itself,
- **`sandbox`** — one of `read-only`, `workspace-write`, or `danger-full-access`, or omitted/empty for no sandbox. Passed to `codex -s <value>`.
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
@@ -1199,6 +1200,10 @@ Returns all sessions, ordered by most recent activity.
| `limit` | integer | 50 | Maximum sessions to return (1-1000) |
| `offset` | integer | 0 | Pagination offset |
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a queryable enum — filter `status=active` and inspect `awaiting_input_since` (non-null = Waiting) |
| `cwd` | string | - | Exact working directory |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity` |
| `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id |
| `archived` | `0` \| `1` | - | `0` = not archived, `1` = archived only; omitted = both |
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
**Example Request:**
@@ -1252,6 +1257,8 @@ classDiagram
+string updated_at
+string awaiting_input_since "null unless Waiting"
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
+number lane_id "owning workspace; null if none"
+string archived_at "null unless archived"
+number cost
+number agent_count
}
@@ -1261,6 +1268,22 @@ classDiagram
---
#### Update Session
```http
PATCH /api/sessions/:id
```
Body: any of `name`, `status`, `ended_at`, `metadata` and `archived`.
`archived` (boolean, else `400 INVALID_INPUT`) sets or clears `archived_at`:
the session leaves its workspace's lists and nothing is deleted. Ownership
(`lane_id`) is not patchable: `bindRunSession` (`server/routes/hooks.js`)
stamps the session a dashboard pane holds with that pane's lane, first writer
wins.
Returns `{ session }`.
---
#### Get Session
```http
@@ -2154,7 +2177,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is flipped to `abandoned` once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
---
+2
View File
@@ -212,6 +212,8 @@ CREATE TABLE sessions (
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
**Constraints:**
- `status` must be one of the four enum values
+5 -7
View File
@@ -132,9 +132,7 @@ classDiagram
+t(key, params)
}
class SidebarLanguageSwitch {
+SUPPORTED_LANGUAGES
+normalizeLanguage()
class SettingsLanguagePicker {
+changeLanguage()
}
@@ -149,7 +147,7 @@ classDiagram
I18nConfig --> NamespaceResource
ReactComponent --> I18nConfig
ReactComponent --> NamespaceResource
SidebarLanguageSwitch --> I18nConfig
SettingsLanguagePicker --> I18nConfig
FormatUtils --> I18nConfig
```
@@ -157,7 +155,7 @@ classDiagram
## 4) Language Detection and Switching Flow
The sidebar language controls call `i18n.changeLanguage()` and UI updates reactively through `useTranslation`.
The language picker in Settings → General (`client/src/pages/Settings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
```mermaid
sequenceDiagram
@@ -239,7 +237,7 @@ npm run test:client
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
| Runtime switching | UI rerenders without reload | Sidebar language toggle |
| Runtime switching | UI rerenders without reload | Settings → General language picker |
---
@@ -284,7 +282,7 @@ gantt
### Operational checklist
- [ ] Confirm all namespaces exist for `en` and `vi`
- [ ] Confirm key parity across all locale JSON files
- [ ] Confirm language switching works in collapsed and expanded sidebar modes
- [ ] Confirm language switching from Settings → General re-renders the whole app
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
- [ ] Confirm date/time/currency formatting for all supported languages
- [ ] Confirm client tests pass before release
+15 -13
View File
@@ -390,11 +390,11 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, between the nav and the footer (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
- **A workspace's sessions** — the chevron expands the row to its sessions (`WorkspaceSessionList.tsx`: `GET /api/sessions?cwd=&tool=`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's sessions with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`; the session view's Sessions tab is the other place that resumes): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes / Sessions panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
@@ -621,15 +621,11 @@ a setting — and one button beats a width button wedged beside a collapse butto
in the panel's tightest corner. It is icon-only, and the icon it draws is the
width the next click LANDS on (even split → wider → closed), with the wording
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
of three icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
of two icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
**Files** (the browse mode below, on its own — no git header, no mode
switcher), **Changes** (the git header and the other four modes) and
**Sessions** — the workspace's sessions (`GET /api/sessions?cwd=<cwd>&tool=<tool>`,
newest 50; an exact cwd match, so a session started in a subdirectory is not
listed) with tool badge, title, model, relative time and cost, a filter box,
**Open** (its detail page) and, on a Claude workspace, **Resume**, which resumes
it in the console's slot-0 pane through the same path as a resume from the run
history. At Full screen the Files and Changes tabs show the same thing: the
switcher) and **Changes** (the git header and the other four modes). The
workspace's sessions live in the sidebar and on its home only — the panel's
former Sessions tab was a third copy of the same list. At Full screen the Files and Changes tabs show the same thing: the
explorer sidebar beside the changes. It
reads the workspace's git surface in five modes:
@@ -678,6 +674,12 @@ reads the workspace's git surface in five modes:
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`),
both sent with the hunk's `@@` line plus the block's index, so they touch
that block alone. The single-file view offers the same bar.
A new line's NUMBER is a button too: it opens a comment box under the diff,
and **Paste into pane** pastes `<path>:<line> — <text>` into this
workspace's pane focused last (`client/src/lib/panePaste.ts`, the same
bracketed `term.paste()` an image drop uses) — nothing is sent until the
user presses Enter in the pane; with no pane of the workspace open the box
says so and keeps the text.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
@@ -1717,7 +1719,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — ten built in: Claude Code, Codex, Gemini CLI, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Oh My Pi, Hermes and OpenClaw —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file:
+1 -1
View File
@@ -204,7 +204,7 @@ recorded install first (`supersededBy(oldRoot)`), falling back to the newest
sibling directory only when there is no record.
Separately, a plugin install whose release lookup failed (`release_check` not
`ok`) now reads **Could not check for updates** in the sidebar instead of
`ok`) now reads **Could not check for updates** in the connection details modal (opened from the status bar) instead of
**Up to date** — a plugin install has no commit count to fall back on.
To hand over by hand without the dashboard:
+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.
@@ -0,0 +1,62 @@
# Phase 0 — Call lanes "workspaces" in the UI and docs
_One PR. No behaviour change, no identifier change. Every test that passed
before passes after; only copy and snapshots differ._
## Goal
The **word** a user reads changes: "Lane" → "Workspace", "Lanes" →
"Workspaces", "Add lane" → "Add workspace", "lane strip" → "workspace list",
and so on — in the dashboard UI (both languages), in the docs, in CLI *help
and output text*, and in the plugin's skill/command prose.
## What does NOT change
Everything that is an identifier stays exactly as it is:
- API paths and payload keys: `/api/lanes`, `{ lanes, counts }`, `lane_id`, …
- WebSocket message types `lane` / `lanes`.
- CLI commands and flags: `agenthub lanes add|up|down|…`, `agenthub stage --lane 3`.
- Env vars: `AGENTHUB_LANE_ID`, `LANES_ROOT`, `LANE_DEAD_SEC`, …
- Error codes (`EBADLANE`, …), tmux session names `agenthub-lane-<id>`.
- SQLite tables/columns (`lanes`, `lane_features`, `lane_stage_events`).
- Source file, component, hook, type and function names (`LaneCard.tsx`,
`LaneChangesPanel`, `useLaneGitFacts`, `listLanes`, `Lane` type, …),
`data-testid` values, localStorage keys, i18n **namespace file name**
`lanes.json` and its **keys**.
Rationale: a rename of identifiers touches ~200 files and every running
install's tmux panes, hooks and bookmarks, for no user-visible gain. The
user-visible gain is the word, so only the word changes.
## Where the word changes
| Surface | Action |
| --- | --- |
| `client/src/i18n/locales/en/*.json`, `…/vi/*.json` | Replace the English word in **values** (not keys): `"Lane"→"Workspace"`, `"lane"→"workspace"`, `"Lanes"→"Workspaces"`, plural/possessive forms. Vietnamese values: "lane"/"làn" → "workspace" (the Vietnamese UI already uses the English loanword "Workspace" in `nav.json`; keep that). Check `lanes.json`, `nav.json`, `settings.json` (`lanesSection.title` = "Workspaces"), `run.json`, `dashboard.json`, `kanban.json`, `errors.json`, `splash.json`. |
| Hardcoded UI strings | `grep -rn -i '\blanes\?\b' client/src --include=*.tsx` — any string literal rendered to the user that escaped i18n is moved into i18n with the new word (do not leave it hardcoded). Code comments and identifiers are left alone. |
| CLI output and help (`bin/agenthub.js`) | Prose in `console.log`/`console.error`/usage strings: "lane" → "workspace" in the sentence, while the command names in those same sentences stay literal: e.g. `no workspace owns <cwd> — create one with: agenthub lanes add --cwd <cwd>`. |
| Docs | `docs/LANES.md`: keep the file name (it is linked from many places and from `CLAUDE.md`), retitle to "Workspaces (lanes)" and open with one paragraph: *"A workspace — called a lane in the API, CLI, database and code — is …"*. Then use "workspace" in prose throughout, keeping every literal command/route/field as is. Same treatment in `README.md`, `docs/API.md`, `docs/CLI.md`, `docs/DATABASE.md`, `docs/HOOKS.md`, `client/README.md`, `server/README.md`, `docs/PLUGINS.md`. |
| `CLAUDE.md` | The "## Lanes" heading becomes "## Workspaces (lanes)"; the first bullet gains the one-sentence glossary above; every rule keeps its literal identifiers. Bump the sync marker. |
| Plugin skills/commands (`skills/*`, `plugins/agenthub/commands/*`) | Prose only. |
| Server-side user-facing text | Error `message` strings that reach the UI (e.g. `"lane not found"`): change the word, keep the `code`. Log lines may stay. |
## Order of work
1. i18n values (en, vi) + any escaped literals → `npm run test:client`, review
the snapshot diff (copy only), regenerate.
2. CLI strings → `npm run test:server` (`lanes-cli.test.js` asserts some
output text; update those assertions).
3. Server error messages → server suite.
4. Docs + `CLAUDE.md` + skills prose.
## Acceptance
- Opening every page of the dashboard in EN and VI shows no "lane" (walk:
Dashboard, Sessions, Workflows, Workspace incl. Add modal / destructive
modals / Changes panel / run form, Settings incl. every tool tab).
- `agenthub lanes` (no args) and `agenthub help` print "workspace" in prose
and `lanes` as the command.
- `git diff --stat` shows no renamed files, no changes to `server/routes/`
paths, `client/src/lib/api.ts` or `client/src/lib/types.ts`.
- Headers check, both suites, snapshots regenerated once.
@@ -0,0 +1,327 @@
# Phase 1 — Project tree in the sidebar, workbench, right panel, status bar
_One PR, after phase 0. New UI text says "workspace"; identifiers stay `lane`._
## Goal
The `/workspace` page stops being "a strip of tiles above a console" and becomes
a three-column workbench: **sidebar** (nav + project → workspace tree),
**center** (the selected workspace's panes), **right panel** (Files · Changes ·
Sessions). Picking a workspace is a sidebar click; creating one is a `+` on its
project row. Reference: `reference/orca-project.png`, `reference/orca-ws2.png`,
`reference/orca-git.png`.
## Non-goals
- No theme change. Use existing tokens (`surface-*`, `border`, `accent`,
`status-*`, `fg-*`) and existing primitives (`Tooltip` via `title=`,
`ConfirmModal`, `Select`, `AiToolIcon`, `StatusBadge`).
- No pipeline changes. Stage/feature/PipelineMap UI stays exactly where it is
today for a workspace whose pipeline has nodes; it is hidden otherwise (as
now).
- No orchestration, no kanban, no parent/child worktree lineage.
- No provider quota in the status bar.
## 1. Data: projects
### Server
`server/lib/projects.js` (new):
```js
/** Root of the repository a directory belongs to: the MAIN worktree's
* top level (realpath), or null when the directory is not inside a git repo. */
projectRootFor(cwd) -> Promise<string|null>
```
Implementation: `git -C <cwd> rev-parse --path-format=absolute --git-common-dir`
(`execFile`, never a shell string), then `dirname` of that, `fs.realpathSync`.
For a bare/odd layout where the common dir does not end in `/.git`, fall back to
`git rev-parse --show-toplevel` of the common dir's parent. Any git error ⇒
`null`.
New nullable column `lanes.project_root TEXT` (add in `server/db.js` with the
existing probe-then-ALTER pattern under the migrate lock; index
`idx_lanes_project_root`). Written by:
- `createLane` (both branches of `POST /api/lanes/ensure`, `POST /…/worktree`, CLI `lanes add`) — computed once at creation.
- A one-time backfill at server start for rows with `project_root IS NULL`
(same place as the other `user_version` repairs; bump `user_version`).
- `POST /api/lanes/:id/refresh-project` — recompute on demand (the
sidebar's row menu "Re-detect project"). Never on the polled list.
Why stored, not computed: `GET /api/lanes` is polled and re-broadcast on
every hook; `CLAUDE.md` forbids shelling out there.
`GET /api/projects` (new route file `server/routes/projects.js`):
```jsonc
{
"projects": [
{
"root": "/home/u/agenthub", // null for the "Folders" pseudo-project
"name": "agenthub", // basename(root); overridable later
"workspaces": [12, 15], // lane ids, in id order
"worktrees": [ // from `git worktree list --porcelain` on root
{ "path": "/home/u/agenthub", "branch": "main", "head": "e01cb26", "main": true, "workspace_id": 12 },
{ "path": "/tmp/ah-release", "branch": "ah-release-1823", "head": "351afb2", "main": false, "workspace_id": null },
{ "path": "/home/u/agenthub/.claude/worktrees/wf_…-1", "branch": "wf-22-macos", "head": "…", "main": false, "workspace_id": null, "source": "claude" }
]
},
{ "root": null, "name": "Folders", "workspaces": [3], "worktrees": [] }
]
}
```
- `workspace_id` is the lane whose `cwd` equals the worktree path (realpath
compare), else null. A lane on a *subdirectory* of a worktree appears in
`workspaces` but marks no worktree.
- `source` is `"claude"` when the path is under `<root>/.claude/worktrees/`,
`"agenthub"` when under `LANES_ROOT`, else absent. The client uses it for the
visibility toggles.
- Worktree lists are read through the existing `listWorktrees` in
`server/lib/worktree.js`, cached per root for 30 s (in-memory `Map`,
invalidated by any lane create/remove and by `POST /api/projects/refresh`).
Shelling out happens here, on an endpoint the client fetches on demand, not on
the lane list.
- Order: projects by name, the `Folders` pseudo-project last.
### Client
`client/src/hooks/useProjects.ts`: fetches `/api/projects` on mount, on every
`lane`/`lanes` websocket message (debounced 500 ms), and on a manual refresh.
Joins with the already-polled workspace list (`useWorkspaces` or whatever the
page uses today) so each sidebar row has the live `status`, `liveness`,
`needs_action`, `tool`, `branch`, `title`.
## 2. Sidebar
`client/src/components/Sidebar.tsx` today: brand, 5 `NavLink`s, language,
theme, connection dot, collapse toggle. Keep all of it; insert the tree between
the nav and the footer.
```
┌──────────────────────────────┐
│ [SmartGift] AgentHub │ brand (unchanged)
│ ▣ Dashboard │
│ ▤ Sessions │ NAV_KEYS unchanged (Dashboard, Sessions,
│ ⟳ Workflows │ Workflows, Workspace, Settings)
│ ▶ Workspace │
│ ──────────────────────────── │
│ PROJECTS ⟳ + │ 11px uppercase, tracking-wide, fg-muted
│ ▾ ◈ agenthub + ⋯ │ project row: chevron, folder icon, name, "+ workspace", menu
│ ● main primary ✳ │ workspace row (see below)
│ ◐ ah-release-1823 ⌘ │
│ ○ wf-22-macos adopt │ untracked worktree row (dimmed)
│ ▸ ◈ other-repo │
│ ▸ ▭ Folders │
│ ──────────────────────────── │
│ EN · ◐ · ● ‹ │ footer (unchanged)
└──────────────────────────────┘
```
- The tree renders only when the current route is `/workspace`? **No** — it
renders on every page; clicking a workspace row navigates to
`/workspace?ws=<id>`. This is what makes the sidebar the one place to pick a
workspace.
- Collapsed sidebar (rail, 4.25rem): the tree collapses to one icon
(`FolderTree` from lucide) that expands the sidebar on click. No tree in the
rail.
- Width: keep `15rem` fixed in this phase. (Resizable sidebar is a follow-up;
Orca does 220–500 px.)
- Scroll: the tree is the only scrolling region (`overflow-y-auto min-h-0`);
nav and footer stay pinned.
- Persisted UI state (localStorage, one key `agenthub.sidebar.projects`):
`{ collapsed: { [root]: boolean }, showUntracked: boolean, hideSources: string[] }`.
Defaults: nothing collapsed, `showUntracked: true`, `hideSources: []`.
(Phase 2 moves the toggles into Settings; the key stays.)
### Workspace row
`client/src/components/lanes/WorkspaceRow.tsx` (replaces
`LaneStripCard.tsx`, which is deleted with its test):
- Left: status dot, 8 px. Map: `status === "running"` ⇒ `bg-status-success`
pulsing (`animate-pulse-slow`); `needs_action` ⇒ `bg-status-warning` and the
row's background `bg-status-warning/10`; `liveness === "dead"` ⇒
`bg-status-danger`; `error` ⇒ `bg-status-danger`; else `bg-surface-4`.
Exactly the existing `LIVENESS_DOT` semantics plus needs-action — do not
invent new states.
- Title line (13 px, `font-medium`): `title || basename(cwd)`. Badge
`primary` (existing badge style, `text-[10px]`) when `cwd === project.root`.
- Second line (11 px, `fg-muted`): `branch` when present, else the cwd
relative to the project root; trailing `AiToolIcon` for the tool, and the id
as `#12` (the CLI still addresses by id).
- `aria-current="true"` + `bg-accent/10 border-l-2 border-accent` when selected.
- Hover/focus reveals a `⋯` button (ghost, `title=` "Workspace actions") that
opens the same menu as right-click.
- Reorder: drag within a project (reuse `laneOrderStorage.ts`),
Alt+↑/↓ as the keyboard path. Order is per project.
- Double-click the title ⇒ inline rename (the existing rename field from the
detail header, moved here; `PATCH /api/lanes/:id {title}`).
### Context menu (`WorkspaceRowMenu.tsx`)
Native `<div role="menu">` positioned at the pointer (there is no menu
primitive in the repo; copy the small pattern from `LaneToolsMenu` in
`LaneCard.tsx`). Items, in order, each wired to an **existing** call:
1. Start / Stop — `POST /api/lanes/:id/start` (slot 0) / `…/stop`.
2. Rename — enters inline rename.
3. Model… — the `LaneRouterRow` select, rendered inline in the menu's
submenu area (claude/codex only; hidden for other tools).
4. Create/update readme — the existing Tools-menu action (claude only).
5. New workspace here… — opens the Add modal prefilled with this repo
(`prefill` prop already exists).
6. Copy path.
7. Re-detect project — `POST /api/lanes/:id/refresh-project`.
8. ─
9. Reset… — `DestructiveLaneModal` (managed only, as today).
10. Remove… — `DestructiveLaneModal` (record-only, as today).
The server's guards are the real ones; the menu only disables what the
row's `kind`/`tool` makes meaningless.
### Untracked worktree row
Dimmed (`text-fg-muted`), hollow dot, branch name, path on hover (`title=`),
and one button **Adopt** → `POST /api/lanes/ensure {cwd, tool: <default tool>, exact: true}`
then select it. Hidden when `showUntracked` is false or its `source` is in
`hideSources`. The project row shows "N hidden" in `fg-muted` when any are
hidden, clicking it toggles `showUntracked` for the session.
### Project row
Chevron (collapse state persisted), folder icon, name, right-aligned on hover:
`+` ("New workspace in <name>" → Add modal prefilled with `source_repo: root`,
Worktree mode) and `⋯` (Refresh worktrees → `POST /api/projects/refresh`;
Copy path; Hide `.claude/worktrees` here — adds to `hideSources`).
### Add modal
`AddLaneModal` is unchanged in logic. Two small edits:
accept `prefill.sourceRepo` (already there as `prefill`), and when opened from
the PROJECTS header `+` with no project, open in the current Repo/Worktree mode
as today.
## 3. Center: workbench
`client/src/pages/Workspace.tsx`:
- Delete the strip (`data-testid="lane-strip"`, the add tile, `LaneStripCard`
usage). Keep `lane-empty` as the only thing rendered when there are
**no workspaces at all**; when workspaces exist but none is selected, render
a quiet centered hint "Pick a workspace in the sidebar" **above** the
always-mounted `ConsoleArea` (CSS-hidden console, per the one-render-site
rule; see the comment block around `lane-detail` — keep it).
- Selection source of truth: `?ws=<id>` in the URL (`useSearchParams`),
mirrored to `localStorage agenthub.workspace.selected` so a bare `/workspace`
restores the last one. The sidebar sets the param; `Workspace.tsx` reads it.
Drop `selectedLaneId` state in favour of this.
- Detail header: keep it, compacted to one row: status dot + status word,
title (read-only here — rename lives in the sidebar), `LaneRouterRow` select,
pipeline picker + stage chips **only when `pipeline_nodes.length > 0`**
(as today), the Changes-size control at the right edge. Remove the
`infoExpanded` toggle and its button (`lane-detail-toggle`): the header is
one row now and never needs collapsing.
- Panes: keep `ConsoleArea`, `SplitLayoutToggle` (1/2/4) and
`splitViewStorage.ts`. Add a 24 px **pane header** to each pane
(`PaneHeader.tsx`): tool icon + `title || basename(cwd)` + `slot N` + the
run's model when known + a close/stop button for slots 1–3
(`DELETE /api/run/<runId>`). Slot 0's header has no close. This is the
"tab-looking" header, not a tab bar: there is still one terminal per pane.
- Everything else in the page (Add modal, destructive modals, feature picker,
PipelineMap, proof, run form) stays.
## 4. Right panel
`LaneChangesPanel` gains an **activity bar**
along its top edge (icon buttons, 28 px, `title=` names, `aria-pressed`):
| Tab | Content | Status |
| --- | --- | --- |
| Files | the existing Files mode (finder + tree) | exists |
| Changes | the existing overview/review/diff/log modes + `LaneGitHeader` | exists |
| Sessions | **new** `WorkspaceSessionsTab.tsx` | new |
- The current five-mode switcher stays *inside* the Changes tab; Files leaves it
and becomes its own tab. Back-navigation and scroll restoration are
unchanged.
- Sizes Narrow / Medium / Full stay. Default when first opened: Medium.
- The panel's open/size/tab is persisted (`agenthub.workspace.changesOpen`,
`…changesSize` exist; add `…panelTab`).
### Sessions tab
Lists `GET /api/sessions?cwd=<workspace cwd>&tool=<workspace tool>&limit=50`
(both query params exist) newest first: `SessionToolBadge`, first prompt
excerpt or title, message count, relative time, model, cost. Actions per row:
**Resume** (claude only: starts a run with `resumeSessionId`, the same call the
run form's resume picker makes; other tools: hidden) and **Open** (`/sessions/:id`).
A search box filters client-side by title/excerpt. Empty state: "No sessions
in this workspace yet". Reference: `reference/orca-grid.png`.
## 5. Status bar
`client/src/components/StatusBar.tsx`, mounted once in `Layout.tsx` under
`<main>`, 24 px, `border-t`, `text-xs`, full width (the main area gets
`pb-6`). Segments left → right:
1. Connection dot + "Live"/"Disconnected" (moves the current sidebar dot's
semantics here; the sidebar keeps its dot for the collapsed rail only).
Click opens the existing `ConnectionStatusModal`.
2. `N running · M need you` from the workspace list's `counts`.
3. Today's cost + tokens — reuse `GET /api/stats` (`server/routes/stats.js`,
the Dashboard's summary call); do not add an endpoint. Poll at the
Dashboard's own interval, only while the tab is visible.
4. Spacer.
5. Update chip — the `UpdateNotifier`'s state, one-line form ("v1.9.0
available"), click opens its modal.
Right-click: nothing in this phase (Orca's show/hide menu is phase 2).
## 6. i18n
New namespace keys in `workspaces.json` (en + vi): `projects.title`,
`projects.folders`, `projects.newWorkspace`, `projects.refresh`,
`projects.hidden_one/_other`, `row.primary`, `row.adopt`, `row.actions`,
`menu.*` (ten items), `pane.slot`, `panel.files/changes/sessions`,
`sessions.empty/resume/open/search`, `statusbar.running/needYou/today`.
## 7. Tests
- Delete `LaneStripCard.test.tsx`; add `WorkspaceRow.test.tsx` (dot states,
primary badge, adopt button, menu items present/disabled by kind/tool) and
`Sidebar.projects.test.tsx` (grouping, collapse persistence, hidden count).
- `Workspace.test.tsx`, `Workspace.laneReorderRename.test.tsx`: rewrite the
strip/rename cases against the sidebar; keep every console/one-render-site
assertion.
- `WorkspaceSessionsTab.test.tsx`: list, filter, resume call shape.
- Server: `projects.test.js` — `projectRootFor` on a main worktree, a linked
worktree, a subdir, a non-repo; `GET /api/projects` grouping and
`workspace_id` matching; cache invalidation on lane create/remove.
- Snapshots: `screens.snapshot.test.tsx` changes for Workspace and every page
(status bar). Regenerate once, after review.
## 8. Docs
`docs/LANES.md` (sidebar, projects, untracked worktrees, Sessions tab,
status bar), `docs/API.md` (`/api/projects`, `refresh-project`, the new
column), `docs/DATABASE.md` (column), `client/README.md`, `CLAUDE.md` marker
and the "Working-copy facts live at …" rule gains: "project roots are stored at
creation (`lanes.project_root`) and worktree lists are read only by
`GET /api/projects`".
## Acceptance
- Two repos with three workspaces and one untracked worktree render as two
project groups + Folders; adopting the worktree adds a row without reload.
- Selecting a row changes the URL, the console keeps its tmux attachment
(no re-attach on selection change of a *different* pane), and F5 restores
the selection.
- The 4-pane layout still works; closing slot 2's pane kills only
`agenthub-lane-<id>-2`.
- Sessions tab: Resume on a claude session starts a run in slot 0 with that
session id; non-claude workspaces show no Resume.
- `npm run test:server`, `npm run test:client`, `tsc --noEmit`, headers check
all green; before/after screenshots in the PR.
@@ -0,0 +1,150 @@
# Phase 2 — Settings as a full-page grouped navigation
_One PR, after phase 1._
## Goal
Replace the horizontal `PageTabs` bar on `/settings` with a left navigation
grouped under small uppercase headings, one section per route, a search box
that filters sections, and consistent row primitives — the structure in
`reference/orca-settings1.png` / `orca-settings-agents.png` /
`orca-settings-scrolled.png`, in AgentHub's own tokens.
## Non-goals
- No new settings. Every control that exists today is kept, moved, and
reachable from search. (One exception: the worktree-visibility toggles from
phase 1 get their first proper home here.)
- No theme change. No new dependency.
- No change to any server setting route.
## 1. Routes
`App.tsx`: `settings` → `settings/:section?`. `SettingsHub.tsx` reads
`:section`; unknown or missing ⇒ redirect to `settings/general`. The old
`?tool=<id>` query is honoured for one release: `?tool=claude-code` ⇒
`/settings/agents/claude-code`, `?tool=ai-router` ⇒ `/settings/ai-router`, and
`#ai-tools` ⇒ `/settings/agents` with the catalog modal open. Every in-app link
that builds a settings URL (`AddLaneModal`'s AI Tools link, the Claude
tab's cross-links, `Sidebar` nav) is updated to the path form.
Section ids are stable strings (they are URLs and i18n keys):
| Group (i18n `settingsNav.group.*`) | Section id | Content = today's component |
| --- | --- | --- |
| **General** | `general` | `Settings.tsx` sections: `lanesSection` (renamed "Workspaces": housekeeping) + `data` (import, remote sources, clear data) + `about` |
| | `appearance` | `AppearanceSettings` |
| | `notifications` | `Settings.tsx` `notifications` + `alertsHub` (`AlertsNotifications`, `WebhookSettings`) |
| **Agents** | `agents` | `AiToolsSettings` inline (the modal's content as a page; `AiToolsModal` stays for the Add-workspace link) |
| | `agents/claude-code` | `CcConfig` (its own sub-tabs stay inside the page) |
| | `agents/codex` | `CodexConfig` |
| | `agents/<catalog id>` | `AiToolConfig toolId` — one entry per installed, enabled tool without a `launcher` (same rule as today's `catalogTabs`) |
| **Routing** | `ai-router` | `AiRouterConfig` |
| | `pricing` | `PricingSettings` with a vendor filter for *all* vendors (today it is embedded per tool; keep those embeds too — this page is the all-vendors view) |
| **Git** | `git-credentials` | `GitCredentialsConfig` |
| | `worktrees` | **new** `WorktreeVisibilitySettings`: "Show untracked worktrees" switch; per-source rows `.claude/worktrees/*` (Show/Hide), AgentHub-managed (`LANES_ROOT`) (Show/Hide); writes the phase-1 localStorage key |
| **Projects** | `projects/<encoded root>` | **new** `ProjectSettings`: read-only project card (root, main branch, worktree count), its workspaces (link to each), the per-project override of the worktree sources above (`hideSources` keyed by root), and "Forget all workspaces of this project" (loops `POST …/remove`, confirm modal listing them) |
A tool section is hidden while that tool is disabled (same `aiTools.byId(...).enabled` check as today).
## 2. Layout
```
┌ sidebar (app) ┬──────────────────────────────────────────────────────────┐
│ │ ← Back to Workspace │
│ │ [🔍 Search settings Ctrl+F] │
│ │ │
│ │ GENERAL │ General │
│ │ ⚙ General │ Workspace housekeeping, data, about. │
│ │ ◐ Appearance │ ───────────────────────────────────── │
│ │ 🔔 Notifications│ ┌ card ─────────────────────────────┐ │
│ │ AGENTS │ │ Housekeeping │ │
│ │ ▣ AI tools │ │ Run housekeeping [Dry run][Run]│ │
│ │ ✳ Claude Code │ ├───────────────────────────────────┤ │
│ │ ◎ Codex │ │ Data │ │
│ │ ▲ Antigravity │ │ Import history … │ │
│ │ ROUTING … │ └───────────────────────────────────┘ │
│ │ PROJECTS │ │
│ │ ◈ agenthub │ │
└───────────────┴──────────────────┴─────────────────────────────────────────┘
```
- The settings nav is a second column **inside** the page (`w-56`, sticky,
own scroll), not the app sidebar. The app sidebar stays as it is.
- "← Back to Workspace" is a plain link to `/workspace` (keeps `?ws` from
localStorage).
- Content column: `max-w-3xl`, section title `text-2xl font-semibold`, one-line
description `text-sm text-fg-muted`, hairline, then **one card per subsection**
(`.card` as today). Do not nest cards.
- Mobile (`< md`): the nav becomes a `<select>` above the content.
## 3. Primitives (`client/src/components/settings/SettingsRow.tsx`)
```tsx
<SettingsSection title description> // h3 + muted description inside a card
<SettingsRow label description control alignTop?> // 2 columns: label+desc | control (shrink-0)
<SettingsSwitchRow label description checked onChange disabled? />
<SettingsSegmented options value onChange /> // role="radiogroup", existing button styles
```
Every control already in `Settings.tsx`, `AppearanceSettings.tsx`,
`ClaudeAgentHubSettings.tsx`, `CodexHooksSettings.tsx`,
`AntigravityHooksSettings.tsx`, `AiToolConfig.tsx` is re-wrapped in these
rows. `CcConfig.tsx` (172 KB) and `CodexConfig.tsx` keep their internal layout
in this phase — only their outer frame changes. Rows: `py-3` with a
description, `py-2` without; divider `border-b border-border/60` between
rows, none after the last.
## 4. Search
`client/src/lib/settingsSearch.ts`:
```ts
interface SettingsEntry { section: string; title: string; description?: string; keywords?: string[] }
rankSettings(query, entries): { section: string; score: number }[]
```
Scoring (lowercase; exact > prefix > substring): section title 900/850/800,
entry title 700/650/600, description 500/450/400, keywords 300/250/200. Each
section registers its entries in a static `SETTINGS_INDEX` array (one per
`SettingsRow` label at minimum; i18n-resolved at render). While the query is
non-empty the nav shows only matching sections (groups with no match hidden),
the first match is focused, Enter opens it; Esc clears. `Ctrl+F` / `Cmd+F`
focuses the box when the settings page has focus (do not steal it from a
terminal — `/settings` has none, so a plain keydown listener on the page root
is enough).
## 5. i18n
`settings.json`: `settingsNav.group.{general,agents,routing,git,projects}`,
`settingsNav.section.*` (title + description per id), `search.placeholder`,
`search.noMatch`, `back`, `worktrees.*`, `project.*`. Remove `tools.*` keys
that no longer render (keep `tools.label` if `AiToolsModal` still uses it).
## 6. Tests
- `SettingsHub.test.tsx`: route → section, legacy `?tool=` redirect, hidden
tool section when disabled, mobile select.
- `settingsSearch.test.ts`: ranking order, empty query, no match.
- `WorktreeVisibilitySettings.test.tsx`: writes the phase-1 key, per-project
override.
- Existing panel tests unchanged except their mount path.
- Snapshots: Settings screens regenerate once.
## 7. Docs
`docs/LANES.md` (worktree visibility, per-project settings), `README.md`
(settings tour), `client/README.md` (route list, primitives), `CLAUDE.md`
marker: "Settings is `/settings/:section` with a grouped nav; a tool's own
settings live under `agents/<id>`; the whole-page search index is
`SETTINGS_INDEX`".
## Acceptance
- Every control reachable before is reachable after (walk `Settings.tsx`'s
section list and each tool tab, tick them off in the PR body).
- `/settings?tool=codex` lands on `/settings/agents/codex`.
- Typing "pricing" in search shows Routing → Pricing and the Claude/Codex
pages (both embed the table); Enter opens the first.
- Keyboard: Tab order follows the visual order; `Ctrl+F` focuses search.
- Suites + headers + snapshots green; before/after screenshots.
@@ -0,0 +1,131 @@
# Phase 3 — Two Orca-derived named themes and notification sounds
_One PR, after phase 2. Small._
## Goal
1. Offer Orca's neutral palette as two **optional** named themes — "Orca Dark"
and "Orca Light" — in the existing Appearance picker. Defaults do not
change; nothing else about the theme system changes.
2. Play a short sound with a browser notification, per category, user-chosen.
## 1. Themes
`client/src/hooks/useTheme.ts` — extend the registry only:
```ts
export const THEMES = {
dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
} as const …
```
`client/src/index.css` — two override blocks shaped exactly like
`.dark[data-theme="darcula"]` (every token the other named themes set, no
more, no fewer; comment each with the hex). Values mapped from Orca's
`src/renderer/src/assets/main.css` (`.dark` / `:root`) onto AgentHub's roles:
| AgentHub token | Orca Dark | Orca Light |
| --- | --- | --- |
| `--surface-0` (page) | `#0a0a0a` | `#ffffff` |
| `--surface-1` | `#111111` | `#fafafa` |
| `--surface-2` | `#171717` (card) | `#f5f5f5` (muted) |
| `--surface-3` (`.card`) | `#1c1c1c` | `#ffffff` |
| `--surface-4` | `#262626` (secondary/muted) | `#ececec` |
| `--surface-5` | `#404040` (accent/hover) | `#e5e5e5` |
| `--border` | `#1f1f1f` (≈ white 7 % on #0a0a0a) | `#e5e5e5` |
| `--border-light` | `#2e2e2e` (≈ white 15 %) | `#d4d4d4` |
| `--accent` | `#e5e5e5` (Orca `primary`) — **check contrast of accent-on-surface text; if the UI uses `text-accent` on `surface-3`, use `#3b82f6` instead and note it** | `#171717` (same caveat) |
| `--accent-hover` | `#fafafa` | `#262626` |
| `--fg-primary` | `#fafafa` | `#0a0a0a` |
| `--fg-secondary` | `#d4d4d4` | `#404040` |
| `--fg-muted` | `#a1a1a1` | `#737373` |
| `--status-success` | `#86efac` | `#15803d` |
| `--status-warning` | `#eab308` | `#ca8a04` |
| `--status-danger` | `#ff6568` | `#e40014` |
| `--syn-*` | copy from `apple-dark` | copy from `apple-light` |
The light theme's surfaces must still *rise in lightness with their index* as
`index.css` documents; the table above honours that except `surface-3` =
`#ffffff` on `surface-0` = `#ffffff` — set `--surface-0` to `#f5f5f5` and
`--surface-1` to `#fafafa` so the card lifts. State the final values in the
PR.
Verify with the existing contrast rule (`fg-muted` ≥ 4.5:1 on `surface-3`,
status labels ≥ 4.5:1 on their densest chip) — the Appearance swatch test
(`Sidebar.theme.test.tsx` / `AppearanceSettings` tests) covers the picker;
add the two names to whatever list those tests assert.
`terminalTheme.ts` already derives the xterm palette from the tokens; confirm
it reads the new blocks live when switching.
i18n: `settings.json` `appearance.themes.orca-dark` / `orca-light` (en: "Orca
Dark"/"Orca Light", vi same).
## 2. Notification sounds
### Prefs
`client/src/lib/notifPrefs.ts`:
```ts
export type NotifSound = "none" | "chime" | "pop" | "alert";
export interface NotifPrefs {
…existing…
sound: NotifSound; // global default
soundByType?: Partial<Record<keyof Omit<NotifPrefs, "enabled" | "sound" | "soundByType">, NotifSound>>;
}
defaultNotifPrefs.sound = "none";
```
`loadNotifPrefs` already spreads defaults over stored JSON, so existing users
get `"none"`.
### Assets
`client/public/sounds/{chime,pop,alert}.wav`, each ≤ 20 KB, generated — not
downloaded — by `scripts/gen-notification-sounds.js` (Node, PCM 16-bit 22.05 kHz
mono, two or three sine partials with a short decay; commit the WAVs *and* the
script; the script carries the file header). No licence question, no network.
### Playback
`client/src/lib/notify.ts`: `playSound(kind)` — a module-level
`Map<NotifSound, HTMLAudioElement>` created lazily, `audio.currentTime = 0;
audio.play().catch(() => {})` (autoplay may be refused before a user gesture;
that is fine). `useNotifications.ts`: in `notify()`, after deciding to show
(native, push or local fallback), resolve `soundByType[type] ?? sound` and
play it when not `"none"`. Play whenever a notification is shown, visible tab
or not; the per-type setting is the user's volume control. No focus
heuristics.
### Settings UI
Phase-2 `notifications` section: a `SettingsRow` "Sound" with a `<select>`
(None / Chime / Pop / Alert) and a ▶ preview button; under each existing
per-type switch, a small "Use default · Chime · Pop · Alert · None" segmented
control (`SettingsSegmented`) that writes `soundByType[type]`. i18n:
`notifications.sound.*`.
## Tests
- `notifPrefs.test.ts`: defaults, legacy JSON without `sound` loads as
`"none"`, `soundByType` round-trip.
- `useNotifications` test: `playSound` called with the per-type value, not
called for `"none"`; stub `HTMLMediaElement.prototype.play`.
- Theme: picker lists the two new names; `data-theme` attribute set; snapshot
of Appearance regenerated once.
## Docs
`client/README.md` (themes list, sounds), `README.md` feature list,
`CLAUDE.md` marker ("named themes gained `orca-dark`/`orca-light`; a sound per
notification category, assets generated by `scripts/gen-notification-sounds.js`").
## Acceptance
- Switching to Orca Dark/Light re-colours every surface, the xterm palette and
the diff view with no hardcoded colour leaking (walk Dashboard, Sessions,
Workspace with the Changes panel open, Settings).
- A turn-complete notification with sound "Pop" plays once; with "None" plays
nothing; preview button plays immediately.
+80
View File
@@ -0,0 +1,80 @@
# Workspace redesign — overview and phase plan
_Status: specs written 2026-10-01, nothing implemented yet. Each phase is one PR, executed in order; a later phase assumes the earlier ones are merged._
## Why
AgentHub's lane surface grew one card at a time: a horizontal lane strip on
`/workspace`, a detail header below it, a Changes panel beside that, and a
Settings page made of horizontal tabs. Orca (https://github.com/stablyai/orca,
MIT, source cloned at `/tmp/orca-repo` while these specs were written) solved
the same problem — many agent worktrees on one screen — with a left-hand
**project → workspace** tree, a terminal workbench in the middle, an
activity-bar panel on the right and a full-page grouped Settings screen.
We are **borrowing Orca's information architecture, not its look**. The
decisions below are fixed for every phase:
| Decision | Value |
| --- | --- |
| Visual theme | **Unchanged.** Keep AgentHub's tokens, dark Radix slate + blue accent, warm light theme, the named themes, Inter + JetBrains Mono. Orca's neutral palette is added only as two more *named* themes in phase 3. |
| Terminology | **Only the word changes**: "lane" → "workspace" in UI copy (en + vi), docs prose, CLI output prose (phase 0). Every identifier stays — `/api/lanes`, `agenthub lanes`, `--lane`, `AGENTHUB_LANE_ID`, DB tables, tmux names, component/file/type names, i18n keys, test ids. |
| Grouping | Sidebar lists **Projects** (one per git repo = the main worktree's root), each expanded into its **workspaces** (one per lane) and the repo's **untracked worktrees** (from `git worktree list`), the latter shown by default with a user setting to hide them. |
| Existing pages | Dashboard, Sessions, Workflows all stay as top-level nav items. Nothing is removed. |
| Split panes | The existing 1 / 2 / 4 pane layout (`splitViewStorage.ts`) stays and gains a tab-style header per pane. |
| Changes panel | Keeps its Narrow / Medium / Full-screen sizes; gains an activity bar (Files · Changes · Sessions). |
| Status bar | A new one-line bar at the bottom of the shell: connection, running / needs-you counts, today's cost, update chip. **No provider quota** — reading Claude quota rotates the stored OAuth token (`cc-usage.js`), and the CLI shows it inside the pane anyway. |
| Per-project settings | Phase 2. |
| Notification sounds | Phase 3. |
| Fonts | Unchanged: UI Inter (`client/tailwind.config.js`), terminal JetBrains Mono 13px (`TerminalView.tsx`). The `claude` CLI has no font of its own — it renders in whatever font the hosting terminal uses. |
## Phases
| # | Spec | Scope | Size |
| --- | --- | --- | --- |
| 0 | [00-rename-lane-to-workspace.md](00-rename-lane-to-workspace.md) | Copy-only rename: i18n values, escaped UI literals, CLI prose, docs. No identifier changes. | small, mechanical |
| 1 | [01-sidebar-and-workbench.md](01-sidebar-and-workbench.md) | Project tree in the sidebar, lane strip removed, workspace cards with status dot + context menu, untracked worktrees, pane tab headers, right-panel activity bar with a Sessions tab, status bar. | large |
| 2 | [02-settings.md](02-settings.md) | Settings as a full-page grouped nav with search, `/settings/:section` routes, row primitives, per-project pane, worktree-visibility setting moved here. | medium |
| 3 | [03-appearance-and-sounds.md](03-appearance-and-sounds.md) | Two Orca-derived named themes, notification sounds per category. | small |
Phase 1 may start before phase 0 is merged; its new UI strings must already
say "workspace". New code keeps using the existing `Lane*` identifiers,
`api.lanes` and `/api/lanes` — only new *user-facing* text uses the new word.
## Ground rules for every phase
- Follow the repo's `CLAUDE.md` rules verbatim. Three bind these specs hardest:
the console has **exactly one render site** and collapses with CSS; the
console never writes a lane's stage; lane git facts come from
`GET …/:id/git`, never from the list endpoint.
- Every new or edited `.ts/.tsx/.js/.css` file starts with the header from
`skills/file-headers/` (`bash skills/file-headers/scripts/check-headers.sh`
must exit 0).
- Backend changes: `npm run test:server` (private tmux server — never run the
suites bare). Frontend: `npm run test:client`; snapshot baselines in
`client/src/pages/__tests__/screens.snapshot.test.tsx` are regenerated with
`cd client && npx vitest run -u` **only after** the diff was reviewed as the
intended change, and the PR says which screens changed.
- Docs are part of the change-set: `CLAUDE.md` (its sync marker and any
statement made wrong), `docs/API.md`, `docs/CLI.md`, `docs/LANES.md`
(file name kept; prose says "workspace"), `client/README.md`, `README.md`. Apply the
`update-project-docs` skill before finishing.
- i18n: every new string goes into **both** `client/src/i18n/locales/en/*.json`
and `…/vi/*.json` (see `docs/I18N.md`); no hardcoded UI text.
- No new runtime dependency unless the spec names it. The specs name none.
- Keep response shapes backward compatible; add fields, never rename or drop
one in the same release as its consumer.
- Each PR ends with a before/after screenshot of every changed screen in the
PR body (the Orca reference screenshots used for these specs live in
`docs/plans/workspace-redesign/reference/`).
## Verification matrix (all phases)
| Check | Command |
| --- | --- |
| Server suite | `npm run test:server` |
| Client suite | `npm run test:client` |
| Type check | `cd client && npx tsc --noEmit` |
| Headers | `bash skills/file-headers/scripts/check-headers.sh` |
| Build | `npm run build` |
| Manual | `npm run dev`, open `/workspace`, `/settings`, create a workspace from the sidebar, start a run, resume a session from the Sessions tab, switch theme Dark/Light/System on every changed screen |
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+128
View File
@@ -0,0 +1,128 @@
# Workspace = Claude-Desktop-style project (v2 of the workspace redesign)
_Replaces the Orca-shaped project → workspace → worktree tree from phase 1.
Three phases, each one commit on `main`, in order._
## What the user asked for
1. Merge the **Workflows** page into the Dashboard → **Analytics** tab.
2. The sidebar lists **workspaces**, each expanding to **its sessions only** —
like Claude Desktop's project list (name ›, `+` on the row, sessions under it).
3. A workspace opens on a **project home** like Claude Desktop's: title,
prompt box, Recents; on the right, **Instructions**, **Memory**,
**Context** (files/documents) and **Folder** (folders) — no Scheduled.
4. Clicking a session **opens its tmux pane** and never resumes it (a resume
re-reads the whole transcript and burns tokens).
5. The Orca parts are regrouped by workspace and kept where they still make sense.
## Phase A — Workflows inside Analytics
- `Dashboard.tsx` Analytics → the existing `workflow` sub-tab renders the whole
`Workflows` page body (pipelines, skill usage, Workflow-tool runs, DAG, error
map, complexity, compaction, drill-in) under its current small cards.
- `/workflows` → `<Navigate to="/?tab=analytics&sub=workflow">`; the sidebar
loses its **Workflows** item; `DocumentTitle`, i18n `nav.workflows` removed.
- No server change.
## Phase B — Sidebar: workspaces → sessions
```
WORKSPACES + 🔍 ⚙
agenthub › +
SG_MiniApp ⌄ +
● Tách thông tin official account (● = a live tmux pane)
○ Kiến trúc tenant đơn mở rộng
scratch › +
```
- A flat list in the stored drag order (`agenthub.workspace.laneOrder`, kept);
the repo grouping, the "Folders" pseudo-project, untracked worktrees and
Adopt leave the sidebar.
- A row's chevron expands its sessions (`GET /api/sessions?cwd=&tool=`, the
newest 20, the session's name or first prompt). A dot shows a live pane.
- Click the **name** → the workspace home (Phase C). Click `+` → the home with
the prompt focused. Click a **session**:
- its pane is alive (a `dashboard_runs` row with that `session_id`, the
tmux session `agenthub-lane-<id>[-slot]` still there) → the session view
attached to that pane — no new process, no resume;
- no live pane → the read-only transcript (`/sessions/:id`, Conversation tab),
with **Resume** as a separate, explicit button that warns about context cost.
- Kept: rename (double-click), drag / Alt+↑↓ reorder, right-click menu
(Start/Stop, Rename, router, readme, Copy path, Reset, Remove).
- Header: `+` new workspace (the Add modal), search (filters workspaces and
loaded session titles), a tool filter.
## Phase C — Workspace home + sources
Route: `/workspace?lane=<id>` = home; `/workspace?lane=<id>&run=<runId>` = the
session view (today's terminal, split panes and the Files/Changes panel,
unchanged — the one-render-site console rule still holds).
```
Workspaces / SG_MiniApp 📌 ⋯
┌────────────────────────────────────────────┐ ┌ Instructions ✎ ┐
│ How can I help you today? │ │ text… │
│ + [Claude ▾] [model ▾] [effort ▾] [Run]│ ├ Memory View ────┤
└────────────────────────────────────────────┘ │ CLAUDE.md, auto │
Recents ├ Context + ─┤
Tách thông tin official account 2h ago ● │ spec.pdf notes… │
Kiến trúc tenant đơn mở rộng Sep 29 ├ Folder + ─┤
│ 📁 SG_MiniApp │
│ 📁 sg-shared │
└──────────────────┘
```
- **Prompt box** = today's run form (tool's model picker, permission, effort,
bypass), compact. Run → a new pane, then the session view.
- **Recents** = the same session list as the sidebar, with time ago, cost and
the live dot.
- **Instructions** — one text per workspace, `lanes.instructions`. Reaches the
agent at spawn: Claude `--append-system-prompt <text>`; Codex
`-c developer_instructions=<text>` if 0.155 accepts it (verified while
building — otherwise Claude-only, shown as "not supported by <tool>").
Never written into the user's repo.
- **Memory** — read-only: the folder's `CLAUDE.md` / `AGENTS.md` and Claude's
auto-memory dir for that cwd (`<CLAUDE_HOME>/projects/<slug>/memory/`).
**View memory** opens them in the existing markdown viewer.
- **Context** — files the user uploads (pdf, md, txt, images…, ≤ 10 MB each),
stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
server-chosen name. The directory goes to the agent as `--add-dir`, and the
appended instructions list the files, so the agent knows to read them.
- **Folder** — the workspace's own cwd (fixed) plus extra folders, each
`--add-dir` for Claude and Codex (both verified in `--help`); other tools show
them as unsupported. **+** opens a folder picker that also suggests the repo's
untracked worktrees (what is left of Adopt). A folder must exist, be a
directory and not be or contain the OS home (`validateHomeDir`).
- **⋯ menu** = the row menu plus what Settings → Projects held (facts,
worktree count, Forget).
### Schema (migration-safe)
- `lanes.instructions TEXT` (ADD COLUMN, null).
- `CREATE TABLE IF NOT EXISTS lane_sources (id INTEGER PRIMARY KEY, lane_id
INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE, kind TEXT NOT NULL
CHECK (kind IN ('folder','file')), path TEXT NOT NULL, name TEXT NOT NULL,
size INTEGER, added_at TEXT NOT NULL)`.
- Removing a workspace deletes its rows and AgentHub's own context copies —
never a user folder.
### API
- `GET/PUT /api/lanes/:id/instructions`
- `GET /api/lanes/:id/sources`, `POST …/sources/folder {path}`,
`POST …/sources/file` (raw body, name header, size cap),
`DELETE …/sources/:sourceId` — all `sameOriginGuard`.
- `GET /api/lanes/:id/memory` → the memory files above, read-only.
- Spawn (`spawnRun`) reads instructions + sources from the lane row, never
from the request — the same rule as `tool`.
### Settings
- **Projects** group leaves Settings (now the workspace's ⋯ menu).
- **Worktrees** stays, renamed "Worktree suggestions": whether Add folder
suggests untracked worktrees and from which sources.
## Verification
Each phase: tests first for new logic, `npm run test:server`,
`npm run test:client`, headers, build, screenshots; docs + CLAUDE.md marker.