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).
16 KiB
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 (Tooltipviatitle=,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):
/** 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 ofPOST /api/lanes/ensure,POST /…/worktree, CLIlanes add) — computed once at creation.- A one-time backfill at server start for rows with
project_root IS NULL(same place as the otheruser_versionrepairs; bumpuser_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):
{
"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_idis the lane whosecwdequals the worktree path (realpath compare), else null. A lane on a subdirectory of a worktree appears inworkspacesbut marks no worktree.sourceis"claude"when the path is under<root>/.claude/worktrees/,"agenthub"when underLANES_ROOT, else absent. The client uses it for the visibility toggles.- Worktree lists are read through the existing
listWorktreesinserver/lib/worktree.js, cached per root for 30 s (in-memoryMap, invalidated by any lane create/remove and byPOST /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
Folderspseudo-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 NavLinks, 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
(
FolderTreefrom lucide) that expands the sidebar on click. No tree in the rail. - Width: keep
15remfixed 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-successpulsing (animate-pulse-slow);needs_action⇒bg-status-warningand the row's backgroundbg-status-warning/10;liveness === "dead"⇒bg-status-danger;error⇒bg-status-danger; elsebg-surface-4. Exactly the existingLIVENESS_DOTsemantics plus needs-action — do not invent new states. - Title line (13 px,
font-medium):title || basename(cwd). Badgeprimary(existing badge style,text-[10px]) whencwd === project.root. - Second line (11 px,
fg-muted):branchwhen present, else the cwd relative to the project root; trailingAiToolIconfor the tool, and the id as#12(the CLI still addresses by id). aria-current="true"+bg-accent/10 border-l-2 border-accentwhen 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:
- Start / Stop —
POST /api/lanes/:id/start(slot 0) /…/stop. - Rename — enters inline rename.
- Model… — the
LaneRouterRowselect, rendered inline in the menu's submenu area (claude/codex only; hidden for other tools). - Create/update readme — the existing Tools-menu action (claude only).
- New workspace here… — opens the Add modal prefilled with this repo
(
prefillprop already exists). - Copy path.
- Re-detect project —
POST /api/lanes/:id/refresh-project. - ─
- Reset… —
DestructiveLaneModal(managed only, as today). - 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 " → 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,LaneStripCardusage). Keeplane-emptyas 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-mountedConsoleArea(CSS-hidden console, per the one-render-site rule; see the comment block aroundlane-detail— keep it). - Selection source of truth:
?ws=<id>in the URL (useSearchParams), mirrored tolocalStorage agenthub.workspace.selectedso a bare/workspacerestores the last one. The sidebar sets the param;Workspace.tsxreads it. DropselectedLaneIdstate 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),
LaneRouterRowselect, pipeline picker + stage chips only whenpipeline_nodes.length > 0(as today), the Changes-size control at the right edge. Remove theinfoExpandedtoggle and its button (lane-detail-toggle): the header is one row now and never needs collapsing. - Panes: keep
ConsoleArea,SplitLayoutToggle(1/2/4) andsplitViewStorage.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,…changesSizeexist; 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:
- 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. N running · M need youfrom the workspace list'scounts.- 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. - Spacer.
- 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; addWorkspaceRow.test.tsx(dot states, primary badge, adopt button, menu items present/disabled by kind/tool) andSidebar.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—projectRootForon a main worktree, a linked worktree, a subdir, a non-repo;GET /api/projectsgrouping andworkspace_idmatching; cache invalidation on lane create/remove. - Snapshots:
screens.snapshot.test.tsxchanges 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.