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

16 KiB
Raw Permalink Blame History

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):

/** 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):

{
  "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 NavLinks, 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 " → 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.