a6bd33bcff
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).
328 lines
16 KiB
Markdown
328 lines
16 KiB
Markdown
# 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.
|