feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces
The Workspace page's lane strip moves into the sidebar as a project tree: workspaces grouped by git repository (new GET /api/projects, cached in memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline rename, drag/Alt+arrow ordering and a right-click row menu. The selected workspace is the URL's ?lane=, one shared lane list feeds the tree, the new bottom status bar and the page. The Changes panel gains an activity bar (Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
+28
@@ -1120,6 +1120,34 @@ POST /api/locks/:name/release
|
||||
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
|
||||
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
|
||||
|
||||
#### Projects (workspaces grouped by repository)
|
||||
|
||||
```http
|
||||
GET /api/projects
|
||||
POST /api/projects/refresh
|
||||
```
|
||||
|
||||
Every workspace grouped under the git repository it belongs to, for the sidebar's project tree. A project's `root` is the repo's MAIN worktree top level (`git rev-parse --git-common-dir`, realpath); workspaces outside any repo form the `root: null` pseudo-project, listed last. `worktrees` is `git worktree list` for that repo (a worktree whose directory is gone is left out); `workspace_id` is the lane sitting exactly on that path, else `null` — an **untracked worktree** the UI offers to adopt. A lane on a subdirectory of a worktree is in `workspaces` but marks no worktree. `source` is `"claude"` under `<root>/.claude/worktrees/`, `"agenthub"` under `LANES_ROOT`, else absent.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"root": "/home/u/agenthub",
|
||||
"name": "agenthub",
|
||||
"workspaces": [12, 15],
|
||||
"worktrees": [
|
||||
{ "path": "/home/u/agenthub", "branch": "main", "main": true, "workspace_id": 12 },
|
||||
{ "path": "/home/u/agenthub/.claude/worktrees/wf-1", "branch": "wf-1", "main": false, "workspace_id": null, "source": "claude" }
|
||||
]
|
||||
},
|
||||
{ "root": null, "name": "Folders", "workspaces": [3], "worktrees": [] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Both git reads are cached in memory — a directory's root for the life of the server, a repo's worktree list for 30 s — and the lane ↔ worktree match is recomputed on every call, so a new or removed workspace needs no invalidation. This route is read on demand (the client re-reads it when the set of workspaces changes), never on the polled `GET /api/lanes`. `POST /api/projects/refresh` drops both caches first and answers the same shape; it is same-origin only (`403 EBADORIGIN`).
|
||||
|
||||
### Sessions
|
||||
|
||||
#### List Sessions
|
||||
|
||||
+24
-13
@@ -389,16 +389,18 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
|
||||
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the workspace list below already shows every workspace with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **Workspace list** — the horizontally scrolling row of workspace tiles. Its FIRST tile is **Add workspace** (dashed border, no liveness dot), not a header button: creating a workspace is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the workspace's id as a bare `#23` — not "Workspace 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a workspace.
|
||||
- **No workspaces yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the workspace tiles — only renders once at least one workspace exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose workspace is gone is skipped.
|
||||
- **Renaming a workspace** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the workspace's `cwd` back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (no "Workspace 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's project tree shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **The sidebar's project tree picks the workspace** — on every page, between the nav and the footer (`client/src/components/ProjectTree.tsx`). Workspaces are grouped under **Projects**: one per git repository, keyed by the repo's MAIN worktree root, with every workspace outside a repo under **Folders** (last). The grouping comes from `GET /api/projects` (see `docs/API.md`), read when the set of workspaces changes or on the header's Refresh — never on every status frame, so it shells out to git only then, and caches what it read. A row (`WorkspaceRow.tsx`) shows a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name, a `primary` badge on the repo's own root, the branch (or the path relative to the repo), the tool's icon and the id as `#23` — the number the CLI addresses it by. Clicking a row opens `/workspace?lane=<id>`: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The project's `+` opens the Add-workspace modal prefilled with that repo; the PROJECTS header has Refresh and a plain `+`. In the collapsed rail the tree is a single icon that opens the sidebar.
|
||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||
- **Untracked worktrees** — a repo's worktrees that no workspace sits on (from `git worktree list`) are listed dimmed under the project, with **Adopt** (`POST /api/lanes/ensure {exact: true}` with the default AI tool, then selected). Shown by default; the tree's preferences (`agenthub.sidebar.projects` in `localStorage`: collapsed projects, `showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) can hide them, and the project row then says "N hidden" — a click shows them for this visit.
|
||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering** — drag a row onto another within its project, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END of its project; a stored id whose workspace is gone is skipped.
|
||||
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) and the split-view toggle. The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
|
||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the workspace list's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane workspace id from an older build is ignored.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the sidebar's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). In split view each pane carries a one-line header — tool icon, workspace name, `slot N`, the run's model — and panes 1–3 a close button that kills only that pane's own session (`DELETE /api/run/agenthub-lane-<id>-<slot>`); slot 0 has none. A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane workspace id from an older build is ignored.
|
||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
@@ -615,9 +617,18 @@ which one is "work" changes several times an hour, so the width is a step, not
|
||||
a setting — and one button beats a width button wedged beside a collapse button
|
||||
in the panel's tightest corner. It is icon-only, and the icon it draws is the
|
||||
width the next click LANDS on (even split → wider → closed), with the wording
|
||||
left to its tooltip. It
|
||||
reads the workspace's git surface in five modes (**Files** sits first: it is the
|
||||
only one that answers "show me the code" rather than "show me the change"):
|
||||
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
|
||||
of three icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
|
||||
**Files** (the browse mode below, on its own — no git header, no mode
|
||||
switcher), **Changes** (the git header and the other four modes) and
|
||||
**Sessions** — the workspace's sessions (`GET /api/sessions?cwd=<cwd>&tool=<tool>`,
|
||||
newest 50; an exact cwd match, so a session started in a subdirectory is not
|
||||
listed) with tool badge, title, model, relative time and cost, a filter box,
|
||||
**Open** (its detail page) and, on a Claude workspace, **Resume**, which resumes
|
||||
it in the console's slot-0 pane through the same path as a resume from the run
|
||||
history. At Full screen the Files and Changes tabs show the same thing: the
|
||||
explorer sidebar beside the changes. It
|
||||
reads the workspace's git surface in five modes:
|
||||
|
||||
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
|
||||
(`/git/tree`), which expands folders in place rather than navigating into
|
||||
@@ -1189,9 +1200,9 @@ key back onto its node, so a stage declared by alias keeps its record — and it
|
||||
node has both a canonical record and an alias record, the canonical one wins.
|
||||
|
||||
The same rule governs `agenthub lanes`: the inferred stage is printed only when it
|
||||
leads the declared one (see "Viewing workspaces" below), and `LaneStripCard.tsx`'s
|
||||
`detectionLeads` shows its badge under the identical condition — the terminal
|
||||
and the browser never disagree about which one is the headline. Both locate the
|
||||
leads the declared one (see "Viewing workspaces" below), and the PipelineMap's
|
||||
amber detection badge follows the identical condition — the terminal and the
|
||||
browser never disagree about which one is the headline. Both locate the
|
||||
declaration by the node the server already resolved it onto (the one whose
|
||||
`state` is `current`), never by looking `lane.stage` up as a node id: most of
|
||||
what a skill declares may be an ALIAS, which is not an id, and an id lookup
|
||||
|
||||
Reference in New Issue
Block a user