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.
@@ -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.