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