fix(workspace): a new tab shows in its workspace; a row opens its tabs

A tab opened bare from + had no session row until its first hook, and
the sidebar re-read its live panes only when a workspace's status or
session moved, which a tab in slots 1-3 never does: the new tab showed
nowhere. The sidebar now lists a live AI tab whose session has no row
yet as "New session" straight from the run (its session id is known
from the start) and follows the Workspace page's own run-list poll
(LIVE_RUNS_EVENT).

A sidebar workspace row now opens the workspace's live tabs on the pane
it last focused, and its home only when it has none; a Home button at
the left end of the tab bar reaches the home of a workspace whose tabs
keep running.
This commit is contained in:
2026-10-03 16:05:15 +07:00
parent 326dfa9d19
commit 8098f446b6
10 changed files with 205 additions and 21 deletions
+2 -2
View File
@@ -391,11 +391,11 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list 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 workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: 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 header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a tab in slots 1–3 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **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").
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active; the sidebar's workspace row returns to the home. A browser notification for a workspace with a live pane opens it here directly.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row opens a workspace's tabs when it has live ones — on the pane it last focused (`agenthub.workspace.paneLayout`) — and its home when it has none; the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **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, 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; a stored id whose workspace is gone is skipped.