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:
@@ -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.
|
||||
Reference in New Issue
Block a user