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).
@@ -0,0 +1,147 @@
|
||||
# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app
|
||||
|
||||
_Status: written 2026-10-02. Phases 1–5 and 7 implemented 2026-10-02
|
||||
(uncommitted); phase 6 waits on decision D4. Phases run in order, each one change-set on `main`.
|
||||
Comes BEFORE `docs/plans/multi-user-server/` — that plan's owner column hangs
|
||||
off the session ownership built in phase 1 here._
|
||||
|
||||
## Where we stand
|
||||
|
||||
The workspace-sessions plan gave AgentHub Claude Desktop's *surface*: a sidebar
|
||||
of workspaces that expand to sessions, a project home with a prompt box,
|
||||
Recents, Instructions / Memory / Context / Folder. Its *data model* is still
|
||||
"a workspace is a folder":
|
||||
|
||||
- Every session list (sidebar `WorkspaceSessionList.tsx`, home Recents in
|
||||
`WorkspaceHome.tsx`, the activity bar's `WorkspaceSessionsTab.tsx`) asks
|
||||
`GET /api/sessions?cwd=<lane.cwd>&tool=`, which filters `s.cwd = ?` exactly.
|
||||
A session typed into a terminal in that folder, or one from months before the
|
||||
workspace existed, is listed as the workspace's own; a session that `cd`'d
|
||||
into a subfolder is not.
|
||||
- Nothing durable records which sessions a workspace started.
|
||||
`dashboard_runs` has `lane_id`, but its key is the pane name
|
||||
(`agenthub-lane-<id>[-slot]`) and `server/lib/dashboard-runs.js` writes it
|
||||
with `INSERT OR REPLACE`, so each Start overwrites the last; `bindRunSession`
|
||||
(`server/routes/hooks.js`) keeps only the pane's CURRENT session, so a
|
||||
`/clear` forgets the previous one.
|
||||
|
||||
Claude Desktop (Code tab) and the Codex app both treat a project as the owner
|
||||
of the sessions/threads started in it, show each one's state, let one be
|
||||
archived, and give a session — not a project — its own worktree.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Value |
|
||||
| --- | --- | --- |
|
||||
| D1 | Sessions in the folder that the workspace did not start | **Not listed** (user's call, 2026-10-02): a workspace shows only its own sessions; the rest stay on the Sessions page. The fold and `?unowned=` / `PATCH {lane_id}` built first were removed again. |
|
||||
| D2 | What "belongs to a workspace" means | `sessions.lane_id` = the lane whose pane held the session (stamped from the hook's `run_id`), or one the user added by hand. First writer wins; only the user moves it. |
|
||||
| D3 | Old history | Not guessed. Only the sessions `dashboard_runs` still names are backfilled; everything older is not listed in any workspace. |
|
||||
| D4 | Per-session worktree (phase 6) | A **child workspace**: a worktree lane with `parent_lane_id`, listed under its parent in the sidebar. Reuses every lane rule (git surface, destroy guard) instead of giving a session its own cwd. Confirm with the user before phase 6. |
|
||||
| D5 | Tool chosen per session instead of per workspace | **Out of scope here.** `lanes.tool` stays immutable. Revisit with the user after phase 6. |
|
||||
| D6 | Environments (SSH/cloud), Routines/Automations, cross-session messaging | **Not built.** AgentHub runs locally and does not orchestrate (`CLAUDE.md`). |
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Scope | Size |
|
||||
| --- | --- | --- |
|
||||
| 1 | Session ownership: `sessions.lane_id`, `GET /api/sessions?lane=`, the "other sessions" fold, **Add to workspace** | medium |
|
||||
| 2 | A session row's state (running / needs you / error), rename, archive | medium |
|
||||
| 3 | Drop the activity bar's **Sessions** tab (third copy of the same list) | small |
|
||||
| 4 | **Fork** a Claude session into a free pane (`--resume <id> --fork-session`) | small |
|
||||
| 5 | Ctrl/Cmd+click a live session in the sidebar opens it in a second pane | small |
|
||||
| 6 | Worktree per session = child workspace (D4) — **ask first** | large |
|
||||
| 7 | Comment on a diff line → text pasted into the session's prompt | medium, optional |
|
||||
|
||||
### Phase 1 — session ownership
|
||||
|
||||
Schema (`server/db.js`, probe-then-ALTER like every other column, inside the
|
||||
migration lock):
|
||||
- `sessions.lane_id INTEGER` (nullable, no FK — a removed lane leaves its
|
||||
sessions, which are the user's history) + `idx_sessions_lane`.
|
||||
- Backfill once: `UPDATE sessions SET lane_id = r.lane_id FROM dashboard_runs r
|
||||
WHERE r.session_id = sessions.id AND sessions.lane_id IS NULL AND r.lane_id IS NOT NULL`.
|
||||
|
||||
Stamping (`server/routes/hooks.js`):
|
||||
- `bindRunSession` already decides when a pane HOLDS a session (a nested
|
||||
`claude -p` the agent runs through Bash inherits `AGENTHUB_RUN_ID` but does
|
||||
not take the pane over). Whenever the run's session is — or becomes — this
|
||||
session, stamp `sessions.lane_id = dashboard_runs.lane_id` where it is null.
|
||||
- A session started from the dashboard with no hooks (Codex without hooks, any
|
||||
catalog tool) is not stamped, so no workspace lists it. Known limit.
|
||||
|
||||
API (`server/routes/sessions.js`):
|
||||
- `GET /api/sessions?lane=<id>` → `s.lane_id = ?`;
|
||||
`?unowned=1` → `s.lane_id IS NULL` (combined with `cwd`+`tool` for the fold).
|
||||
- `PATCH /api/sessions/:id {lane_id}` (number or `null`), `sameOriginGuard`:
|
||||
the lane must exist and have the session's `tool` (`EBADLANE`).
|
||||
Existing `name`/`status` patching is unchanged.
|
||||
|
||||
Client:
|
||||
- `WorkspaceSessionList` and home Recents list `?lane=<id>`; below them a folded
|
||||
"Other sessions in this folder (N)" (`?cwd=&tool=&unowned=1`), each row with
|
||||
**Add to workspace**.
|
||||
- `SessionResumeButton`/`owningLane` unchanged (resume still needs the folder).
|
||||
|
||||
Tests first: server — a hook carrying `run_id` stamps the lane, a nested
|
||||
`claude -p` session does not, the backfill, `?lane=`, `?unowned=1`, PATCH
|
||||
validation. Client — the list asks `lane=`, the fold, Add to workspace.
|
||||
|
||||
### Phase 2 — row state, rename, archive
|
||||
|
||||
- Row dot: live pane (green), the session `awaiting_input_since` set (amber,
|
||||
"needs you"), `status = 'error'` (red), else hollow. Re-read when the lane's
|
||||
`needs_action` moves too.
|
||||
- Rename: double-click a session row → `PATCH /api/sessions/:id {name}`.
|
||||
`syncSessionName` already lets only a Claude `/rename` (custom title)
|
||||
override a non-placeholder name.
|
||||
- Archive: `sessions.archived_at TEXT`; `PATCH {archived: true|false}`; lists
|
||||
for a workspace skip archived rows unless `?archived=1`; the row's hover
|
||||
shows an archive button, the home Recents a "Show archived" toggle.
|
||||
Archiving deletes nothing.
|
||||
|
||||
### Phase 3 — one session list fewer
|
||||
|
||||
`WorkspaceSessionsTab.tsx` and the `sessions` activity-bar tab go; a stored
|
||||
`agenthub.workspace.panelTab = "sessions"` falls back to `changes`. Resume stays
|
||||
on the transcript page (`SessionResumeButton`). `CLAUDE.md`'s "Opening a
|
||||
session never resumes it" rule loses the Sessions-tab mention.
|
||||
|
||||
### Phase 4 — fork
|
||||
|
||||
- `buildClaudeArgv` gains `forkSession` → `--fork-session` after `--resume <id>`
|
||||
(Claude only; refused elsewhere, `EUNSUPPORTED`). `POST /api/lanes/:id/start`
|
||||
passes `forkSession: true` through like `resumeSessionId`.
|
||||
- Transcript page: **Fork** beside **Resume**, same warning, same first-free-slot
|
||||
logic (`freeSlot`). The new session id arrives by hook and is stamped to the
|
||||
workspace by phase 1.
|
||||
|
||||
### Phase 5 — open in a second pane
|
||||
|
||||
Ctrl/Cmd+click on a live session row → `/workspace?lane=<id>&run=<runId>&pane=1`;
|
||||
`Workspace.tsx` switches a 1-pane layout to 2 and hands `attachRequest` to that
|
||||
pane instead of pane 0. A plain click is unchanged.
|
||||
|
||||
### Phase 6 — worktree per session (needs D4 confirmed)
|
||||
|
||||
- `lanes.parent_lane_id INTEGER` (nullable).
|
||||
- Home prompt box: a **Worktree** toggle (git repos only). Run → `POST
|
||||
/api/lanes/worktree` with a generated branch + `parent_lane_id`, then start the
|
||||
prompt in the child's slot 0.
|
||||
- Sidebar: a child lane is listed under its parent's sessions, not as a
|
||||
top-level row. Archive on it = the existing Remove (record only — the
|
||||
worktree stays the user's, per the remove rule) plus a hint to delete it.
|
||||
|
||||
### Phase 7 — diff comment to prompt (optional)
|
||||
|
||||
A **Comment** action on a diff line in `LaneDiffView.tsx` builds
|
||||
`<path>:<line> — <text>` and pastes it into the pane the Changes panel belongs
|
||||
to, through the same client-side `term.paste()` path the image drop uses. No
|
||||
server route.
|
||||
|
||||
## Ground rules
|
||||
|
||||
`CLAUDE.md` binds every phase: one console render site, opening a session never
|
||||
resumes it, migrations probe-then-ALTER under the lock, headers on every
|
||||
touched file, `npm run test:server` + `npm run test:client` (snapshots
|
||||
regenerated only after reviewing the diff), docs + the `CLAUDE.md` marker in the
|
||||
same change-set.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Phase 0 — Call lanes "workspaces" in the UI and docs
|
||||
|
||||
_One PR. No behaviour change, no identifier change. Every test that passed
|
||||
before passes after; only copy and snapshots differ._
|
||||
|
||||
## Goal
|
||||
|
||||
The **word** a user reads changes: "Lane" → "Workspace", "Lanes" →
|
||||
"Workspaces", "Add lane" → "Add workspace", "lane strip" → "workspace list",
|
||||
and so on — in the dashboard UI (both languages), in the docs, in CLI *help
|
||||
and output text*, and in the plugin's skill/command prose.
|
||||
|
||||
## What does NOT change
|
||||
|
||||
Everything that is an identifier stays exactly as it is:
|
||||
|
||||
- API paths and payload keys: `/api/lanes`, `{ lanes, counts }`, `lane_id`, …
|
||||
- WebSocket message types `lane` / `lanes`.
|
||||
- CLI commands and flags: `agenthub lanes add|up|down|…`, `agenthub stage --lane 3`.
|
||||
- Env vars: `AGENTHUB_LANE_ID`, `LANES_ROOT`, `LANE_DEAD_SEC`, …
|
||||
- Error codes (`EBADLANE`, …), tmux session names `agenthub-lane-<id>`.
|
||||
- SQLite tables/columns (`lanes`, `lane_features`, `lane_stage_events`).
|
||||
- Source file, component, hook, type and function names (`LaneCard.tsx`,
|
||||
`LaneChangesPanel`, `useLaneGitFacts`, `listLanes`, `Lane` type, …),
|
||||
`data-testid` values, localStorage keys, i18n **namespace file name**
|
||||
`lanes.json` and its **keys**.
|
||||
|
||||
Rationale: a rename of identifiers touches ~200 files and every running
|
||||
install's tmux panes, hooks and bookmarks, for no user-visible gain. The
|
||||
user-visible gain is the word, so only the word changes.
|
||||
|
||||
## Where the word changes
|
||||
|
||||
| Surface | Action |
|
||||
| --- | --- |
|
||||
| `client/src/i18n/locales/en/*.json`, `…/vi/*.json` | Replace the English word in **values** (not keys): `"Lane"→"Workspace"`, `"lane"→"workspace"`, `"Lanes"→"Workspaces"`, plural/possessive forms. Vietnamese values: "lane"/"làn" → "workspace" (the Vietnamese UI already uses the English loanword "Workspace" in `nav.json`; keep that). Check `lanes.json`, `nav.json`, `settings.json` (`lanesSection.title` = "Workspaces"), `run.json`, `dashboard.json`, `kanban.json`, `errors.json`, `splash.json`. |
|
||||
| Hardcoded UI strings | `grep -rn -i '\blanes\?\b' client/src --include=*.tsx` — any string literal rendered to the user that escaped i18n is moved into i18n with the new word (do not leave it hardcoded). Code comments and identifiers are left alone. |
|
||||
| CLI output and help (`bin/agenthub.js`) | Prose in `console.log`/`console.error`/usage strings: "lane" → "workspace" in the sentence, while the command names in those same sentences stay literal: e.g. `no workspace owns <cwd> — create one with: agenthub lanes add --cwd <cwd>`. |
|
||||
| Docs | `docs/LANES.md`: keep the file name (it is linked from many places and from `CLAUDE.md`), retitle to "Workspaces (lanes)" and open with one paragraph: *"A workspace — called a lane in the API, CLI, database and code — is …"*. Then use "workspace" in prose throughout, keeping every literal command/route/field as is. Same treatment in `README.md`, `docs/API.md`, `docs/CLI.md`, `docs/DATABASE.md`, `docs/HOOKS.md`, `client/README.md`, `server/README.md`, `docs/PLUGINS.md`. |
|
||||
| `CLAUDE.md` | The "## Lanes" heading becomes "## Workspaces (lanes)"; the first bullet gains the one-sentence glossary above; every rule keeps its literal identifiers. Bump the sync marker. |
|
||||
| Plugin skills/commands (`skills/*`, `plugins/agenthub/commands/*`) | Prose only. |
|
||||
| Server-side user-facing text | Error `message` strings that reach the UI (e.g. `"lane not found"`): change the word, keep the `code`. Log lines may stay. |
|
||||
|
||||
## Order of work
|
||||
|
||||
1. i18n values (en, vi) + any escaped literals → `npm run test:client`, review
|
||||
the snapshot diff (copy only), regenerate.
|
||||
2. CLI strings → `npm run test:server` (`lanes-cli.test.js` asserts some
|
||||
output text; update those assertions).
|
||||
3. Server error messages → server suite.
|
||||
4. Docs + `CLAUDE.md` + skills prose.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Opening every page of the dashboard in EN and VI shows no "lane" (walk:
|
||||
Dashboard, Sessions, Workflows, Workspace incl. Add modal / destructive
|
||||
modals / Changes panel / run form, Settings incl. every tool tab).
|
||||
- `agenthub lanes` (no args) and `agenthub help` print "workspace" in prose
|
||||
and `lanes` as the command.
|
||||
- `git diff --stat` shows no renamed files, no changes to `server/routes/`
|
||||
paths, `client/src/lib/api.ts` or `client/src/lib/types.ts`.
|
||||
- Headers check, both suites, snapshots regenerated once.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Phase 2 — Settings as a full-page grouped navigation
|
||||
|
||||
_One PR, after phase 1._
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the horizontal `PageTabs` bar on `/settings` with a left navigation
|
||||
grouped under small uppercase headings, one section per route, a search box
|
||||
that filters sections, and consistent row primitives — the structure in
|
||||
`reference/orca-settings1.png` / `orca-settings-agents.png` /
|
||||
`orca-settings-scrolled.png`, in AgentHub's own tokens.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new settings. Every control that exists today is kept, moved, and
|
||||
reachable from search. (One exception: the worktree-visibility toggles from
|
||||
phase 1 get their first proper home here.)
|
||||
- No theme change. No new dependency.
|
||||
- No change to any server setting route.
|
||||
|
||||
## 1. Routes
|
||||
|
||||
`App.tsx`: `settings` → `settings/:section?`. `SettingsHub.tsx` reads
|
||||
`:section`; unknown or missing ⇒ redirect to `settings/general`. The old
|
||||
`?tool=<id>` query is honoured for one release: `?tool=claude-code` ⇒
|
||||
`/settings/agents/claude-code`, `?tool=ai-router` ⇒ `/settings/ai-router`, and
|
||||
`#ai-tools` ⇒ `/settings/agents` with the catalog modal open. Every in-app link
|
||||
that builds a settings URL (`AddLaneModal`'s AI Tools link, the Claude
|
||||
tab's cross-links, `Sidebar` nav) is updated to the path form.
|
||||
|
||||
Section ids are stable strings (they are URLs and i18n keys):
|
||||
|
||||
| Group (i18n `settingsNav.group.*`) | Section id | Content = today's component |
|
||||
| --- | --- | --- |
|
||||
| **General** | `general` | `Settings.tsx` sections: `lanesSection` (renamed "Workspaces": housekeeping) + `data` (import, remote sources, clear data) + `about` |
|
||||
| | `appearance` | `AppearanceSettings` |
|
||||
| | `notifications` | `Settings.tsx` `notifications` + `alertsHub` (`AlertsNotifications`, `WebhookSettings`) |
|
||||
| **Agents** | `agents` | `AiToolsSettings` inline (the modal's content as a page; `AiToolsModal` stays for the Add-workspace link) |
|
||||
| | `agents/claude-code` | `CcConfig` (its own sub-tabs stay inside the page) |
|
||||
| | `agents/codex` | `CodexConfig` |
|
||||
| | `agents/<catalog id>` | `AiToolConfig toolId` — one entry per installed, enabled tool without a `launcher` (same rule as today's `catalogTabs`) |
|
||||
| **Routing** | `ai-router` | `AiRouterConfig` |
|
||||
| | `pricing` | `PricingSettings` with a vendor filter for *all* vendors (today it is embedded per tool; keep those embeds too — this page is the all-vendors view) |
|
||||
| **Git** | `git-credentials` | `GitCredentialsConfig` |
|
||||
| | `worktrees` | **new** `WorktreeVisibilitySettings`: "Show untracked worktrees" switch; per-source rows `.claude/worktrees/*` (Show/Hide), AgentHub-managed (`LANES_ROOT`) (Show/Hide); writes the phase-1 localStorage key |
|
||||
| **Projects** | `projects/<encoded root>` | **new** `ProjectSettings`: read-only project card (root, main branch, worktree count), its workspaces (link to each), the per-project override of the worktree sources above (`hideSources` keyed by root), and "Forget all workspaces of this project" (loops `POST …/remove`, confirm modal listing them) |
|
||||
|
||||
A tool section is hidden while that tool is disabled (same `aiTools.byId(...).enabled` check as today).
|
||||
|
||||
## 2. Layout
|
||||
|
||||
```
|
||||
┌ sidebar (app) ┬──────────────────────────────────────────────────────────┐
|
||||
│ │ ← Back to Workspace │
|
||||
│ │ [🔍 Search settings Ctrl+F] │
|
||||
│ │ │
|
||||
│ │ GENERAL │ General │
|
||||
│ │ ⚙ General │ Workspace housekeeping, data, about. │
|
||||
│ │ ◐ Appearance │ ───────────────────────────────────── │
|
||||
│ │ 🔔 Notifications│ ┌ card ─────────────────────────────┐ │
|
||||
│ │ AGENTS │ │ Housekeeping │ │
|
||||
│ │ ▣ AI tools │ │ Run housekeeping [Dry run][Run]│ │
|
||||
│ │ ✳ Claude Code │ ├───────────────────────────────────┤ │
|
||||
│ │ ◎ Codex │ │ Data │ │
|
||||
│ │ ▲ Antigravity │ │ Import history … │ │
|
||||
│ │ ROUTING … │ └───────────────────────────────────┘ │
|
||||
│ │ PROJECTS │ │
|
||||
│ │ ◈ agenthub │ │
|
||||
└───────────────┴──────────────────┴─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- The settings nav is a second column **inside** the page (`w-56`, sticky,
|
||||
own scroll), not the app sidebar. The app sidebar stays as it is.
|
||||
- "← Back to Workspace" is a plain link to `/workspace` (keeps `?ws` from
|
||||
localStorage).
|
||||
- Content column: `max-w-3xl`, section title `text-2xl font-semibold`, one-line
|
||||
description `text-sm text-fg-muted`, hairline, then **one card per subsection**
|
||||
(`.card` as today). Do not nest cards.
|
||||
- Mobile (`< md`): the nav becomes a `<select>` above the content.
|
||||
|
||||
## 3. Primitives (`client/src/components/settings/SettingsRow.tsx`)
|
||||
|
||||
```tsx
|
||||
<SettingsSection title description> // h3 + muted description inside a card
|
||||
<SettingsRow label description control alignTop?> // 2 columns: label+desc | control (shrink-0)
|
||||
<SettingsSwitchRow label description checked onChange disabled? />
|
||||
<SettingsSegmented options value onChange /> // role="radiogroup", existing button styles
|
||||
```
|
||||
|
||||
Every control already in `Settings.tsx`, `AppearanceSettings.tsx`,
|
||||
`ClaudeAgentHubSettings.tsx`, `CodexHooksSettings.tsx`,
|
||||
`AntigravityHooksSettings.tsx`, `AiToolConfig.tsx` is re-wrapped in these
|
||||
rows. `CcConfig.tsx` (172 KB) and `CodexConfig.tsx` keep their internal layout
|
||||
in this phase — only their outer frame changes. Rows: `py-3` with a
|
||||
description, `py-2` without; divider `border-b border-border/60` between
|
||||
rows, none after the last.
|
||||
|
||||
## 4. Search
|
||||
|
||||
`client/src/lib/settingsSearch.ts`:
|
||||
|
||||
```ts
|
||||
interface SettingsEntry { section: string; title: string; description?: string; keywords?: string[] }
|
||||
rankSettings(query, entries): { section: string; score: number }[]
|
||||
```
|
||||
|
||||
Scoring (lowercase; exact > prefix > substring): section title 900/850/800,
|
||||
entry title 700/650/600, description 500/450/400, keywords 300/250/200. Each
|
||||
section registers its entries in a static `SETTINGS_INDEX` array (one per
|
||||
`SettingsRow` label at minimum; i18n-resolved at render). While the query is
|
||||
non-empty the nav shows only matching sections (groups with no match hidden),
|
||||
the first match is focused, Enter opens it; Esc clears. `Ctrl+F` / `Cmd+F`
|
||||
focuses the box when the settings page has focus (do not steal it from a
|
||||
terminal — `/settings` has none, so a plain keydown listener on the page root
|
||||
is enough).
|
||||
|
||||
## 5. i18n
|
||||
|
||||
`settings.json`: `settingsNav.group.{general,agents,routing,git,projects}`,
|
||||
`settingsNav.section.*` (title + description per id), `search.placeholder`,
|
||||
`search.noMatch`, `back`, `worktrees.*`, `project.*`. Remove `tools.*` keys
|
||||
that no longer render (keep `tools.label` if `AiToolsModal` still uses it).
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- `SettingsHub.test.tsx`: route → section, legacy `?tool=` redirect, hidden
|
||||
tool section when disabled, mobile select.
|
||||
- `settingsSearch.test.ts`: ranking order, empty query, no match.
|
||||
- `WorktreeVisibilitySettings.test.tsx`: writes the phase-1 key, per-project
|
||||
override.
|
||||
- Existing panel tests unchanged except their mount path.
|
||||
- Snapshots: Settings screens regenerate once.
|
||||
|
||||
## 7. Docs
|
||||
|
||||
`docs/LANES.md` (worktree visibility, per-project settings), `README.md`
|
||||
(settings tour), `client/README.md` (route list, primitives), `CLAUDE.md`
|
||||
marker: "Settings is `/settings/:section` with a grouped nav; a tool's own
|
||||
settings live under `agents/<id>`; the whole-page search index is
|
||||
`SETTINGS_INDEX`".
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every control reachable before is reachable after (walk `Settings.tsx`'s
|
||||
section list and each tool tab, tick them off in the PR body).
|
||||
- `/settings?tool=codex` lands on `/settings/agents/codex`.
|
||||
- Typing "pricing" in search shows Routing → Pricing and the Claude/Codex
|
||||
pages (both embed the table); Enter opens the first.
|
||||
- Keyboard: Tab order follows the visual order; `Ctrl+F` focuses search.
|
||||
- Suites + headers + snapshots green; before/after screenshots.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Phase 3 — Two Orca-derived named themes and notification sounds
|
||||
|
||||
_One PR, after phase 2. Small._
|
||||
|
||||
## Goal
|
||||
|
||||
1. Offer Orca's neutral palette as two **optional** named themes — "Orca Dark"
|
||||
and "Orca Light" — in the existing Appearance picker. Defaults do not
|
||||
change; nothing else about the theme system changes.
|
||||
2. Play a short sound with a browser notification, per category, user-chosen.
|
||||
|
||||
## 1. Themes
|
||||
|
||||
`client/src/hooks/useTheme.ts` — extend the registry only:
|
||||
|
||||
```ts
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
|
||||
} as const …
|
||||
```
|
||||
|
||||
`client/src/index.css` — two override blocks shaped exactly like
|
||||
`.dark[data-theme="darcula"]` (every token the other named themes set, no
|
||||
more, no fewer; comment each with the hex). Values mapped from Orca's
|
||||
`src/renderer/src/assets/main.css` (`.dark` / `:root`) onto AgentHub's roles:
|
||||
|
||||
| AgentHub token | Orca Dark | Orca Light |
|
||||
| --- | --- | --- |
|
||||
| `--surface-0` (page) | `#0a0a0a` | `#ffffff` |
|
||||
| `--surface-1` | `#111111` | `#fafafa` |
|
||||
| `--surface-2` | `#171717` (card) | `#f5f5f5` (muted) |
|
||||
| `--surface-3` (`.card`) | `#1c1c1c` | `#ffffff` |
|
||||
| `--surface-4` | `#262626` (secondary/muted) | `#ececec` |
|
||||
| `--surface-5` | `#404040` (accent/hover) | `#e5e5e5` |
|
||||
| `--border` | `#1f1f1f` (≈ white 7 % on #0a0a0a) | `#e5e5e5` |
|
||||
| `--border-light` | `#2e2e2e` (≈ white 15 %) | `#d4d4d4` |
|
||||
| `--accent` | `#e5e5e5` (Orca `primary`) — **check contrast of accent-on-surface text; if the UI uses `text-accent` on `surface-3`, use `#3b82f6` instead and note it** | `#171717` (same caveat) |
|
||||
| `--accent-hover` | `#fafafa` | `#262626` |
|
||||
| `--fg-primary` | `#fafafa` | `#0a0a0a` |
|
||||
| `--fg-secondary` | `#d4d4d4` | `#404040` |
|
||||
| `--fg-muted` | `#a1a1a1` | `#737373` |
|
||||
| `--status-success` | `#86efac` | `#15803d` |
|
||||
| `--status-warning` | `#eab308` | `#ca8a04` |
|
||||
| `--status-danger` | `#ff6568` | `#e40014` |
|
||||
| `--syn-*` | copy from `apple-dark` | copy from `apple-light` |
|
||||
|
||||
The light theme's surfaces must still *rise in lightness with their index* as
|
||||
`index.css` documents; the table above honours that except `surface-3` =
|
||||
`#ffffff` on `surface-0` = `#ffffff` — set `--surface-0` to `#f5f5f5` and
|
||||
`--surface-1` to `#fafafa` so the card lifts. State the final values in the
|
||||
PR.
|
||||
|
||||
Verify with the existing contrast rule (`fg-muted` ≥ 4.5:1 on `surface-3`,
|
||||
status labels ≥ 4.5:1 on their densest chip) — the Appearance swatch test
|
||||
(`Sidebar.theme.test.tsx` / `AppearanceSettings` tests) covers the picker;
|
||||
add the two names to whatever list those tests assert.
|
||||
|
||||
`terminalTheme.ts` already derives the xterm palette from the tokens; confirm
|
||||
it reads the new blocks live when switching.
|
||||
|
||||
i18n: `settings.json` `appearance.themes.orca-dark` / `orca-light` (en: "Orca
|
||||
Dark"/"Orca Light", vi same).
|
||||
|
||||
## 2. Notification sounds
|
||||
|
||||
### Prefs
|
||||
|
||||
`client/src/lib/notifPrefs.ts`:
|
||||
|
||||
```ts
|
||||
export type NotifSound = "none" | "chime" | "pop" | "alert";
|
||||
export interface NotifPrefs {
|
||||
…existing…
|
||||
sound: NotifSound; // global default
|
||||
soundByType?: Partial<Record<keyof Omit<NotifPrefs, "enabled" | "sound" | "soundByType">, NotifSound>>;
|
||||
}
|
||||
defaultNotifPrefs.sound = "none";
|
||||
```
|
||||
|
||||
`loadNotifPrefs` already spreads defaults over stored JSON, so existing users
|
||||
get `"none"`.
|
||||
|
||||
### Assets
|
||||
|
||||
`client/public/sounds/{chime,pop,alert}.wav`, each ≤ 20 KB, generated — not
|
||||
downloaded — by `scripts/gen-notification-sounds.js` (Node, PCM 16-bit 22.05 kHz
|
||||
mono, two or three sine partials with a short decay; commit the WAVs *and* the
|
||||
script; the script carries the file header). No licence question, no network.
|
||||
|
||||
### Playback
|
||||
|
||||
`client/src/lib/notify.ts`: `playSound(kind)` — a module-level
|
||||
`Map<NotifSound, HTMLAudioElement>` created lazily, `audio.currentTime = 0;
|
||||
audio.play().catch(() => {})` (autoplay may be refused before a user gesture;
|
||||
that is fine). `useNotifications.ts`: in `notify()`, after deciding to show
|
||||
(native, push or local fallback), resolve `soundByType[type] ?? sound` and
|
||||
play it when not `"none"`. Play whenever a notification is shown, visible tab
|
||||
or not; the per-type setting is the user's volume control. No focus
|
||||
heuristics.
|
||||
|
||||
### Settings UI
|
||||
|
||||
Phase-2 `notifications` section: a `SettingsRow` "Sound" with a `<select>`
|
||||
(None / Chime / Pop / Alert) and a ▶ preview button; under each existing
|
||||
per-type switch, a small "Use default · Chime · Pop · Alert · None" segmented
|
||||
control (`SettingsSegmented`) that writes `soundByType[type]`. i18n:
|
||||
`notifications.sound.*`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `notifPrefs.test.ts`: defaults, legacy JSON without `sound` loads as
|
||||
`"none"`, `soundByType` round-trip.
|
||||
- `useNotifications` test: `playSound` called with the per-type value, not
|
||||
called for `"none"`; stub `HTMLMediaElement.prototype.play`.
|
||||
- Theme: picker lists the two new names; `data-theme` attribute set; snapshot
|
||||
of Appearance regenerated once.
|
||||
|
||||
## Docs
|
||||
|
||||
`client/README.md` (themes list, sounds), `README.md` feature list,
|
||||
`CLAUDE.md` marker ("named themes gained `orca-dark`/`orca-light`; a sound per
|
||||
notification category, assets generated by `scripts/gen-notification-sounds.js`").
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Switching to Orca Dark/Light re-colours every surface, the xterm palette and
|
||||
the diff view with no hardcoded colour leaking (walk Dashboard, Sessions,
|
||||
Workspace with the Changes panel open, Settings).
|
||||
- A turn-complete notification with sound "Pop" plays once; with "None" plays
|
||||
nothing; preview button plays immediately.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Workspace redesign — overview and phase plan
|
||||
|
||||
_Status: specs written 2026-10-01, nothing implemented yet. Each phase is one PR, executed in order; a later phase assumes the earlier ones are merged._
|
||||
|
||||
## Why
|
||||
|
||||
AgentHub's lane surface grew one card at a time: a horizontal lane strip on
|
||||
`/workspace`, a detail header below it, a Changes panel beside that, and a
|
||||
Settings page made of horizontal tabs. Orca (https://github.com/stablyai/orca,
|
||||
MIT, source cloned at `/tmp/orca-repo` while these specs were written) solved
|
||||
the same problem — many agent worktrees on one screen — with a left-hand
|
||||
**project → workspace** tree, a terminal workbench in the middle, an
|
||||
activity-bar panel on the right and a full-page grouped Settings screen.
|
||||
|
||||
We are **borrowing Orca's information architecture, not its look**. The
|
||||
decisions below are fixed for every phase:
|
||||
|
||||
| Decision | Value |
|
||||
| --- | --- |
|
||||
| Visual theme | **Unchanged.** Keep AgentHub's tokens, dark Radix slate + blue accent, warm light theme, the named themes, Inter + JetBrains Mono. Orca's neutral palette is added only as two more *named* themes in phase 3. |
|
||||
| Terminology | **Only the word changes**: "lane" → "workspace" in UI copy (en + vi), docs prose, CLI output prose (phase 0). Every identifier stays — `/api/lanes`, `agenthub lanes`, `--lane`, `AGENTHUB_LANE_ID`, DB tables, tmux names, component/file/type names, i18n keys, test ids. |
|
||||
| Grouping | Sidebar lists **Projects** (one per git repo = the main worktree's root), each expanded into its **workspaces** (one per lane) and the repo's **untracked worktrees** (from `git worktree list`), the latter shown by default with a user setting to hide them. |
|
||||
| Existing pages | Dashboard, Sessions, Workflows all stay as top-level nav items. Nothing is removed. |
|
||||
| Split panes | The existing 1 / 2 / 4 pane layout (`splitViewStorage.ts`) stays and gains a tab-style header per pane. |
|
||||
| Changes panel | Keeps its Narrow / Medium / Full-screen sizes; gains an activity bar (Files · Changes · Sessions). |
|
||||
| Status bar | A new one-line bar at the bottom of the shell: connection, running / needs-you counts, today's cost, update chip. **No provider quota** — reading Claude quota rotates the stored OAuth token (`cc-usage.js`), and the CLI shows it inside the pane anyway. |
|
||||
| Per-project settings | Phase 2. |
|
||||
| Notification sounds | Phase 3. |
|
||||
| Fonts | Unchanged: UI Inter (`client/tailwind.config.js`), terminal JetBrains Mono 13px (`TerminalView.tsx`). The `claude` CLI has no font of its own — it renders in whatever font the hosting terminal uses. |
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Spec | Scope | Size |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | [00-rename-lane-to-workspace.md](00-rename-lane-to-workspace.md) | Copy-only rename: i18n values, escaped UI literals, CLI prose, docs. No identifier changes. | small, mechanical |
|
||||
| 1 | [01-sidebar-and-workbench.md](01-sidebar-and-workbench.md) | Project tree in the sidebar, lane strip removed, workspace cards with status dot + context menu, untracked worktrees, pane tab headers, right-panel activity bar with a Sessions tab, status bar. | large |
|
||||
| 2 | [02-settings.md](02-settings.md) | Settings as a full-page grouped nav with search, `/settings/:section` routes, row primitives, per-project pane, worktree-visibility setting moved here. | medium |
|
||||
| 3 | [03-appearance-and-sounds.md](03-appearance-and-sounds.md) | Two Orca-derived named themes, notification sounds per category. | small |
|
||||
|
||||
Phase 1 may start before phase 0 is merged; its new UI strings must already
|
||||
say "workspace". New code keeps using the existing `Lane*` identifiers,
|
||||
`api.lanes` and `/api/lanes` — only new *user-facing* text uses the new word.
|
||||
|
||||
## Ground rules for every phase
|
||||
|
||||
- Follow the repo's `CLAUDE.md` rules verbatim. Three bind these specs hardest:
|
||||
the console has **exactly one render site** and collapses with CSS; the
|
||||
console never writes a lane's stage; lane git facts come from
|
||||
`GET …/:id/git`, never from the list endpoint.
|
||||
- Every new or edited `.ts/.tsx/.js/.css` file starts with the header from
|
||||
`skills/file-headers/` (`bash skills/file-headers/scripts/check-headers.sh`
|
||||
must exit 0).
|
||||
- Backend changes: `npm run test:server` (private tmux server — never run the
|
||||
suites bare). Frontend: `npm run test:client`; snapshot baselines in
|
||||
`client/src/pages/__tests__/screens.snapshot.test.tsx` are regenerated with
|
||||
`cd client && npx vitest run -u` **only after** the diff was reviewed as the
|
||||
intended change, and the PR says which screens changed.
|
||||
- Docs are part of the change-set: `CLAUDE.md` (its sync marker and any
|
||||
statement made wrong), `docs/API.md`, `docs/CLI.md`, `docs/LANES.md`
|
||||
(file name kept; prose says "workspace"), `client/README.md`, `README.md`. Apply the
|
||||
`update-project-docs` skill before finishing.
|
||||
- i18n: every new string goes into **both** `client/src/i18n/locales/en/*.json`
|
||||
and `…/vi/*.json` (see `docs/I18N.md`); no hardcoded UI text.
|
||||
- No new runtime dependency unless the spec names it. The specs name none.
|
||||
- Keep response shapes backward compatible; add fields, never rename or drop
|
||||
one in the same release as its consumer.
|
||||
- Each PR ends with a before/after screenshot of every changed screen in the
|
||||
PR body (the Orca reference screenshots used for these specs live in
|
||||
`docs/plans/workspace-redesign/reference/`).
|
||||
|
||||
## Verification matrix (all phases)
|
||||
|
||||
| Check | Command |
|
||||
| --- | --- |
|
||||
| Server suite | `npm run test:server` |
|
||||
| Client suite | `npm run test:client` |
|
||||
| Type check | `cd client && npx tsc --noEmit` |
|
||||
| Headers | `bash skills/file-headers/scripts/check-headers.sh` |
|
||||
| Build | `npm run build` |
|
||||
| Manual | `npm run dev`, open `/workspace`, `/settings`, create a workspace from the sidebar, start a run, resume a session from the Sessions tab, switch theme Dark/Light/System on every changed screen |
|
||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 139 KiB |
@@ -0,0 +1,128 @@
|
||||
# Workspace = Claude-Desktop-style project (v2 of the workspace redesign)
|
||||
|
||||
_Replaces the Orca-shaped project → workspace → worktree tree from phase 1.
|
||||
Three phases, each one commit on `main`, in order._
|
||||
|
||||
## What the user asked for
|
||||
|
||||
1. Merge the **Workflows** page into the Dashboard → **Analytics** tab.
|
||||
2. The sidebar lists **workspaces**, each expanding to **its sessions only** —
|
||||
like Claude Desktop's project list (name ›, `+` on the row, sessions under it).
|
||||
3. A workspace opens on a **project home** like Claude Desktop's: title,
|
||||
prompt box, Recents; on the right, **Instructions**, **Memory**,
|
||||
**Context** (files/documents) and **Folder** (folders) — no Scheduled.
|
||||
4. Clicking a session **opens its tmux pane** and never resumes it (a resume
|
||||
re-reads the whole transcript and burns tokens).
|
||||
5. The Orca parts are regrouped by workspace and kept where they still make sense.
|
||||
|
||||
## Phase A — Workflows inside Analytics
|
||||
|
||||
- `Dashboard.tsx` Analytics → the existing `workflow` sub-tab renders the whole
|
||||
`Workflows` page body (pipelines, skill usage, Workflow-tool runs, DAG, error
|
||||
map, complexity, compaction, drill-in) under its current small cards.
|
||||
- `/workflows` → `<Navigate to="/?tab=analytics&sub=workflow">`; the sidebar
|
||||
loses its **Workflows** item; `DocumentTitle`, i18n `nav.workflows` removed.
|
||||
- No server change.
|
||||
|
||||
## Phase B — Sidebar: workspaces → sessions
|
||||
|
||||
```
|
||||
WORKSPACES + 🔍 ⚙
|
||||
agenthub › +
|
||||
SG_MiniApp ⌄ +
|
||||
● Tách thông tin official account (● = a live tmux pane)
|
||||
○ Kiến trúc tenant đơn mở rộng
|
||||
scratch › +
|
||||
```
|
||||
|
||||
- A flat list in the stored drag order (`agenthub.workspace.laneOrder`, kept);
|
||||
the repo grouping, the "Folders" pseudo-project, untracked worktrees and
|
||||
Adopt leave the sidebar.
|
||||
- A row's chevron expands its sessions (`GET /api/sessions?cwd=&tool=`, the
|
||||
newest 20, the session's name or first prompt). A dot shows a live pane.
|
||||
- Click the **name** → the workspace home (Phase C). Click `+` → the home with
|
||||
the prompt focused. Click a **session**:
|
||||
- its pane is alive (a `dashboard_runs` row with that `session_id`, the
|
||||
tmux session `agenthub-lane-<id>[-slot]` still there) → the session view
|
||||
attached to that pane — no new process, no resume;
|
||||
- no live pane → the read-only transcript (`/sessions/:id`, Conversation tab),
|
||||
with **Resume** as a separate, explicit button that warns about context cost.
|
||||
- Kept: rename (double-click), drag / Alt+↑↓ reorder, right-click menu
|
||||
(Start/Stop, Rename, router, readme, Copy path, Reset, Remove).
|
||||
- Header: `+` new workspace (the Add modal), search (filters workspaces and
|
||||
loaded session titles), a tool filter.
|
||||
|
||||
## Phase C — Workspace home + sources
|
||||
|
||||
Route: `/workspace?lane=<id>` = home; `/workspace?lane=<id>&run=<runId>` = the
|
||||
session view (today's terminal, split panes and the Files/Changes panel,
|
||||
unchanged — the one-render-site console rule still holds).
|
||||
|
||||
```
|
||||
Workspaces / SG_MiniApp 📌 ⋯
|
||||
┌────────────────────────────────────────────┐ ┌ Instructions ✎ ┐
|
||||
│ How can I help you today? │ │ text… │
|
||||
│ + [Claude ▾] [model ▾] [effort ▾] [Run]│ ├ Memory View ────┤
|
||||
└────────────────────────────────────────────┘ │ CLAUDE.md, auto │
|
||||
Recents ├ Context + ─┤
|
||||
Tách thông tin official account 2h ago ● │ spec.pdf notes… │
|
||||
Kiến trúc tenant đơn mở rộng Sep 29 ├ Folder + ─┤
|
||||
│ 📁 SG_MiniApp │
|
||||
│ 📁 sg-shared │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
- **Prompt box** = today's run form (tool's model picker, permission, effort,
|
||||
bypass), compact. Run → a new pane, then the session view.
|
||||
- **Recents** = the same session list as the sidebar, with time ago, cost and
|
||||
the live dot.
|
||||
- **Instructions** — one text per workspace, `lanes.instructions`. Reaches the
|
||||
agent at spawn: Claude `--append-system-prompt <text>`; Codex
|
||||
`-c developer_instructions=<text>` if 0.155 accepts it (verified while
|
||||
building — otherwise Claude-only, shown as "not supported by <tool>").
|
||||
Never written into the user's repo.
|
||||
- **Memory** — read-only: the folder's `CLAUDE.md` / `AGENTS.md` and Claude's
|
||||
auto-memory dir for that cwd (`<CLAUDE_HOME>/projects/<slug>/memory/`).
|
||||
**View memory** opens them in the existing markdown viewer.
|
||||
- **Context** — files the user uploads (pdf, md, txt, images…, ≤ 10 MB each),
|
||||
stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
|
||||
server-chosen name. The directory goes to the agent as `--add-dir`, and the
|
||||
appended instructions list the files, so the agent knows to read them.
|
||||
- **Folder** — the workspace's own cwd (fixed) plus extra folders, each
|
||||
`--add-dir` for Claude and Codex (both verified in `--help`); other tools show
|
||||
them as unsupported. **+** opens a folder picker that also suggests the repo's
|
||||
untracked worktrees (what is left of Adopt). A folder must exist, be a
|
||||
directory and not be or contain the OS home (`validateHomeDir`).
|
||||
- **⋯ menu** = the row menu plus what Settings → Projects held (facts,
|
||||
worktree count, Forget).
|
||||
|
||||
### Schema (migration-safe)
|
||||
|
||||
- `lanes.instructions TEXT` (ADD COLUMN, null).
|
||||
- `CREATE TABLE IF NOT EXISTS lane_sources (id INTEGER PRIMARY KEY, lane_id
|
||||
INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE, kind TEXT NOT NULL
|
||||
CHECK (kind IN ('folder','file')), path TEXT NOT NULL, name TEXT NOT NULL,
|
||||
size INTEGER, added_at TEXT NOT NULL)`.
|
||||
- Removing a workspace deletes its rows and AgentHub's own context copies —
|
||||
never a user folder.
|
||||
|
||||
### API
|
||||
|
||||
- `GET/PUT /api/lanes/:id/instructions`
|
||||
- `GET /api/lanes/:id/sources`, `POST …/sources/folder {path}`,
|
||||
`POST …/sources/file` (raw body, name header, size cap),
|
||||
`DELETE …/sources/:sourceId` — all `sameOriginGuard`.
|
||||
- `GET /api/lanes/:id/memory` → the memory files above, read-only.
|
||||
- Spawn (`spawnRun`) reads instructions + sources from the lane row, never
|
||||
from the request — the same rule as `tool`.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Projects** group leaves Settings (now the workspace's ⋯ menu).
|
||||
- **Worktrees** stays, renamed "Worktree suggestions": whether Add folder
|
||||
suggests untracked worktrees and from which sources.
|
||||
|
||||
## Verification
|
||||
|
||||
Each phase: tests first for new logic, `npm run test:server`,
|
||||
`npm run test:client`, headers, build, screenshots; docs + CLAUDE.md marker.
|
||||