# 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 ``` Implementation: `git -C 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 `/.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=`. 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 `
` 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: , 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, `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=` 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/`). 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=&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 `
`, 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--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.