Files
nntrivi2001 a6bd33bcff 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).
2026-10-02 13:19:50 +07:00

328 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.