Compare commits

...

118 Commits

Author SHA1 Message Date
nntrivi2001 6c605849d8 feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
2026-10-06 11:31:26 +07:00
nntrivi2001 c067abc68f chore(release): bump version to 2.0.1 2026-10-05 22:11:06 +07:00
nntrivi2001 b0cf2132d1 fix(ai-tools): detect AI CLIs on Windows through PATHEXT
which() looked for the bare name only, so on Windows every tool (claude.exe,
codex.cmd, ...) read installed: false. It now tries each PATHEXT extension
(default .EXE;.CMD;.BAT), the bare name only when it already carries one -
npm's extensionless shim beside codex.cmd is a sh script Windows cannot run.

spawnTarget() gives a .cmd/.bat shell: true and a quoted path, used by the
install/update job runner and the npm/version probes, which Node otherwise
refuses with EINVAL (CVE-2024-27980). Catalog argv only.
2026-10-05 22:05:33 +07:00
nntrivi2001 094d9c4418 chore(release): bump version to 2.0.0 2026-10-05 17:05:53 +07:00
nntrivi2001 198fbb7980 fix: event-loop freezes, plugin-install boot crash, tmux attach/scrollbar, Codex transcripts
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js,
  workflow-stats.js); idx_events_session restored, idx_events_tool added
- compat-sqlite gains function/pluck/readonly so a plugin install without a
  better-sqlite3 build boots again; antigravity-usage uses db.js's Database
- install-hooks adds no checkout hooks while the agenthub plugin is enabled
  (events were doubled)
- terminal attach runs the live tmux server's own binary; the pane scrollbar
  gets a visible 2-column style, and claude panes start with
  CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll
- Codex sessions' conversation in GET /api/sessions/:id/transcript
- Codex/Antigravity sessions claimed by their workspace without hooks
- UI-audit follow-ups, docs and OpenAPI
2026-10-05 17:01:05 +07:00
nntrivi2001 442f133930 docs: UI-audit fixes in CLAUDE.md, API, OpenAPI, LANES and READMEs
New params and errors (agents session_ids, sessions awaiting/from/to,
stats tool counts, event labels, cleanup dry_run/INVALID_PURGE_DAYS, lane
start slot auto/ESESSIONLIVE/EUNSUPPORTED), terminal and pane shortcuts,
the shared UI primitives, accent vs accent-solid, lazy routes and the
Settings groups. openapi.yaml regenerated.
2026-10-05 13:22:13 +07:00
nntrivi2001 d4961d94ef test(client): refresh screen snapshots; cover Settings Data and About
The UI audit changed roles, aria attributes, theme tokens and layout on
most screens; the snapshots are regenerated after review. General split
into General / Data / About, so the moved content gets its own two
snapshots.
2026-10-05 13:22:12 +07:00
nntrivi2001 efa417488a feat(workspace): no double resume, server-picked tabs, terminal a11y
- A resume of a session a live pane already holds is refused (409
  ESESSIONLIVE with error.run_id) and the client opens that pane, pasting
  any typed message there; catalog tools refuse resumeSessionId
  (EUNSUPPORTED) instead of silently starting fresh.
- POST /api/lanes/:id/start takes slot: "auto": the server picks the free
  tab under the lane lock (components/lanes/startSession.ts on the client,
  kept out of WorkspaceHome so the sidebar does not pull react-markdown
  into the main bundle).
- Unsaved whole-file edits are guarded on every exit (Back, another file,
  tab switch, close, lane/session change, beforeunload).
- Closing an AI tab asks first; hiding a pane has its own icon. Tabs and
  pane headers show the session name, a state dot, rename by double-click.
- Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F /
  Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52
  clipboard, a Reconnecting overlay, debounced resizes, optional screen
  reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes.
- Changes panel: draggable splitter, wrapping git bar, history paging,
  disarming discard/undo/abort, inline diff comments pasted only into a
  visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show
  more in the sidebar and Recents; idle dots readable with sr-only text.
- Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
2026-10-05 13:22:12 +07:00
nntrivi2001 6715fbd971 feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages
- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
2026-10-05 13:22:08 +07:00
nntrivi2001 03f1673fce feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers
- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy
  (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale
  chunk after a self-update reloads once (vite:preloadError too), any other
  error shows a Reload panel instead of a blank page.
- Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing
  debounces that froze under a steady event stream; refresh skips hidden
  tabs and refetches after a WebSocket reconnect. Monitor loads only while
  shown, with one batched GET /api/agents?session_ids= instead of N+1; a
  failing endpoint is an inline error, not a blank Dashboard.
- NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort
  and search live in the URL (Dashboard, Sessions, Activity, SessionDetail).
- Sessions: tool / workspace / date-range filters, CSV/JSON export
  (formula-neutralised), focusable row links, Open in workspace (the dead
  /run links are gone), stale responses dropped, error + Retry.
- Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday
  labels west of UTC (now averages), cache hit rate denominator, billable
  tokens vs cache reads, exact tool-call counts, "To" date = end of day,
  invalid ?from= no longer throws, recent list no longer shrinks to 15,
  stable message keys, transcript switch races.
- Light-theme-unreadable bg-black code panels and dark-only DAG fills use
  tokens; status configs and chart colours use theme tokens; Health tab
  and other literals translated.
- Server (additive): GET /api/agents?session_ids=, GET /api/sessions
  awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event
  rows carry session/agent labels.
2026-10-05 13:22:04 +07:00
nntrivi2001 8fb703c6db feat(a11y): contrast tokens, accent-solid, responsive drawer, keyboard menus
- Contrast: --fg-muted reaches 4.5:1 on every surface in all nine themes
  (dark default was 2.55:1); status chips and accent text clear 4.5:1 on
  their own tints. New --accent-solid(/-hover) + tailwind `accent-solid`
  is the fill behind white text (.btn-primary, Switch on, Checkbox tick);
  --accent is now a text colour. Switch off / unchecked Checkbox get a
  >=3:1 edge.
- <html lang> follows the chosen language; a global prefers-reduced-motion
  rule; index.html body uses a token instead of text-gray-200.
- Layout: the sidebar starts collapsed below lg and becomes an off-canvas
  drawer below md (menu button, backdrop, Escape, useDialog).
- ContextMenu: focus moves in, arrows/Home/End, Escape/Tab close, focus
  returns, a keyboard-opened menu anchors to its target. Select is a real
  listbox button (aria-expanded, activedescendant, type-ahead). PageTabs
  implements APG tabs with a tabpanel. New .btn-secondary/.btn-danger/
  .btn-icon and a .select class for native selects, all with focus rings.
- StatusBar announces connection and "N need you" politely; Sidebar's
  connection modal uses useDialog; EmptyState/Skeleton/AiToolIcon a11y.
- api.ts request(): a non-JSON 200 throws a translated EBADRESPONSE
  instead of "Unexpected token '<'".
2026-10-05 13:22:00 +07:00
nntrivi2001 17a5c7470d feat(ui): shared toast, confirm, dialog, copy and IME-safe Enter primitives
One implementation each, for every page to use instead of its own copy:

- lib/toast.ts + components/Toaster.tsx: toast.success|error|info from
  anywhere, rendered in polite/assertive live regions; an error stays until
  dismissed, a success fades, a repeat collapses.
- lib/confirm.ts + components/ConfirmHost.tsx: await confirmDialog({...})
  and confirmDiscard() through the themed ConfirmModal, replacing
  window.confirm and the actions that asked nothing.
- hooks/useDialog.ts: the modal focus contract (focus in, Tab trapped,
  Escape, focus restored), extracted from ConfirmModal; dialogs stack so
  only the top one answers keys. ConfirmModal now uses it.
- hooks/useArmed.ts: two-click confirmation that disarms after 3 s.
- hooks/useUnsavedGuard.ts: beforeunload while a draft is dirty.
- lib/clipboard.ts copyText: Clipboard API with an execCommand fallback for
  non-secure origins, outcome toasted.
- lib/keys.ts isSubmitEnter: ignores the Enter that confirms an IME
  candidate (Vietnamese Telex, CJK).
2026-10-05 13:17:09 +07:00
nntrivi2001 215cae7393 feat(db): trim stored hook payloads; price the session list like a session
Analytics parity, phase 1 (data hygiene), from the analytics-parity session.

events.data is now stored through the SQL function trim_event_data(?)
(server/lib/event-payload.js, registered in server/db.js) on every event
INSERT and in the duplicate guard: Edit/MultiEdit/NotebookEdit/Write drop
the whole-file mirrors (originalFile, oldString/newString, Write content),
Read drops file.content/base64, and any string in tool_input/tool_response
over 8 KB keeps a 6 KB head + 2 KB tail. structuredPatch and every field
outside those two stay whole; _trimmed names what was dropped. The full
text stays in the transcript and its snapshot. Rows stored before this are
trimmed once in the background (sweepEventPayloads, 200 rows per write
transaction, user_version 4). No VACUUM, no row pruned.

The session list now prices every token column /api/pricing/cost/:id does
(speed, geo, tier, the 1h cache write, server-tool fees) through one
tokensForSessions helper, so a row's cost equals the session's own.
2026-10-05 13:13:02 +07:00
nntrivi2001 138e706dd1 feat(workspace): split menus start AI tools; drop the Tools menu
A pane's split right/down menus offered only New terminal. They now list
the same AI tools as the tab bar's + menu (newTabTools/newTabBody,
shared from ConsoleTabs), started bare in the first free slot on that
side of the pane.

The tab bar's Tools menu is gone: its readme run hit 409 ERUNLIVE
whenever slot 0 was live (always, in the session view) and depended on
a personal skill; MCP sync and the dev-sync check served the dormant
ship-feature-lane pipeline. The sidebar row menu's readme item goes too.
The routes stay, reached by `agenthub lanes mcp sync` and
`agenthub lanes sync-base`.

Also: the home's Memory header and file tabs no longer shrink (a long
file squeezed the tab row and clipped it).
2026-10-05 10:55:10 +07:00
nntrivi2001 151dc8a480 feat(settings): a Pet page for the mascot
Settings -> Pet (/settings/pet, PetSettings.tsx): show/hide, speech
bubbles, size with a live preview, and the pet grid - Sagi, featured
codex-pets.net pets (downloaded on first pick), your own (deletable) -
with image upload and .codex-pet.zip import. The Sagi block leaves
General; the mascot menu's Pet settings opens the page. PetMenu and the
page share components/Sagi/usePets.ts.
2026-10-05 09:54:36 +07:00
nntrivi2001 ad6e80192d feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every
item with actions - sidebar/Recents/archived session rows (SessionMenu),
console tabs, pane headers, the Changes panel's files/tree/commits, the
workspace home, sessions outside a workspace (SessionRowMenu: Sessions
table, Dashboard, Activity, Workflow runs), prompt images, every Settings
list and every Claude Code/Codex config list (FileItemMenu). Buttons stay;
a delete keeps its button's confirm; the shell stops portal clicks from
reaching the card that opened it.

Status bar: Workspace $ and Session $ on the Workspace page, from
?lane= on GET /api/pricing/cost and the focused/replayed session's cost.

Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB,
hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created
(replacing the redundant idx_events_session), watchdog reads
sessions.transcript_path, transcript parsed before the hook's write
transaction, GET /api/sessions/:id?events=0 for the web page (reloads only
on its own session, debounced), streamed export, transactional clear-data
and chunked cleanup, subagent import reads tool_use_ids once, runtime logs
rotated past 5 MB.
2026-10-05 09:37:48 +07:00
nntrivi2001 65fe680d38 fix(pricing): book daily cost to the day tokens were spent
token_usage holds a session's cumulative totals with no time, so
/api/pricing/cost booked a multi-day session's whole cost to its start
date and the status bar's Today read ~$0. TranscriptCache now also splits
usage by each message's UTC hour (tokensByHour); the hook writer stores it
in the new token_usage_hourly table (MAX per hour) and the server
back-fills existing sessions at start. The daily split books each total
over its hourly rows' local days and the rest to the start date, so the
days still add up to the total.
2026-10-05 08:03:05 +07:00
nntrivi2001 ed1bf20e6b feat(sagi): custom pets — Codex pet bundles, images, featured downloads
The mascot can wear a pet. Right-click it for Hide pet, Size (48-250
px), Choose pet (Sagi, featured, your own - deletable), Upload your
own..., Import .codex-pet bundle..., Find more on codex-pets.net and
Pet settings.... The worn pet and its size are viewer prefs
(agenthub.pet, agenthub.pet.size); the pets live on the server under
<data dir>/pets/<id>/ (server/lib/pets.js, /api/pets).

A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872
spritesheet.webp, 8x9 frames of 192x208, one row per state.
PetSprite.tsx plays the row of the mascot's mood; an uploaded image
bobs. Only the two bundle files are read, sizes are checked from the
zip directory before inflating, the sheet's WebP header must match,
uploads are raster only (no SVG), ids are made by the server, every
write is same-origin.

The four featured pets (Goku, Bac Ho, Border Collie, 11) are
codex-pets.net uploads with no redistribution licence, so none is
bundled: a pick downloads it from codex-pets.net by a fixed id.
2026-10-03 16:25:42 +07:00
nntrivi2001 8098f446b6 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.
2026-10-03 16:05:15 +07:00
nntrivi2001 326dfa9d19 feat(workspace): VS Code-style split panes, edge-to-edge session view
The console splits into up to four panes: a binary split tree of tmux
slots (client/src/lib/paneLayout.ts) drawn by PaneGrid.tsx, every pane an
absolutely placed sibling keyed by slot so splitting, swapping, resizing
or closing never remounts a live terminal. Ctrl/Cmd+click on a tab, a +
item or a sidebar session row opens it beside the focused pane (router
state, never the URL); a pane header drags onto another pane's edge to
split there or onto its middle to swap, picks another live session,
splits right/down (a not-shown session or a new terminal), or closes the
pane only. Dividers drag or take arrow keys. ?run= is the focused pane:
every layout change goes through change(), which pushes the URL with
focusOnly instead of an effect watching the focus (that ping-ponged with
open requests). The layout is kept per workspace in localStorage.

Only the focused pane's terminal takes the keyboard when it appears, and
the keyboard follows a focus change. A pane closes when its terminal
reports the exit; the run list prunes only a remembered layout on a
workspace's first read and a just-opened slot that never got listed, so
one empty read cannot wipe a split. A run ending on the home or a replay
closes its pane without leaving the page.

The session view now fills the page edge to edge: no page padding, the
tab bar and the right panel's activity bar one h-10 strip, and the tab
bar, pane headers and right panel on the sidebar/status bar colour
(surface-1) around terminals on the page colour.
2026-10-03 15:13:06 +07:00
nntrivi2001 1597c110d4 feat(workspace): session view and right panel sit on the page, no cards
The session section and the Files/Changes panel drop their cards; one
vertical divider separates them (none at full width), and the collapsed
rail keeps only that divider. The terminal goes back to the page colour
(--surface-0), and the panel's two sticky bars cover scrolled content in
the page colour instead of the card's.

Inside, the git row loses its bordered box for a bottom divider, and the
ended-session prompt box drops its own fill and shadow.
2026-10-03 13:33:22 +07:00
nntrivi2001 8f6360f993 feat(workspace): frameless console that blends into its card
The tab bar and terminal lose their bordered box: the bar keeps one
divider with an accent underline on the active tab, and the terminal is
painted in the card's colour (--surface-3) instead of the page's.

That made a long-standing overflow visible: xterm re-measures its cell
only on a font option change or a resize, so a webfont landing after
open left the fit on the fallback font's cell and the rows/cols spilled
out of the pane. Re-set the family and refit on document.fonts
loadingdone.
2026-10-03 12:37:35 +07:00
nntrivi2001 e5762a59d6 fix(run): hide the terminal's dead xterm viewport scrollbar
The rule hiding .xterm-viewport's scrollbar lived in @layer utilities,
where Tailwind purges any class that never appears in the source. xterm
creates that element at runtime, so the rule never shipped and an empty
scrollbar groove showed beside every terminal. Move it out of the layer.
2026-10-03 12:16:41 +07:00
nntrivi2001 b7efba8747 feat(workspace): the console tab bar is the session view's only header
The status/name/back-arrow row is gone; Tools sits at the tab bar's right
end and the pipeline/feature pickers show only with a template.
LaneConsolePane is reduced to the terminal (no title, no Active runs, no
New run): RunHistory.tsx, api.run.history and the page's active-sessions
poll are removed.
2026-10-03 12:13:11 +07:00
nntrivi2001 4df944c6aa test(workspace): drop the unused fireEvent import 2026-10-02 18:17:49 +07:00
nntrivi2001 d9511ea446 feat(workspace): session view console as tabs, with New terminal
The 1/2/4 split view becomes a tab bar (ConsoleTabs.tsx): one tab per live
tmux slot (0-3) of the workspace, the active one being ?run=, every live tab's
pane kept mounted and hidden with CSS. The + menu opens a new tab in the first
free slot: New terminal (a plain shell - shell: true on POST
/api/lanes/:id/start, spawnShell, tool 'shell', no run_id/status change), any
installed enabled AI tool started bare, or Settings -> Agents. A tab's x stops
its session. splitViewStorage, &pane= and the sidebar's Ctrl/Cmd+click are gone.
2026-10-02 18:09:31 +07:00
nntrivi2001 fadecaddd0 feat(workspace,settings): model menu chip in the composer, compact settings panels
The workspace home's composer takes ONE Model chip (ModelMenu: model list
plus Effort) and moves AI Router / Permission / Local-New worktree into the
row under it; Settings drops the General-page cost card and export row,
PricingSettings and the empty states are compacted for settings panels.
2026-10-02 17:49:05 +07:00
nntrivi2001 da1b17778c fix(workspace): a new session shows in its workspace 2026-10-02 16:18:00 +07:00
nntrivi2001 f35bf2043f feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker 2026-10-02 16:13:27 +07:00
nntrivi2001 4802684d2f feat(workspace): AI tool per session, one place to start a session, ended sessions open like Claude Desktop
- A session picks its own AI tool: `tool` on POST /api/lanes/:id/start
  (a catalog id, known + enabled, else 400 EBADTOOL/EDISABLED); a resume
  keeps the tool its session ran on (`sessions.tool`). The workspace's
  `lanes.tool` is now only the default.
- The workspace home's prompt box is the one place a session starts. Its
  fields run in order: AI → AI Router (Native or a profile) → Model (the
  tool's own list, or the router's models) → Permission (Claude mode /
  Codex sandbox+approval / catalog bypass) → Effort.
- The pane's run form (RunSetup) is gone: New run, a single pane's exit and
  an empty split pane's New session all go to the home. RunSetup.tsx keeps
  PromptEditor, CwdAutocomplete and ModelPicker (new `choices` prop).
- The tool icon moved from the sidebar's workspace row to each session row
  (right edge, `session.tool`); the split-pane header shows the run's tool.
- An ended session opens in its workspace (`&session=`, SessionReplay.tsx):
  its conversation read-only plus a prompt box; only a SENT message resumes
  it (resumeSessionId + prompt, first free slot). The console is only
  hidden behind it.
- Analyst button beside Files/Changes in the right panel opens
  /sessions/:id for the opened session.
- Gemini CLI ships its own icon (client/public/ai-tools/gemini.svg, from
  zonalogo.com).
- RouterPicker always shows (Native disabled for tools without router
  support); useAiRouters() exported.
- Tests: server start-with-tool cases; SessionReplay, home field order/tool/
  router models, empty pane, Analyst; dropped the cwd-field suites
  (Run.defaultCwd, Workspace.laneCwd). Docs: CLAUDE.md rules, API.md,
  LANES.md, README, ARCHITECTURE, INSTALL.
2026-10-02 15:29:38 +07:00
nntrivi2001 c507d37baf feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons
AI Router is a session's choice, not a workspace's:
- an AI Router select beside the model picker in the workspace home and a
  pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only
  Native; last pick remembered in agenthub.session.router)
- `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets
  --settings=<data dir>/router-settings/<name>.json (0600, outranks project
  and user settings, key never in argv), Codex -p <name> + key via tmux -e
- dashboard_runs.router; a resume naming none goes back to it
- profile PATCH rewrites the session files, DELETE removes them
- gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace
  router radio, the claude-/codex-home `routers` answers; clearLaneRouters
  takes the old keys out of each workspace's settings.local.json at start

Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked
on the rail) instead of in the brand's corner.

Icons: shipped SVG marks for claude, codex, openclaw and hermes (a
black/white pair swapped by theme) in client/public/ai-tools/, used by
AiToolIcon before the server's favicon.
2026-10-02 14:19:02 +07:00
nntrivi2001 5084334d81 feat(workspace): a session in its own worktree, Claude's way
Desktop-parity phase 6, following Claude Desktop:
- the workspace home's Worktree checkbox starts the pane with
  `claude --worktree=<name>`, so Claude Code makes, populates
  (.worktreeinclude, WorktreeCreate hook) and cleans up
  <repo>/.claude/worktrees/<name>; AgentHub only excludes
  /.claude/worktrees/ at the repo root (Claude does not)
- server/lib/session-worktree.js resolves a session's worktree from the
  session row only (owned by the workspace, registered by git worktree
  list); every lane git route takes ?session= and answers ENOWORKTREE
  instead of falling back to the main folder
- the session view's Changes panel works in the opened run's worktree
  (dashboard_runs.worktree, GitTarget in the client api)
- Resume/Fork start in the session's worktree; a gone one is ENOWORKTREE
- Archive of a worktree session removes the worktree after asking:
  git worktree remove without --force, git branch -d (unmerged work keeps
  its branch), a lock held by a live pid refused, a stale one cleared
- listWorktrees reads `locked <reason>` locks
- tests scrub GIT_* env so the pre-commit suite cannot act on this repo
2026-10-02 13:22:29 +07:00
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
nntrivi2001 5b269698f6 feat(ui): finish the workspace-sessions plan — tool filter, session search, explicit Resume
- Sidebar: a tool filter (shown once workspaces of two tools exist, kept in
  agenthub.sidebar.projects → tool; reordering under it keeps the hidden
  workspaces' places), and search that also finds a workspace by one of its
  session names, showing only the matching sessions.
- Workspace home: the repository it belongs to (root, main branch, worktree
  count) under the title, and each recent's cost.
- Transcript page: an explicit Resume for a Claude session started in a
  workspace's own folder (SessionResumeButton). It warns that a resume re-reads
  the whole transcript, re-reads the live panes, then starts in the first free
  pane and opens it; a live session offers Open pane instead.
2026-10-02 01:35:34 +07:00
nntrivi2001 d0535af626 feat(ui): a Claude-Desktop-style home for every workspace
/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt
box whose Start opens a new session in the first free pane slot, Recents (a
live session attaches its pane, any other opens its transcript, nothing
resumes), and Instructions / Memory / Context / Folder. Instructions live in
lanes.instructions, extra folders and uploaded files in the new lane_sources
table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads
them from the lane's rows and hands Claude --add-dir + an appended system
prompt file, Codex --add-dir + developer_instructions. Memory is read-only.

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
2026-10-02 01:10:58 +07:00
nntrivi2001 2e051e23b6 feat(ui): sidebar lists workspaces with their sessions, Claude Desktop style
The sidebar's project tree becomes a flat workspace list (WorkspaceTree):
one row per workspace in the stored order, each expanding to its sessions.
A session that still has a live pane opens /workspace?lane=<id>&run=<runId>,
which only attaches pane 0 to that tmux session; any other session opens
its read-only transcript. Nothing in the sidebar starts or resumes a run.
2026-10-01 22:23:01 +07:00
nntrivi2001 744e9c6cda feat(ui): fold the Workflows page into Dashboard → Analytics → Workflow
The workflow analytics now render under the Analytics tab's Workflow
sub-tab; ?tab= and ?sub= deep-link the Dashboard, /workflows redirects there,
and the sidebar drops its Workflows item.
2026-10-01 22:05:55 +07:00
nntrivi2001 b71024f244 feat(ui): Orca Dark / Orca Light themes and notification sounds
Two optional named themes from Orca's neutral palette join the Appearance
picker (Orca Dark keeps a blue accent so white-on-accent buttons stay
legible; Orca Light's status colours are darkened to clear 4.5:1 on their
chips). A shown notification can play a sound — None (default), Chime, Pop
or Alert — chosen globally and per notification type in Settings →
Notifications; the WAVs are generated by scripts/gen-notification-sounds.js.
2026-10-01 17:10:46 +07:00
nntrivi2001 eb90244182 feat(settings): one page per section with a grouped nav and search
Settings moves from a tab bar to /settings/<section>: a left nav grouped
General / Agents / Routing / Git / Projects (a select on narrow screens), a
search box over every section (Ctrl/Cmd+F, Enter opens the first match), and
row primitives. Each AI tool has its page under agents/<id>, the AI Tools
modal becomes the Agents page, Pricing shows every vendor, Git → Worktrees
sets which untracked worktrees the sidebar lists, and each project gets a
page with its own worktree override and Forget all. ?tool= and #ai-tools
links redirect to the path form.
2026-10-01 16:55:44 +07:00
nntrivi2001 ba2c4e1914 feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces
The Workspace page's lane strip moves into the sidebar as a project tree:
workspaces grouped by git repository (new GET /api/projects, cached in
memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline
rename, drag/Alt+arrow ordering and a right-click row menu. The selected
workspace is the URL's ?lane=, one shared lane list feeds the tree, the new
bottom status bar and the page. The Changes panel gains an activity bar
(Files / Changes / Sessions, with resume), and split panes get a header.
2026-10-01 16:33:25 +07:00
nntrivi2001 5b3884b309 feat(ui): call lanes "workspaces" in the UI, CLI output and docs
Copy-only rename (workspace redesign, phase 0). The word a user reads
changes — "Lane" → "Workspace", "Add lane" → "Add workspace" — in both
locales, in agenthub CLI prose and help, in the server's error messages
and in the docs. Every identifier stays: /api/lanes, `agenthub lanes`,
--lane, AGENTHUB_LANE_ID, error codes, tmux names, DB tables, file,
component and i18n key names. docs/LANES.md keeps its name, retitled
"Workspaces (lanes)".
2026-10-01 16:03:24 +07:00
nntrivi2001 e01cb26bde chore(release): bump version to 1.8.13 2026-10-01 13:41:54 +07:00
nntrivi2001 f887c16309 feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
2026-10-01 13:39:17 +07:00
nntrivi2001 c5aba094bd feat(ui): AI Tools modal, one-button lane tool picker, AgentHub settings in Overview, markdown preview
- AI Tools moves from a Settings section into a modal behind a button at the
  Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs,
  /settings#ai-tools); rows get an enable switch, Default badge, and an
  Update button for every tool whose catalog names an updater (no version
  lookup on open); not-installed tools fold behind a disclosure, a finished
  job's log folds to one line; shared AiToolIcon
- codex updates through its own `codex update`, openclaw/hermes gain
  `update --yes`; update-all runs every installed updater and skips an npm
  one only when npm does not manage the binary
- Add-lane tool picker is one button showing the chosen tool that opens the
  enabled tools as buttons
- Claude Code / Codex AgentHub sub-tab folded into Overview (home editor,
  hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME;
  HomeDirSetting shows the saved value only while the field differs
- Preview/Raw toggle for markdown files in the config FileViewer and the
  Changes panel's whole-file view (MarkdownPreview: react-markdown +
  remark-gfm, raw HTML never run, frontmatter as a code block)
- docs and the CLAUDE.md marker updated
2026-10-01 10:11:43 +07:00
nntrivi2001 058c574138 fix(updates): report a plugin build installed beside the running one, never "up to date" on a failed check
A `claude plugin update` run from a terminal (or with only the Claude desktop
app open) unpacks the new build and starts nothing: no SessionStart fires, so
the dashboard kept running the old cache dir across reboots, and with the
forge unreachable the sidebar read "Up to date" (seen on a machine stuck at
1.8.10).

- pluginStatus asks supersededBy for its own root: a recorded sibling install
  sets `installed_not_running: {root, version}` and `update_available`, with
  no network needed
- the sidebar reads "<version> installed, not running"; the update modal keys
  on it, so Update now is offered even with no release tag or sha
- plugin-update-helper hands over to the recorded install first, the newest
  sibling dir only as a fallback
- a plugin install whose release lookup failed reads "Could not check for
  updates" instead of "Up to date"
- docs/PLUGINS.md: the case and the manual hand-over command
2026-10-01 10:09:12 +07:00
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
2026-09-29 16:09:19 +07:00
nntrivi2001 7ec5a7cd85 feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2) 2026-09-29 08:25:37 +07:00
nntrivi2001 fcfebe44f1 feat(ai-tools): Settings → AI Tools, Default/Other lane picker, per-run bypass switch (#40 A1)
Settings → Dashboard gains an AI Tools section (installed tools with
Enabled/Disabled and Set default, available ones with a homepage link,
Refresh). useAiTools shares one catalog copy across the app and replaces
LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the
router row reads Native (<tool>) and disables AI Router profiles for tools
that cannot use one. The run form gives catalog tools resume and a red
per-run Bypass permissions switch (Codex too). Claude Code / Codex settings
tabs hide while that tool is disabled. The Workspace probes each lane tool's
CLI fresh.

Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md,
server/README.md, OpenAPI fragment + regenerated openapi.yaml.
2026-09-29 08:21:05 +07:00
nntrivi2001 77917203fd feat(ai-tools): catalog of AI coding CLIs, detection, enable/default, lanes for any tool (#40 A1)
server/lib/ai-tools.js loads server/data/ai-tools/*.json (38 tools, data from
Orca, MIT) plus DASHBOARD_AI_TOOLS_DIR, detects each CLI by walking PATH, and
keeps enable/default in <data dir>/ai-tools.json. shell-path.js merges the
login shell's PATH at startup and on Refresh. /api/ai-tools lists, refreshes
and toggles; /api/run/binary resolves through the catalog.

Lanes accept any catalog id (EDISABLED for a disabled one on creation, ENOTOOL
at start once a tool left the catalog). pty-run keeps the Claude/Codex
builders and builds every other tool's argv from its catalog file, pasting an
after-start prompt through a tmux buffer; every pane gets the hydrated PATH.
A per-run bypass switch appends the catalog's bypass args (Codex included).
POST /api/run now forwards sandbox/approval/resumeLast, which it dropped.
agenthub lanes add gains --tool.
2026-09-29 08:18:40 +07:00
nntrivi2001 0aa7fc232e docs(spec): AI Tools part A1 — catalog, detection, enable/default, lanes for any tool (#40) 2026-09-29 08:18:40 +07:00
nntrivi2001 887dc4640e chore(release): bump version to 1.8.12 2026-09-28 17:49:38 +07:00
nntrivi2001 2b8f83d2dd refactor(lanes): drop the workflow skills and Superpowers link; pipelines go dormant
The plugin stops shipping its workflow layer:
- skills/ship-feature and skills/ship-feature-lane, the 18 stage/ship slash
  commands (plugin.json keeps agenthub-doctor/update/open), and all four
  agents/ (qc-local, senior-gate-reviewer, backend-reviewer, frontend-reviewer).
- CLI `agenthub skills install`, `agenthub lanes agents install`,
  `agenthub ship-full|ship-quick`; routes POST /api/skills/install and
  POST /api/lanes/:id/agents/install; the Settings button, the lane Tools
  menu item and the Add-lane setup step that called them.
- The Superpowers auto-install: server/lib/skill-plugins.js, the bootstrap's
  autoInstallSkillPlugins / skillPluginsSettled gate and the doctor row.

Pipelines are kept but dormant: server/data/pipelines/ is deleted, so no
template ships. `default` resolves to EMPTY_PIPELINE (no nodes, progress 0),
createLane still accepts it, POST /api/lanes/:id/stage answers 400
ENOPIPELINE, and the Workspace hides the stage timer, pickers, PipelineMap
and strip stage chip for a lane with no nodes. A template dropped into
DASHBOARD_PIPELINES_DIR turns it all back on. The server suite loads its
templates from server/__tests__/fixtures/pipelines/.

Docs: CLAUDE.md (marker, retired rules), README(.vi), INSTALL, ARCHITECTURE,
docs/LANES|PLUGINS|CLI|API, agenthub-introduction.html.
2026-09-28 17:49:05 +07:00
nntrivi2001 3c238bc5c4 chore(release): bump version to 1.8.11 2026-09-28 10:26:56 +07:00
nntrivi2001 9d096b61f7 feat(lanes): Antigravity lanes, release exited panes, fresh Codex starts
- antigravity: a third lane tool running Antigravity's `agy` CLI. `-i`
  carries the first prompt, `--continue` resumes; `agy` has no model,
  effort or permission flag, and `--dangerously-skip-permissions` is never
  emitted. No pipeline, no agents install, and no AI Router (native only,
  else EUNSUPPORTED). Add lane gains an Antigravity choice and the binary
  probe maps it to `agy`.
- UI: the lane card no longer renders a frame holding only a "Codex" chip,
  and the prompt box names the lane's CLI ("Ask Codex anything…"), via
  LANE_TOOL_LABEL.
- runs: a pane that exits is released on a 10 s server beat
  (healAllRunIds, DASHBOARD_RUN_HEAL_MS). Codex and Antigravity fire no
  hook, so their lanes stayed "running" until something re-read them.
- codex: Start no longer sends resumeLast — only "Resume last session"
  continues the previous thread.
2026-09-28 10:22:07 +07:00
nntrivi2001 952e8584f0 feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
2026-09-25 17:30:28 +07:00
nntrivi2001 3f6e903c22 chore(release): bump version to 1.8.10 2026-09-25 13:37:46 +07:00
nntrivi2001 1da46f10f4 feat(git): per-block conflict view with mine/theirs choices (#45)
Opening a conflicted file in the Changes panel now shows each conflict
block as its two sides, labelled from git's markers and tinted apart,
with Keep mine / Take theirs / Keep both per block (undoable, ^/v to
jump). Save and mark resolved writes the chosen text through the
whole-file editor's PUT /git/file (baseSha stale check) then runs
resolve done. The Merge conflicts group's buttons are text, and a
conflicted file is marked with a red ! in the Files tree.

Fix: ls-files --cached lists an unmerged path once per stage, so the
tree showed a conflicted file three times; lsFiles now dedupes.
2026-09-25 13:35:50 +07:00
nntrivi2001 52392962fb chore(release): bump version to 1.8.9 2026-09-25 11:41:53 +07:00
nntrivi2001 40fe41bb77 fix(db): keep router_profile through the legacy lanes rebuild
The (cwd, tool) rebuild lists its columns by hand and omitted
router_profile, which its probe adds earlier in the same start — a legacy
DB lost the column (and every lane's router) until the next restart.
2026-09-25 11:30:10 +07:00
nntrivi2001 99a2a8277c fix(tmux,run,hooks): the four pre-existing bugs the #37 evaluation found
- tmux targets are exact (`=name`, `=name:` for panes/options): a bare
  `-t agenthub-lane-1` resolves by prefix to agenthub-lane-12 once lane 1
  is gone, so killing a finished lane could kill another.
- getRun/killRun refuse ids that are not `agenthub-lane-<n>`, so
  DELETE /api/run/main can no longer kill a user's own tmux session.
- router/apply reports restart_required only for a running pane; getRun
  returns a `gone` handle, so it was always true.
- needs_action is cleared only by the session that raised it (new nullable
  lanes.needs_action_session); a second session in the same cwd used to
  clear it on its second hook.
2026-09-25 11:30:02 +07:00
nntrivi2001 04b6b98d97 fix(git,macos,runs): close the review findings on #22, #34 and #38
- validateRemoteUrl checks the literal scheme prefix git will receive and
  refuses %/backslash in the authority (WHATWG normalises https:/host and
  percent-escapes that git reads differently); route test for EBADURL.
- Parent-session scrub also drops AI_AGENT, and GIT_EDITOR when it is
  Claude Code's `true`.
- canonicalCwd resolves a not-yet-existing path through its deepest
  existing ancestor, so a symlinked parent still yields one lane.
- Note the clock-step ceiling on liveServers' boot-time filter.
- Docs/header wording: the Active runs count is lane-scoped.
2026-09-25 11:26:31 +07:00
nntrivi2001 478418f602 fix(macos): scrub parent-session env, survive node upgrades and reboots, store lanes by real path (#22)
What broke (code audit, untested on a Mac):
- Without systemd the server is always spawned from inside a Claude Code
  session, so it inherited CLAUDECODE, CLAUDE_CODE_SESSION_ID,
  CLAUDE_PLUGIN_ROOT, ... and tmux handed them to every lane pane (a codex
  pane even resolved as `claude` in `agenthub`'s harness check).
- The ~/.local/bin/agenthub launcher pinned a versioned node path that
  `brew upgrade`/nvm deletes, leaving the CLI dead until the next bootstrap.
- A discovery entry left over from before a reboot could name a recycled pid,
  so the bootstrap's fast path believed a dashboard was live and
  stopDashboard could signal an unrelated process.
- Lanes were stored under the typed cwd; macOS reports /private/tmp and the
  on-disk case, so sessions never bound to a /tmp or differently-cased lane.

What changed:
- server/index.js deletes a DENYLIST of parent-session variables at startup;
  user config (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) stays.
- linkCli's launcher falls back to `node` on PATH when the pinned one is gone.
- liveServers ignores entries whose startedAt predates this boot (60 s margin).
- createLane and POST /api/lanes/ensure store/look up fs.realpathSync.native
  of an existing cwd; the liveness reaper also tries the realpath. Existing
  rows are untouched.
- session-liveness gains an exec seam so the darwin ps+lsof branch is tested.
- plugin-doctor names ~/.zshrc on darwin; INSTALL.md gains a macOS section
  with the Mac verification checklist, mirrored in both READMEs and PLUGINS.md.
No launchd, no macOptionIsMeta, engines stays >=20.
2026-09-25 11:23:59 +07:00
nntrivi2001 aff79a787c feat(lanes,git): publish a repo with no remote, and read a repo with no commits
What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.

What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
  `head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
  subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
  `push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
  all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
  `validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
  local paths, file://, ext::, fd::, git://, http://, leading `-`:
  EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
  commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
  no remote offers Publish… with an inline URL box; a remote but no
  upstream relabels Push as "Publish branch". AddLaneModal disables Add
  on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
  now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
  docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
2026-09-25 11:21:30 +07:00
nntrivi2001 f514602697 feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
2026-09-25 10:44:40 +07:00
nntrivi2001 605ca9d65e chore(release): bump version to 1.8.8 2026-09-24 17:08:18 +07:00
nntrivi2001 158783d5d5 feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing
- Changes panel at full screen: the whole Files view (finder, expand-all,
  tree) becomes a left sidebar and the Files tab is dropped; a working-copy
  file opens straight into the editor (Save/Cancel live once the text
  differs, Cancel restores it, Whole file toggles back to the diff), and the
  page is height-locked so the editor no longer collapses with no live run.
- Overview splits into Staged Changes / Changes groups in the work range.
- A changed file's whole tree row takes its status tint.
- Drag an image onto the lane terminal: POST /api/run/drop-image saves it
  (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path
  is pasted into the pane, which is how claude attaches it.
- Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40);
  claude-opus-5% had been pricing it as Opus 5.
- server/lib/pricing-sync.js: a model family used in token_usage with no
  row of its own is inserted from LiteLLM's public price list, never
  updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly
  that one third-party GitHub raw URL.
2026-09-24 17:05:15 +07:00
nntrivi2001 804b193ac7 chore(release): bump version to 1.8.7 2026-09-24 15:54:09 +07:00
nntrivi2001 d397d3a1f6 feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux
  session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the
  lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's
  Active runs hides what the other panes show; stop/reset/remove kill
  every slot. No per-pane lane picker any more.
- Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js)
  with the read path's guards, existing text files only, and a baseSha
  stale check (409 ESTALE). A file it cannot take says why.
- Per-hunk review bar under each hunk's last changed line: prev/next,
  "n of N", Undo, Keep. Review diffs no longer nest their own scroll box
  (unless windowed), so navigation moves the one real scroller.
- Files tree lists untracked-not-ignored files and colours entries by git
  status (M/U/A/D/R), folders holding changes get a dot.
- Changes panel: Unstage all on the single commit/Stage-all row; sizes
  Narrow (30%) / Medium / Full screen, no 70/30.
- Lane card: the needs-you banner is gone (the strip tile keeps it).
2026-09-24 15:49:39 +07:00
nntrivi2001 67210bcd61 fix(test): run the server suite on a private tmux server
Several suites start real `agenthub-lane-<id>` tmux sessions and then kill
or reset them, with lane ids from their own throwaway database. On the
user's default tmux server those names collide with the dashboard's real
lane panes, so a test run — including the one the pre-commit hook runs on
every commit — killed whichever live session shared an id, the pane the
commit was made from included.

`npm run test:server` now goes through scripts/test-server.js, which drops
TMUX, points TMUX_TMPDIR at a fresh directory and kills that server after.
Also fixes the ERUNTIMEOUT test's tmux mock, which had the wrong (async,
cmd+args) signature and only passed by accident.
2026-09-24 15:48:19 +07:00
nntrivi2001 4e0b869dd3 chore(release): bump version to 1.8.6 2026-09-24 14:40:32 +07:00
nntrivi2001 cfe832383c feat(lanes): stage/undo one hunk at a time, sticky review file headers
Review and the single-file view gain per-hunk Stage and Undo
(stage-hunk / discard-hunk: the hunk is named by its @@ line and
re-matched against a fresh diff, then git apply --cached / -R; undo
needs confirm: true and two clicks). Each review file header stays
pinned while its diff scrolls and carries the file's own
stage/unstage/discard.
2026-09-24 14:40:31 +07:00
nntrivi2001 0737786faf feat(lanes): full-width Changes, Stage all, one-read expand-all
- The Changes panel's width control gains a full-width step (narrow →
  50/50 → 70/30 → full → closed); the console beside it is hidden with
  CSS, never unmounted.
- "Expand all folders" is one GET /api/lanes/:id/git/tree-all (one
  `git ls-files`, every level shaped like /git/tree) instead of one
  request and one git process per folder.
- Overview: a Stage all button and the staged-only commit box sit above
  the file list and stay pinned while it scrolls; the Files finder is
  pinned too.
2026-09-24 14:40:31 +07:00
nntrivi2001 428519335d fix(run,lanes): prompt autocomplete, per-lane Active runs, compact Changes header
- The run form's prompt box offers suggestions again: `/` lists the run
  cwd's project and user commands and skills plus the CLI built-ins
  (claude lanes only), `@` lists files under the cwd; Enter picks while
  the list is open and sends otherwise. The 2f39f4e rewrite had left a
  bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
  (sessions matched to the lane by longest path-boundary cwd), instead of
  every lane's.
- The Changes panel's git line and its actions are one compact row in one
  card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
  leaving an empty frame where the git row used to be.
2026-09-24 13:32:43 +07:00
nntrivi2001 faa56f5c75 feat(lanes): move the lane's git section into the Changes panel
- The branch line, the uncommitted-files hover panel and the git action
  row (fetch/pull/push/branch) move from the lane card to the top of the
  Changes panel (new LaneGitHeader.tsx); the card has no git row now.
- Header and panel share one git-facts read: stage/unstage/discard/commit
  and Refresh re-read it, and a git bar action re-reads the diff, the
  history and the file tree.
- The git bar no longer commits: the panel's staged-only box is the one
  commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi).
- "New lane here" is wired from Workspace to the panel.
2026-09-24 11:44:31 +07:00
nntrivi2001 7bc4594b94 fix(lanes,git): harden the lane git surface and fix client races in the Changes panel
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js):
- Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a
  file name is a file name, never a glob, and odd names parse correctly.
- A lane that is a subdirectory of its repo sees and acts on ONLY its own
  paths (reads, stage/unstage/discard/commit, and gitFacts.files).
- Any file named settings.local.json (the lane's router API key) is refused as
  403 ESECRET on disk (symlinks resolved) and at every committed rev.
- commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is
  staged (checked with --no-renames, so a move into the lane is caught).
- push resolves its remote (upstream → only remote → origin), else 400
  ENOREMOTE; unstaging a rename unstages both halves.
- Git writes serialize on the repo's git dir, not the lane id, so two lanes on
  one index cannot collide on index.lock.
- git refusing an action (a numeric exit status) answers 409 EGIT with git's
  stderr instead of a 500 carrying a numeric code; one shared parseNumstat.

Client:
- Changes panel: no stale answer from a previous lane or an older quick-open
  query repaints the panel; discard disarms on lane switch; Enter on a file
  path opens it instead of folding; bounded tree expand-all.
- Large diffs (> 500 rows) render only the visible rows, with stable width
  and in-sync split columns; mirrored split scroll is clamped.
- Git bar: no "in sync" without counts, a clear no-remote message, branch list
  re-read after a switch, and no previous-lane facts after a lane switch.
- Tooltips no longer flicker or close on unrelated scrolls.
2026-09-24 10:30:25 +07:00
nntrivi2001 6153d50fa8 chore(release): bump version to 1.8.5 2026-09-24 10:30:25 +07:00
nntrivi2001 562f43fd88 fix(ui): keep the sidebar's EN/VI buttons at full width beside the three theme buttons
The language row was a 4-column grid holding two buttons inside a flex-1 box. Once the theme row gained a third (System) button, that box narrowed and each language button fell below the width of its own label, so EN/VI sat off-centre and overflowed. One column per language, centred text.
2026-09-24 10:28:51 +07:00
nntrivi2001 8ea40260ad feat(ui): terminal, charts and low-contrast text follow the selected theme
- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
2026-09-24 09:50:44 +07:00
nntrivi2001 6353e88c9e chore(release): bump version to 1.8.4 2026-09-24 09:46:52 +07:00
nntrivi2001 6cf8a82889 feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and
  a named theme remembered per mode — dark: Default, Darcula, Apple Dark;
  light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a
  CSS-variable override block keyed by `data-theme` on <html>; the sidebar
  quick toggle gains System and swaps between the two remembered themes.
- An inline boot script in index.html applies the stored mode and theme
  before first paint, so a reload no longer flashes the default colours.
- Diff +/- rows are easier to tell apart on every theme: a /20 status tint,
  a 3px status bar and a bold coloured marker; the changed-span emphasis no
  longer vanishes on light themes.
- #42: the Add-lane modal (Repo mode / "Current branch") asks
  POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside
  an existing lane resolved to that parent lane with created:false, so the
  modal closed and no new card appeared. When the folder really already has a
  lane, the modal now says which one instead of closing.

Closes #42
2026-09-24 09:44:42 +07:00
nntrivi2001 2ea2184372 chore(release): bump version to 1.8.3 2026-09-23 16:46:40 +07:00
nntrivi2001 aff863c69f fix(plugins): retry claude plugin install|update without -y on an old CLI
An older Claude Code has no -y on `plugin install|update` and exits with
"error: unknown option '-y'", so the dashboard's Update now chain failed at
its plugin_update step (and so did Update all, the plugin buttons and the
superpowers auto-install). Those versions also predate the confirmation
prompt -y answers, so every wrapper now retries the same argv without it
when, and only when, the CLI rejects the flag. /agenthub-update, which the
failure message points users to, falls back the same way.
2026-09-23 16:46:20 +07:00
nntrivi2001 764b967856 fix(dashboard): measure V8 heap against its limit, not its current size
The Health Score's heap component divided heapUsed by heapTotal, which is
only what V8 has grown the heap to so far, so a healthy server read 70-90%
and dragged the score down. GET /api/settings/info now also reports
server.memory.heapLimit (v8 heap_size_limit, the ceiling the process dies
at); the V8 Heap bar and the Health Score divide by it, falling back to
heapTotal against an older server.
2026-09-23 16:46:00 +07:00
nntrivi2001 94ee2dc79c chore(release): bump version to 1.8.2 2026-09-23 13:23:40 +07:00
nntrivi2001 3f50e162e6 feat(run): Enter sends the prompt, and a busy lane attaches instead of failing
- The prompt box sends on Enter (Ctrl/Cmd+Enter still works); Shift+Enter
  inserts a newline, and IME composition (Vietnamese/CJK) never sends.
- Starting or resuming into a lane that still holds a live run answered a
  raw 409 ERUNLIVE; the console now attaches to that run with a notice.
  Checked by error code, since the server's message is prose.
- Active runs gains a two-click Kill for live dashboard-spawned rows, the
  way to free a lane when the previous session really should go.
2026-09-23 13:23:20 +07:00
nntrivi2001 1f5c04e765 feat(ui): warm light theme and pre-mixed status fills
- Light mode moves to Claude's ivory/terracotta family: page #e8e5da, cards
  #f5f3ec (a notch below Claude's ivory, no pure-white step), warm
  borders and card shadow; dark mode is untouched.
- The surface ramp rises in lightness with its index in BOTH themes, so a
  card sits brighter than the page and every hover lifts instead of dims.
- Light --accent #a34a28 (the brand #cc785c is 3.1:1 on ivory); --fg-muted
  held at >=4.5:1; new --syn-* syntax tokens, dark keeps its pastels.
- Status/accent fills (bg-*/N, border-*/N) compile to color-mix against
  --tint-base instead of rgb(/a), so a chip is one colour on every surface.
- Hardcoded dark-only hovers (hover:text-white, hover:bg-white/5) replaced
  by tokens in DateTimePicker and SessionDrillIn.
2026-09-23 13:23:00 +07:00
nntrivi2001 952ee7c724 chore(release): bump version to 1.8.1 2026-09-23 09:18:17 +07:00
nntrivi2001 92cee94431 fix(hooks): store a hook delivered twice only once
A doubled hook registration (plugin + leftover checkout install) POSTs every
event twice with a byte-identical payload, so the timeline showed each row
twice and token/alert/lane side effects ran twice. POST /api/hooks/event now
skips a payload identical to the session's LATEST event from the last 10s
(findRecentDuplicateEvent). Latest-only keeps two legitimately identical
Stop events of consecutive turns, which always have other events between.
2026-09-23 09:18:17 +07:00
nntrivi2001 1b561c22d9 feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
2026-09-23 09:18:17 +07:00
nntrivi2001 32a8887419 chore(release): bump version to 1.7.0 2026-09-21 17:07:08 +07:00
nntrivi2001 f77049c238 feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33):
- `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a
  tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the
  column it was on) had the rest of that draw land at column 0, stamping a
  line's first characters into the left margin of the rows below — the torn
  left edge that only showed up once content scrolled.
- The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery`
  in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl).
  `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every
  attach, and tmux sizes the window to the newest client, so `claude` redrew
  its whole UI at 80 columns until the first `resize` message landed.
- The terminal takes the keyboard on mount, so typing reaches tmux without
  clicking the pane first.
- A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a
  new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console
  returns to the setup form instead of leaving a dead pane behind a
  "New run" button.

Pane environment:
- `server/lib/desktop-env.js` forwards the graphical session's DISPLAY /
  WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from
  `systemctl --user show-environment`, this process's own values winning)
  into the pane. The server normally runs as a systemd user service with none
  of them, so `claude` there could not read the clipboard and answered "no
  image found in clipboard" on every image paste.

Run setup:
- `auto` is a permission mode and is now the default everywhere — allowlists,
  the `--permission-mode` fallback, the select, and the pane's initial state.
- The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`)
  instead of pinned ids. `claude --model` resolves an alias to the latest
  model itself, so a release like Fable 5.1 needs no edit here; pricing is
  unaffected because it keys off the resolved id the transcript reports.
- **Create/update readme file** moved from the Add-lane modal into the lane's
  Tools menu: creating a lane and writing its docs are separate decisions,
  and the second is worth re-running long after the first.
- The Workspace's no-lane state is a real banner; the lane strip (add tile
  included) renders only once a lane exists.

Refs #32, #33.

Tests: npm run test:server (1828 pass), client vitest (558 pass).
2026-09-21 17:03:27 +07:00
nntrivi2001 64017c6ca5 feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
  (`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
  slash command on a claude lane and a stage checklist on a codex one).
  Whatever sits in that box is sent as a real prompt the moment the run
  starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
  the resumed session carries its own transcript, and leftover text in the box
  fired an extra turn into it.

Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
  `useNotifications` and Settings' test button, and it reports where the
  notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
  means it went NOWHERE, so the client falls back to a local notification
  instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
  Workspace selects that lane on arrival.

Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
  `Claude Code-credentials`, where the `claude` CLI actually keeps
  `claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
  The file stays the fallback, is kept in step only when it already exists, and
  `AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).

Tests: npm run test:server (1820 pass), client vitest (553 pass).
2026-09-21 15:15:22 +07:00
nntrivi2001 9156781d75 chore(release): bump version to 1.6.17 2026-09-18 16:53:56 +07:00
nntrivi2001 8d48a038c7 fix(ingest,ui): count each assistant message once, each turn once, and each event once
Three defects that all showed up as "the dashboard's numbers are wrong", plus
the notification and brand work asked for alongside them.

Tokens: Claude Code writes ONE JSONL line per content block of an assistant
message, and every one of those lines repeats the same message-level `usage`.
Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran
1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId`
counts a message once, carried across the transcript cache's incremental chunk
boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser
cannot correct what is already stored, so `setTokenUsage` (exact, baselines
cleared) is added for the deliberate re-parse path and `reconcileTokens` now
covers every session with a transcript on disk rather than imported ones only.

Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on
the transcript's timestamp, so each hook re-inserted the session's whole turn
history — 587k rows, 75% of a real events table, one session holding 68,747
copies of the same turns. The row now carries the transcript timestamp, which
fixes the guard and the feed's ordering together. Same class of bug in the
session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms`
are assigned from the full parse instead of `+=`, since `extract()` returns
whole-transcript totals on every call.

Events: leftover checkout hook entries in `~/.claude/settings.json` double every
event, every subagent row and every token figure. `stripLegacyHooks` has always
removed them — but only inside `bootstrap()`, which `fastPathOk` skips on
exactly the healthy installs where nothing else forces a full pass, so they
survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path.

A one-time repair at the first start after upgrading (`user_version` 3) deletes
the duplicated `TurnDuration` rows and re-parses every transcript with exact
writes. Marker 3 and not 2: an install that ran the earlier pass had its
corrected rows re-inflated by whatever old build was still ingesting hooks.

Router: switching a lane's AI Router while a pane is live only half-applies —
Claude Code re-applies `env` to a running session but keeps the model it
resolved at startup, so the old router's model name reaches the new endpoint and
the next prompt fails with "model not found". The apply route reports
`restart_required` / `run_id`, and the picker offers a Restart that respawns the
pane from the lane id alone (replaying the old `--model` would re-create the
mismatch).

Notifications (#29): the two moments Claude is BLOCKED on the user now have
their own switches and default to on — `onAwaitingInput` (a permission prompt or
a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable
and the second hung off a preference that defaults to off, so the one thing a
user walks away from went unannounced. Prefs shape/defaults/storage move to
`client/src/lib/notifPrefs.ts`; Settings and the hook had private copies.

Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under
it, the collapsed rail shows the mark, and the favicon is vendored to match.
2026-09-18 16:52:05 +07:00
nntrivi2001 59b3ba9e39 chore(release): bump version to 1.6.14 2026-09-18 10:40:24 +07:00
nntrivi2001 eab7c437df feat(skills,pipelines): make the review node review, and make a long run followable and affordable
Closes #24.

**The review node ran nothing.** Both templates declared
`/superpowers:requesting-code-review` — the skill about how to HAND OVER a
review, not one that reviews anything — so `agenthub lanes node review`
performed no review at all. They now declare the bare `/code-review`, Claude
Code's own. It carries no `plugin:` prefix, so `requiredSkillPlugins` skips it
and the doctor's required-plugin row is unchanged (verified against the live
function: still exactly the five superpowers skills the templates name).

**The rest is the brainstorm → implement → review span, made cheaper to run and
harder to lose track of.** `ship-feature` gains four sections it never had:

- **Right-size the run.** The pipeline is not free — every dispatching phase
  costs a model call and minutes, and the fix-loop pays it again each lap. A
  typo does not enter the pipeline at all; a small single-layer change skips the
  plan-debate, independent-test and gate agents and lets the user's acceptance
  be the gate; medium/large runs everything. Sizing is a claim made out loud,
  and a task that grows re-sizes upward.
- **Dispatching a judgement.** The fresh-context/FOREGROUND/never-`fork`/
  hand-only-verifiable-artifacts/one-parseable-line contract, written once
  instead of re-argued in three phases — plus the rule that was missing
  entirely: never dispatch what a command answers. An agent is for judgement,
  not for typing.
- **Resuming after a compaction.** Long runs outlive the context window; the
  lane skill had this and this one did not. Re-derive position from the declared
  stage and the diff, and treat a phase whose evidence you cannot find as one
  that did not happen.
- **A `Leave when:` line on every phase.** Objective exit criteria are what stop
  both the silent skip and the endless polish: intake leaves when the answers
  are in hand, review leaves when the latest pass over the CURRENT diff is
  clean, the gate leaves when verification really ran AND the user said yes.

And, narrower: intake asks only what the repo cannot answer (a question the code
could have answered spends the user's round and earns a worse answer);
implement works the approved plan task by task, re-checks each acceptance
criterion, heartbeats on long work, and treats a plan that turns out wrong as a
return to `plan` rather than a quiet substitution; tests runs the suite the diff
needs rather than both by reflex.
2026-09-18 10:39:01 +07:00
nntrivi2001 e217314248 chore(release): bump version to 1.6.13 2026-09-18 10:22:49 +07:00
nntrivi2001 26b0fe53c7 feat(skills,pipelines): ask the user at the two points being wrong is cheap, and stop paying for reviews the diff does not need
The `default` 8-node pipeline walked past its own intake: the node declared
`agenthub stage intake`, a stage report with no work attached, so "intake" was
a label the run passed through without ever asking a question. It now declares
`/superpowers:brainstorming` (and detects it — that rule moves off `plan`),
and both driving skills make the brainstorm mandatory, asked with
AskUserQuestion and waited on rather than answered on the user's behalf.

Three stops replace three silent slides:

- After the plan is written and adversarially challenged, it goes to the USER
  for approval. Implementation does not start without it, and work that turns
  out to need a different approach comes back for a fresh one.
- Review picks its reviewers from `git diff --name-only`: Claude Code's own
  `code-review` always, a domain reviewer only for a layer the diff actually
  touched, and — new — only once the change is big enough to have seams. Three
  agents re-reading twenty lines buys one opinion at three times the tokens, on
  every fix-loop lap. Review is also a loop now: it ends on a clean pass over
  the current diff, not on one pass having happened.
- The gate asks the user to accept before anything is claimed done (and in the
  lane pipeline, before the PR is published — publishing is outward-facing).
  A NO-GO re-enters by SIZE: a defect goes back to implement, a wrong approach
  back to plan. Nothing carries a NO-GO forward with a note about it.

Also here, from a real stall seen on a lane: `ship-feature` had no waiting rule
at all, and a session at the gate invented one with `ScheduleWakeup` — a
/loop-only primitive that paces nothing here and is refused outright when
`auto` mode cannot reach its safety classifier. The rule the lane skill already
had now exists in both.

Cost, measured in what each agent is actually for: the suite run leaves its
agent (nothing is judged, so nothing is delegated — an agent there buys a
second model's tokens to read output the session can read for free), and
`backend-reviewer` drops from opus to sonnet like its frontend twin; both read
a scoped diff against a fixed checklist, and the senior gate above them is
still opus.

Skill bugs found in the same sweep: doc-map told agents not to recreate the
removed translations and then, three lines later, to mirror every README change
into them; the docs mapping still carried `+VN+CN` and the punctuation left by
their removal; Procedure jumped 2 → 6; Verify required `scripts/doc-coverage.sh`,
which does not exist in this repo.
2026-09-18 10:21:03 +07:00
nntrivi2001 4227a97ab1 chore(release): bump version to 1.6.12 2026-09-18 09:45:50 +07:00
nntrivi2001 0004cf7036 fix(lanes): removing a lane deletes its record only, never the worktree
A lane's `remove` tore down what the lane pointed at: `git worktree remove
--force`, a branch delete, and a drop of the lane's own database. One
mis-click on a dashboard row cost a whole branch's unpushed code — and the
row is the only thing AgentHub actually owns. `remove` now stops the run and
the stack, clears AgentHub's own runtime bookkeeping under
`LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its
branch, git's worktree records and the database stay exactly where they are,
for the user to delete by hand when they mean to.

That also fixes #28. Removal used to route through `assertDestroyable`, whose
third check demands the cwd appear in `git worktree list`; a managed lane
whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not
listed as a worktree in …") and could never be dropped from the dashboard at
all. Removal no longer asks git anything, so no preflight blocker stops it,
and the unpushed-commits force-gate now applies to `reset` alone — nothing
`remove` does can discard a commit.

The UI follows: one label for both lane kinds ("Delete lane record"), copy
that says what survives, and no Force checkbox on removal.
`removeWorktree`/`removeLaneData` remain in place as guarded primitives; no
route calls them any more.
2026-09-18 09:44:17 +07:00
nntrivi2001 75a2de60b2 chore(release): bump version to 1.6.11 2026-09-17 23:20:19 +07:00
nntrivi2001 1aee080fca fix(cc-config): read the live account's quota from the CLI, and ask for every account at once
The quota check failed with `invalid_grant` for the account the user was
logged into - the one profile they would try first. An OAuth refresh token is
single use, so every refresh Claude Code performs on its own account retires
the copy sitting in that account's saved profile: the stored credentials of
the LIVE account are the one set guaranteed to be rejected, however recently
the profile was saved. Confirmed on a real install - the saved profile held a
refresh token three rotations behind `.credentials.json`.

`cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against
the live credentials and, on a hit, the usage call uses what the CLI is
holding. That token is never refreshed and that file is never written:
rotating it would log the user out of their own terminal. An expired live
token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it -
and a genuinely spent refresh token is now reported as ESTALE with what to do
about it, instead of echoing Anthropic's raw 400.

Opening the Profiles modal also asks for every saved account at once, rather
than one button per row: comparing accounts is the only reason the panel
exists. The guard against rewriting credentials over and over is no longer
"one row at a time" but the rules above - the live account costs a plain read,
and any other profile refreshes only once its token has actually expired,
which then leaves it valid for hours of further opens. Each row resolves on
its own, so a superseded account still lets its neighbours report.
2026-09-17 23:17:06 +07:00
nntrivi2001 470f9909b4 chore(release): bump version to 1.6.10 2026-09-17 22:22:15 +07:00
nntrivi2001 5ba9e07d78 feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
2026-09-17 22:20:32 +07:00
nntrivi2001 b0e8368c1d chore(release): bump version to 1.6.9 2026-09-17 16:16:39 +07:00
nntrivi2001 686b80bd99 fix(lanes,reload): count a pipeline run by declarations, and stop the two reload commands merging
Closes #26, #25.

`runs` is the denominator every pass rate on the Workflows page is read
against, and any backwards move opened a new one. Stage detection bounces by
design — one feature reads `implement → tests → implement` as the session
edits, runs the suite and edits again — so a single feature was recorded as
five or six runs, and a lane in constant use reported a handful of passes
spread across dozens of phantom runs. A backwards move now opens a run only
when it is DECLARED; a backwards detection continues the run in flight.
Reaching the final node still closes the run for either, since work after
`done` is new work however it was observed. Replayed over this repo's own lane
the rule takes 26 recorded runs down to the 5 features that actually shipped
through it. The panel itself is unchanged: passes are still declarations only.

`/reload-plugins` and `/reload-skills` were typed back to back. Claude Code
opens its slash-command menu the moment `/` is typed and the Enter that follows
lands on that menu, so the second command was appended to the line still in the
composer and the pane submitted `/reload-plugins/reload-skills` — which Claude
Code runs as `/reload-plugins` with an argument, never reloading skills, and
leaving a stray prompt turn in the scrollback. Reproduced and fixed against a
live pane: SETTLE_MS (1s) between the two sends, which makes
`reloadOpenSessions` async rather than block the event loop, and the same pause
in the detached update-all worker.
2026-09-17 16:16:31 +07:00
nntrivi2001 02ee46c862 chore(release): bump version to 1.6.8 2026-09-17 15:09:30 +07:00
nntrivi2001 5bd1c0323f feat(lanes): reorder and rename lane cards, and show each file's line delta
The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.

Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.

That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.

The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
2026-09-17 15:07:37 +07:00
nntrivi2001 b5ca531800 chore(release): bump version to 1.6.7 2026-09-17 13:21:34 +07:00
nntrivi2001 0e38d42cf9 refactor(workspace): drop the page header, move Add lane and Tools onto the lanes
The title and the four counters said a second time what the lane strip
already shows lane by lane (liveness dot, ⚠ marker), so they only cost a row
of height on a screen whose point is the terminal. `GET /api/lanes` still
returns `counts` for other callers.

Add lane becomes the strip's FIRST tile — creating a lane is the same kind of
act as picking one — and the per-lane Tools menu (agents install / mcp sync /
sync-base check) moves onto the lane-detail header row beside the pipeline
picker and the Model select, next to the lane it acts on.
2026-09-17 13:20:14 +07:00
nntrivi2001 af2ad63a18 feat(lanes): name the files behind a lane's uncommitted counts
GET /api/lanes/:id/git now returns `files`, the raw `git status --porcelain`
lines the dirty/untracked counts were already parsed from, capped at 20 so a
lane mid-`npm install` cannot ship thousands of paths through a polled
endpoint. The lane card hovers them as the tooltip on its
"N modified · N untracked" text, appending "… +N" when the cap bit.

No new subprocess and no new endpoint — statusCounts was counting those lines
and throwing them away.
2026-09-17 13:19:07 +07:00
nntrivi2001 2fe1dcd46f chore(release): bump version to 1.6.6 2026-09-17 11:39:00 +07:00
nntrivi2001 7fae2f48c0 refactor(run): drop the lane progress bar, iconify pane layout, and one-row the run setup
Four more spots that spent height saying what was already visible or
saying too much of it.

**LaneCard** drops the progress bar and percent entirely — the pipeline
map right below the card already draws the same number as named,
coloured nodes, and a bar beside it was a worse second telling of it.

**SplitLayoutToggle** (Workspace) swaps "1 pane" / "2 pane" / "4 pane"
text buttons for the conventional square / two-column / 2x2-grid icons
(`Square`, `Columns2`, `Grid2x2`) most apps use for this. The words move
to `aria-label` and `title` rather than disappearing.

**RunSetup** merges the fresh/resume source picker onto the same row as
the Working directory / Model / Permission mode / Thinking effort fields
instead of a second row above them — settings left, New/Resume session
right. The session picker for an active resume still gets its own row
below, since it only appears in that one state.

**LaneConsolePane**'s "Run Claude" header goes from a two-row title +
wrapped blurb to one row (smaller icon and title), and the blurb itself
is trimmed to one short sentence, hidden below `sm` where it would wrap
again anyway.

`docs/LANES.md` updated to match (split-view description, lane-card
field list, and `agenthub stage`'s progress-bar wording).

Verification: `tsc --noEmit` clean, `npx vitest run` 508/508 (2 stale
progress-bar assertions in LaneCard.test.tsx replaced with one asserting
the bar is gone), `npm run build` clean, screens.snapshot diff reviewed
before regenerating (icon buttons + new blurb + reordered DOM only), file
headers audit clean.
2026-09-17 11:37:01 +07:00
nntrivi2001 229002cc2b chore(release): bump version to 1.6.5 2026-09-17 10:02:12 +07:00
nntrivi2001 27923db4e0 refactor(lanes): compact the lane strip tile and the lane-detail header
The Workspace lane panel spent four rows saying what fits in two.

Strip tile: the stage badges move onto the id row and the progress bar
and percent go away — the detail panel's pipeline map right below shows
the same progress with the stage names attached.

Lane detail: the pipeline picker, stage badges, feature picker, router
select and the remove button merge into the single header row instead of
a second row under it, and the pipeline's name is no longer interpolated
into `laneHeader` — the picker beside it already says it. The git block
collapses from four lines to one and drops the cwd, which the header
above it already spells out.

The router select is relabelled Model, moves out of LaneCard into that
header row (`LaneRouterRow` is now exported), and reports its apply
outcome as a ✓/⚠ marker with the message in a tooltip rather than a
wrapped sentence that pushes every row below it down.

Drops the tracker / dev QC / CI wait badge row and everything feeding it
(`useLaneIntegrations`, `api.lanes.integration`, the `integrations.*`
strings): two of the three toggles have no consumer at all, and a badge
reporting a toggle nothing acts on costs a row of height to say nothing.
`agenthub lanes integration` and the API route are untouched.
2026-09-17 09:41:03 +07:00
nntrivi2001 5ab674f134 chore(release): bump version to 1.6.4 2026-09-16 11:28:18 +07:00
nntrivi2001 00a2aedb09 feat(ai-router): test an endpoint before saving it, and re-point a live lane
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.

**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.

The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.

Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.

**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.

Adds `Lane.router_profile` to the client type (the payload already carried it).
2026-09-16 11:26:00 +07:00
nntrivi2001 9128352d1b fix(pricing): price Claude Opus 5, correct Sonnet 5, drop retired models
Claude Opus 5 had no pricing row at all, so every token it burned was costed
at $0 — the pattern list stopped at the 4.x Opus family and nothing matched
`claude-opus-5`. Add it (5/25, fast mode 10/50, same shape as 4.8).

Sonnet 5's launch discount expired 2026-08-31 and the discounted rate became
the published standard, so the row's 3/15 standard rate started overcharging
the day after. Fold 2/10 into DEFAULT_PRICING and empty DEFAULT_INTRO_PRICING;
no model ships a promo today.

Fable/Mythos 5.1 fell through to the `claude-fable-5%` row, which caches at
$1/MTok against 5.1's $0.25 — give them their own rows. That overlap is only
resolvable if the lookup prefers the more specific pattern, and `matchPricing`
took `LIMIT 1` with no ORDER BY, i.e. whichever row was inserted first. Order
by pattern length descending, which is what docs/DATABASE.md already claimed.
(The nested REPLACE(model_pattern, '%', '%') it dropped was a no-op.)

Retire Claude 3.x, Opus 4/4.1 and Sonnet 4 from the defaults. The startup
top-up only ever ADDS missing rows, so removing them from DEFAULT_PRICING
would leave every existing install untouched — prune them once, guarded by
PRAGMA user_version (0 -> 1) so a model deliberately re-added in Settings
survives later restarts. The same one-shot migration corrects the existing
Sonnet 5 row. Only rates are deleted; token_usage is never touched, and
Reset Defaults still restores the full current set.
2026-09-15 15:17:55 +07:00
610 changed files with 84082 additions and 21710 deletions
-20
View File
@@ -1,20 +0,0 @@
---
name: repo-onboarding
description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning.
---
# Repo Onboarding Skill
## Workflow
- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`.
- Determine target layer: `server/`, `client/`, or docs.
- Identify the minimal file set needed for the task.
- Select verification commands before editing.
## Verification defaults
- Backend: `npm run test:server`
- Frontend: `npm run test:client`
## References
- `references/module-map.md`
- `references/verification-map.md`
@@ -1,4 +0,0 @@
interface:
display_name: "Repo Onboarding"
short_description: "Map architecture, ownership, and verification strategy before coding."
default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task."
@@ -1,8 +0,0 @@
# Module Map
- `server/index.js`: app startup and route mounting.
- `server/routes/*.js`: API contracts and route behavior.
- `server/db.js`: schema and statement layer.
- `server/websocket.js`: live update broadcast path.
- `client/src/pages/`: route-level UI.
- `client/src/components/`: reusable UI primitives.
@@ -1,8 +0,0 @@
# Verification Map
- Backend changes:
- `npm run test:server`
- Frontend changes:
- `npm run test:client`
- Docs-only changes:
- validate command consistency against root `package.json`
+1 -19
View File
@@ -12,25 +12,7 @@
"commands": [
"./plugins/agenthub/commands/agenthub-doctor.md",
"./plugins/agenthub/commands/agenthub-update.md",
"./plugins/agenthub/commands/agenthub-open.md",
"./plugins/agenthub/commands/intake.md",
"./plugins/agenthub/commands/plan.md",
"./plugins/agenthub/commands/implement.md",
"./plugins/agenthub/commands/tests.md",
"./plugins/agenthub/commands/e2e.md",
"./plugins/agenthub/commands/e2e-passed.md",
"./plugins/agenthub/commands/review.md",
"./plugins/agenthub/commands/qc-plan.md",
"./plugins/agenthub/commands/qc.md",
"./plugins/agenthub/commands/gate.md",
"./plugins/agenthub/commands/publishing.md",
"./plugins/agenthub/commands/pr-open.md",
"./plugins/agenthub/commands/reported.md",
"./plugins/agenthub/commands/watching-pr.md",
"./plugins/agenthub/commands/merged.md",
"./plugins/agenthub/commands/done.md",
"./plugins/agenthub/commands/ship-full.md",
"./plugins/agenthub/commands/ship-quick.md"
"./plugins/agenthub/commands/agenthub-open.md"
],
"hooks": {
"PreToolUse": [
+4
View File
@@ -38,5 +38,9 @@
"Bash(claude --version)",
"Bash(gh pr *)"
]
},
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
}
}
-1
View File
@@ -24,5 +24,4 @@ This directory contains all project-scoped Codex extensions:
## Included skills
- `repo-onboarding` — architecture discovery and verification selection
- `release-guard` — release readiness checks
-20
View File
@@ -1,20 +0,0 @@
---
name: repo-onboarding
description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning.
---
# Repo Onboarding Skill
## Workflow
- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`.
- Determine target layer: `server/`, `client/`, or docs.
- Identify the minimal file set needed for the task.
- Select verification commands before editing.
## Verification defaults
- Backend: `npm run test:server`
- Frontend: `npm run test:client`
## References
- `references/module-map.md`
- `references/verification-map.md`
@@ -1,4 +0,0 @@
interface:
display_name: "Repo Onboarding"
short_description: "Map architecture, ownership, and verification strategy before coding."
default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task."
@@ -1,8 +0,0 @@
# Module Map
- `server/index.js`: app startup and route mounting.
- `server/routes/*.js`: API contracts and route behavior.
- `server/db.js`: schema and statement layer.
- `server/websocket.js`: live update broadcast path.
- `client/src/pages/`: route-level UI.
- `client/src/components/`: reusable UI primitives.
@@ -1,8 +0,0 @@
# Verification Map
- Backend changes:
- `npm run test:server`
- Frontend changes:
- `npm run test:client`
- Docs-only changes:
- validate command consistency against root `package.json`
+37
View File
@@ -2,6 +2,11 @@
# Override this if your Claude Code data is in a different location, e.g.:
# CLAUDE_HOME=~/.codefuse/engine/cc
# CLAUDE_HOME=~/.claude
# A value saved from Settings (CLAUDE_HOME, CODEX_HOME, a pinned DASHBOARD_DATA_DIR)
# goes to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE). This file wins
# over it, and the real environment wins over both; a key set here or in the real
# environment is reported as env-owned in Settings (a value saved there would be
# overridden again at the next start).
# ── Server / network ────────────────────────────────────────────────────────
# Port to listen on (default: 4820)
@@ -66,6 +71,38 @@
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
# DASHBOARD_SESSION_SYNC_MS=30000
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
# DASHBOARD_DB_MAINTAIN_MS=3600000
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
# Where agy keeps its data, when not ~/.gemini (agy itself has no override).
# AGENTHUB_GEMINI_DIR=~/.gemini
# Poll interval (ms) for Codex's rollout journals ($CODEX_HOME/sessions/**):
# drives both the codex lane running/idle mirror and Codex sessions + tokens
# (0 disables both; default: 15000).
# DASHBOARD_CODEX_POLL_MS=15000
# Automatic pricing: a model seen in token usage with no rate row of its own
# gets one from LiteLLM's public price list. 0 / false / off disables it
# (default: enabled).
# AGENTHUB_PRICING_SYNC=0
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
# their own connection so they never stall other requests. 0 runs them inline
# on the event loop instead (default: worker). A job past the timeout answers
# 503 EREADTIMEOUT and the next one gets a fresh worker (default: 60000).
# AGENTHUB_READ_WORKER=0
# AGENTHUB_READ_TIMEOUT_MS=60000
# Where settings saved from the dashboard persist (default:
# $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env).
# Here or in the real environment; the file is located after this one loads.
# AGENTHUB_SETTINGS_FILE=/home/you/.config/agenthub/settings.env
# ── Remote Data Sources (SSH multi-machine collection) ───────────────────────
# The dashboard can pull Claude Code history from other machines over SSH:
# it rsyncs each enabled remote's ~/.claude/projects into a sandboxed staging
+4 -35
View File
@@ -11,42 +11,11 @@
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
## Declare your lane stage as you work
## Workspace stage
This repository is watched by AgentHub, its own dashboard. A **lane** is keyed by
working directory, and the pipeline map only moves forward when the agent doing
the work declares its stage:
```bash
agenthub stage <stage> --tool codex --evidence "<what is actually done>"
```
- Call it at every real transition — starting to plan, starting to implement,
running tests, opening the PR — not once at the end.
- Stage names, `default` template: `intake → plan → implement → tests → review
→ gate → ship → done` (the 16-node `ship-feature` template accepts these as
aliases too; `agenthub stage` warns on stderr and names the valid nodes if a stage
matches none).
- **`--tool codex` matters.** One directory can back both a `claude` lane and a
`codex` lane, and without a tool the command fails rather than guess (it names
both candidates). Inside a pane the dashboard started you need nothing — that
pane carries `AGENTHUB_LANE_ID`, which outranks everything but `--lane`.
- If `agenthub stage` reports that no lane owns the directory, this checkout was
never adopted: skip the stage calls and carry on. Stage reporting is
reporting, never control flow — a failed `agenthub stage` never justifies skipping
a step of the actual work.
The dashboard also *infers* your stage from your tool calls (it reads Codex's
own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane
that inference does move the pipeline map forward on its own. It still never
counts as **evidence**: a node it moved through stays amber and never renders
green. Only your own declaration does that — which is why the call above is
worth making even when the map already looks right.
If you hold a stage for a while with no evidence recorded, the dashboard may
type a one-line `[agenthub]` reminder straight into this pane asking you to run
`agenthub stage ... --evidence "..."`. That is the dashboard, not the user — treat
it as the same instruction as this section and just make the real call.
AgentHub's `codex` workspaces — lanes in the API and CLI — (like every non-Claude AI tool workspace) carry no pipeline: the dashboard shows no stage for
them and never infers or advances one, so there is no `agenthub stage` call to
make while you work here as Codex.
## Where to work
- `server/` for API/routes/data processing.
+247 -112
View File
File diff suppressed because one or more lines are too long
+35 -17
View File
File diff suppressed because one or more lines are too long
+86 -14
View File
@@ -31,11 +31,10 @@ dashboard, run it from source, or stay on Node 20.
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, and the Codex nudge. See [Step 0](#step-0--system-packages) |
| **tmux** | 3.x (3.7+ for pane scrollbars) | **Required for the run console** — the browser terminal, `agenthub workspace shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` and the PR stages of `/agenthub:ship-full` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub workspace ci status` |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
---
@@ -43,7 +42,7 @@ dashboard, run it from source, or stay on Node 20.
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
backs the run console**. The dashboard shells out to the `tmux` binary and has
no bundled fallback, so without it **Start Run** and `agenthub lanes shell`
no bundled fallback, so without it **Start Run** and `agenthub workspace shell`
fail while everything else keeps working.
```bash
@@ -55,6 +54,22 @@ sudo pacman -S git tmux # Arch
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
On a native Windows install, workspace terminals are unavailable, but Settings →
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
and runs a `.cmd` updater or npm through `cmd.exe`.
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release:
```bash
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz
cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin
```
A tmux server already running keeps its old version until every tmux session
has closed (or the machine reboots) — only then do the scrollbars appear.
Check it:
@@ -73,11 +88,12 @@ curl -s localhost:4820/api/run/tmux # {"available":true}
**No configuration is needed.** AgentHub creates its own session per lane,
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
depends on (mouse mode is set on the session explicitly). You can attach to the
depends on (mouse mode, the hidden status bar and the scrollbar are set on the
session explicitly). You can attach to the
exact same pane from a normal terminal:
```bash
agenthub lanes shell # from inside the lane's directory
agenthub workspace shell # from inside the lane's directory
tmux attach -t agenthub-lane-7 # or by name
tmux ls # every dashboard session is prefixed agenthub-lane-
```
@@ -86,6 +102,61 @@ Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
the browser terminal and your local terminal are two clients on one pane, and
restarting the dashboard never stops a run.
### macOS
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
covered by Linux-side unit tests only; the checklist at the end of this section
is what a Mac user runs to confirm them.
```bash
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
```
- **Node:** a plugin install needs **>= 22.5** (it stores data through
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
service install`), so the dashboard comes back after every login with no
Claude Code session, and launchd restarts it if it dies. Lane panes survive
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
`launchctl` fails, the server is a detached process started by the next
Claude Code session (within about 10 s) or by `agenthub start`, as before. A
discovery entry left over from before the reboot is ignored, so a recycled
pid can never pass for a live dashboard.
- **Started from a Claude Code session.** The server drops the variables that
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
an older build started keeps its dirty global env until it exits
(`tmux kill-server` once, with no run you care about open).
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
volume becomes the one on disk — that is what sessions report, so they bind.
Lanes created before this change keep the string they were created with.
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
macOS may ask once for permission the first time.
- **Option key** in the browser terminal types special characters (it is not
sent as Meta).
**Mac checklist** — please run it and report the result on issue #22:
1. Fresh plugin install from a Terminal `claude` session: the dashboard answers on `http://localhost:4820`, `~/Library/LaunchAgents/io.smartgift.agenthub.plist` exists, and `/agenthub-doctor`'s launchd row says the LaunchAgent is current and running.
2. `tmux show-environment -g | grep -i claude` prints nothing.
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the workspace home.
5. Changes panel: diff, stage and commit on a lane.
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
8. Reboot and log in WITHOUT opening `claude`: the dashboard answers within about 10 s (`agenthub service status` shows `state = running`).
9. `kill <server pid>` (from `agenthub status`): it is back within a few seconds.
10. With a lane run open, `/agenthub-update` completes, the server restarts on the new version, the plist names the new cache directory, and the run's terminal is still alive.
11. `agenthub service uninstall`: the plist is gone and nothing restarts the server after a `kill`.
---
## Step 1 — Clone the repository
@@ -137,7 +208,7 @@ This starts two processes concurrently:
Open **http://localhost:5173** in your browser.
> [!TIP]
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json` — unless the `agenthub` plugin is enabled there, whose own hooks already run; then it removes any copies a checkout wrote instead, since two sets post every event twice. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
---
@@ -160,7 +231,7 @@ After starting a Claude Code session, you should see:
- **Agent Board** — a `Main Agent` card in the `Waiting` column until you type your first message; it flips to `Working` on `UserPromptSubmit` / `PreToolUse` and back to `Waiting` after each `Stop`
- **Activity Feed** — events streaming in as Claude Code uses tools
- **Dashboard** — stats updating in real-time
- **Settings page** — model pricing rules, hook configuration status, data export and cleanup tools
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
@@ -239,13 +310,15 @@ npm start # Start Express serving client/dist on port 4820
Open **http://localhost:4820** in your browser.
`npm start` (and `agenthub start`, its backgrounded form) only run while that
process is alive — a reboot loses it. On Linux, run the server as a systemd
`--user` service instead so it comes back on its own after every reboot, with
no manual step and no Claude Code (or any other tool) needed to trigger it:
process is alive — a reboot loses it. Run the server as a systemd `--user`
service (Linux) or a LaunchAgent (macOS) instead so it comes back on its own
after every reboot, with no manual step and no Claude Code (or any other tool)
needed to trigger it:
```bash
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
agenthub service status # systemctl --user status agenthub
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
```
@@ -262,7 +335,6 @@ This repository includes extension packs for both Claude Code and Codex.
- `CLAUDE.md`
- `.claude/rules/`
- `skills/` (shipped as the `agenthub` plugin's skills)
- `agents/` (shipped as the `agenthub` plugin's agents)
- Codex project packs live under `.codex/`:
- `AGENTS.md`
- `.codex/rules/`
+129 -101
View File
@@ -2,9 +2,9 @@
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
monitoring is one part of it, not the whole. Hooks POST every tool call to an
Express + SQLite server and a React UI updates over WebSocket; **lanes** track
parallel agent work through a pipeline; and the `agenthub` CLI drives real terminal
sessions, lane runtimes, and Claude Code's own configuration.
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
sessions, workspace runtimes, and Claude Code's own configuration.
AgentHub records and shows — it does not orchestrate. The session in control makes
every decision.
@@ -31,25 +31,44 @@ Internal build — all rights reserved.
**Drive**
- **Lanes.** One lane per working directory, surviving session restarts. A lane
moves through pipeline stages and the dashboard shows where it is.
- **Stage detection.** The stage is inferred from the tool stream, so a session
that never calls `agenthub stage` still shows progress — rendered dashed amber and
never as done, because an inference is not evidence.
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a workspace moves through its stages and
the dashboard shows where it is.
- **Stage detection.** With a template installed, the stage is inferred from the
tool stream, so a session that never calls `agenthub stage` still shows
progress — rendered dashed amber and never as done, because an inference is
not evidence.
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per lane (`agenthub lanes up|down|logs`).
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
feature from intake to PR, declaring every stage to the dashboard as it goes.
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
Beside the workspace sits a collapsible **Changes** panel — an overview of the
changed files, a full review of every diff, or one file at a time, comparing
either uncommitted work or the whole branch against its base. There is no
merge/rebase/reset button by design: recovery from those needs a terminal.
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
**Configure**
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
- **Workspaces like Claude Desktop projects.** The sidebar lists every
workspace with its sessions under it (a live one opens its tmux pane, any
other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files and extra Folders, which Claude and Codex
sessions start with.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (General / Data / About / Appearance / Pet / Notifications) /
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
@@ -57,6 +76,14 @@ Internal build — all rights reserved.
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
Per-browser only.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
@@ -66,11 +93,11 @@ Internal build — all rights reserved.
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` lanes |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
| **Codex CLI** | any | Optional — only for `codex` workspaces |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node **25** is the one version to avoid: it breaks 6 server tests through a
@@ -81,8 +108,8 @@ v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub lanes shell` fail without it. Everything else
(sessions, analytics, lanes, stage detection, the CLI) works fine.
**Start Run** and `agenthub workspace shell` fail without it. Everything else
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
@@ -93,6 +120,12 @@ sudo pacman -S tmux # Arch
Windows has no native tmux — run AgentHub under WSL2 and install it there.
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
no systemd, so the dashboard runs until logout/reboot and comes back at the
next Claude Code session, or by hand with `agenthub start`. Details and a
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
Verify, both from your shell and from the server's point of view:
```bash
@@ -100,39 +133,11 @@ tmux -V # e.g. tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
A lane's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
### The superpowers plugin
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
`superpowers:brainstorming`, `writing-plans`, `test-driven-development`,
`requesting-code-review` and `verification-before-completion` phase by phase,
and both built-in pipeline templates name those same slash commands as their
per-node drivers. Without it the dashboard, hooks and CLI all work
normally — the two pipeline skills are what stop partway. `/agenthub-doctor` reports
it as a `WARN` row.
**You do not normally have to install it.** The plugin's `SessionStart`
bootstrap installs it for you, once, in its detached worker — so a session never
waits on the clone, and the current session needs `/reload-plugins` before the
skills resolve (any later session picks them up on its own). The outcome is
logged to `~/.claude/agenthub/runtime/bootstrap.log`, and `/agenthub-update`
retries a failed one. To install it by hand instead, or after opting out:
```bash
claude plugin install superpowers@claude-plugins-official
```
Set `AGENTHUB_AUTO_INSTALL_PLUGINS=0` to opt out of the automatic install. Only the
plugins on a fixed allowlist in `server/lib/skill-plugins.js` are ever installed
this way — `superpowers` today. A custom pipeline template naming some other
plugin's command is *reported* by `/agenthub-doctor` and never installed, because a
template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`.
## Install as a Claude Code plugin
Two commands on a machine that has nothing but Claude Code, no clone and no
@@ -176,19 +181,20 @@ every event is counted twice.
### Run as a service (auto-start on boot)
`npm start` only runs while that terminal (or `agenthub start`'s background
process) is alive — a reboot loses it. On Linux, `agenthub service install` sets
up a systemd `--user` service instead, so `:4820` is up again on its own after
process) is alive — a reboot loses it. `agenthub service install` sets up a
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
every reboot, with no manual step and no Claude Code (or any AI tool) needed
to trigger it:
```bash
npm run build # the service serves the built client, same as npm start
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
agenthub service status # systemctl --user status agenthub
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
```
`agenthub service install` also tries `loginctl enable-linger` so the service
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. Plugin installs get the same persistence automatically — see
@@ -203,80 +209,102 @@ agenthub status # is the dashboard up
agenthub start # start it in the background and wait for healthy
agenthub service install # auto-start on boot via systemd --user (Linux)
agenthub sessions # recent sessions
agenthub lanes # lanes with stage and progress
agenthub lanes pipeline # this lane's pipeline template, or switch it
agenthub lanes node <id> # run ONE pipeline node's own command (no id: list them)
# (in a session: /intake, /qc, /ship-full, /ship-quick …)
agenthub workspaces # workspaces with stage and progress
agenthub workspace pipeline # this lane's pipeline template, or switch it
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
agenthub stage <name> # declare the current lane's stage
agenthub tail # live event feed
```
`agenthub --help` lists the rest.
## Lanes
## Workspaces
A lane is a working directory the dashboard watches. Two kinds:
A workspace is a working directory the dashboard watches. Two kinds:
- **adopted** — a directory you already had. The dashboard only reads it; it is
never reset or deleted.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It owns
the full lifecycle and may reset or remove it, behind a three-check destroy
guard and a counted preflight the caller has to echo back.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
reset that worktree, behind a three-check destroy guard and a counted preflight
the caller has to echo back. Removing a workspace — either kind — deletes only the
dashboard's record of it: the working tree, its branch and its database stay,
for you to delete yourself.
```bash
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
```
Every lane also carries the CLI that drives it — `claude` (the default) or
`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex emits none of this
dashboard's hooks, so a Codex lane records its runs but stays absent from
Sessions and Analytics. Its **stage detection** works anyway, from Codex's own
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
`DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection
also **advances the declared stage** — the one place an inference moves the blue
`current` ring, because a Codex session has no hook and nothing else would ever
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
node stays amber (`passed-no-evidence`), never green, since only the session
itself can say what it finished. Three things help it get there: the run console
pre-fills a codex pane's opening prompt with a stage-declaration checklist built
from its own pipeline nodes, `agenthub lanes agents install` writes the same
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
a lane's pane if its current stage sits without evidence for more than two
minutes.
Every workspace also carries the CLI that drives it — `claude` (the default) or
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex sessions and token usage
are read from Codex's own rollout journals: every Codex session (a workspace's or
not) appears in Sessions with a **Codex** badge and is priced like any other,
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
live feed, install **Codex hooks** from the same tab: the next time Codex starts
it asks you to trust them (**Trust all and continue**), and from then on each
tool call lands in the session's timeline, a codex workspace turns running/idle as
each turn starts and ends, and "Codex needs your permission" shows on the workspace
card and as a notification while Codex waits for an approval. A Codex workspace also
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
advanced; without hooks the dashboard mirrors its running/idle status from the
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
usage are read from agy's own conversation files
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
the same tab, add a live feed: each tool call in the session's timeline and a
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
and it has no hook for a permission prompt, so an Antigravity workspace never shows
"needs your permission". agy fires no hook when it exits, nor when a run ends
on an API error or a permission refused in headless mode, so a session gone
quiet is marked completed and its workspace set idle — after ten minutes of quiet
for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity workspaces carry no pipeline either.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
Further development is planned.
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done.
A lane's pipeline template can be switched after creation — `agenthub lanes
A workspace's pipeline template can be switched after creation — `agenthub workspace
pipeline <template>` from the terminal, or the pipeline-template picker next to
the lane's title in the Workspace detail panel. Both re-resolve the lane's
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
current declared stage against the new template's nodes and warn if it no
longer matches one.
A lane can also run **its own application stack**, isolated per lane, when its
A workspace can also run **its own application stack**, isolated per workspace, when its
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its
ports and per-lane directories derive from it:
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
ports and per-workspace directories derive from it:
```bash
agenthub lanes up # boot the stack of the lane owning this directory
agenthub lanes runtime # slot, ports, service health
agenthub lanes logs api # tail a service log
agenthub lanes down
agenthub workspace up # boot the stack of the lane owning this directory
agenthub workspace runtime # slot, ports, service health
agenthub workspace logs api # tail a service log
agenthub workspace down
```
Services are fully detached, so restarting the dashboard never stops a running
lane. This is resource namespacing on the host, not a container: lanes run as the
workspace. This is resource namespacing on the host, not a container: workspaces run as the
same user and share the network.
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
`POST /:id/git/:action`), and the full
runtime/profile contract.
## Tests
@@ -292,16 +320,16 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
| Path | What |
|---|---|
| `server/` | Express API, SQLite schema, hook ingest, lane and worktree libraries |
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, lanes, database |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard |
## Docs
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
- [`INSTALL.md`](INSTALL.md)
+74 -70
View File
@@ -2,9 +2,9 @@
Bản build nội bộ SmartGift. **Bàn điều khiển** local-first cho agent Claude Code —
monitoring chỉ là một phần, không phải tất cả. Hooks POST mỗi tool call lên server
Express + SQLite và React UI cập nhật qua WebSocket; **lane** (làn đường) theo dõi
công việc song song của agent qua một pipeline; còn CLI `agenthub` điều khiển phiên
terminal thật, runtime của lane, và cả cấu hình của chính Claude Code.
Express + SQLite và React UI cập nhật qua WebSocket; **workspace** theo dõi
công việc song song của agent, mỗi working directory một workspace; còn CLI `agenthub` điều khiển phiên
terminal thật, runtime của workspace, và cả cấu hình của chính Claude Code.
AgentHub ghi nhận và trình bày — nó không điều phối. Phiên đang chạy mới là nơi ra
mọi quyết định.
@@ -31,24 +31,33 @@ Internal build — all rights reserved.
**Điều khiển**
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
session không bao giờ gọi `agenthub stage` vẫn hiển thị progress — render dạng
- **Workspace.** Mỗi working directory một workspace, sống sót qua session restart. Khi bạn
cài một pipeline template (không có template nào đi kèm), workspace đi qua các
stage của nó và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Khi đã cài template, stage được suy luận (infer) từ tool
stream, nên một session không bao giờ gọi `agenthub stage` vẫn hiển thị
progress — render dạng
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
không phải bằng chứng.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
thật, render bằng xterm.js) gắn vào thư mục của workspace — đúng cái TUI bạn thấy ở
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
`agenthub workspace shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
- **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi
workspace được cấp slot, port và service riêng (`agenthub workspace up|down|logs`).
**Cấu hình**
- **Workspace như project của Claude Desktop.** Sidebar liệt kê mọi workspace
kèm các session của nó (session còn sống mở đúng tmux pane, session khác mở
transcript — không bao giờ resume). Vào một workspace là trang chủ của nó: ô
prompt mở session mới, mục Gần đây, cùng Hướng dẫn, Bộ nhớ, file Ngữ cảnh và
Thư mục bổ sung mà session Claude và Codex khởi động kèm.
- **Settings mỗi mục một trang** (`/settings/<section>`): thanh điều hướng bên
trái chia nhóm Chung / Tác tử / Định tuyến / Git, ô tìm kiếm (Ctrl+F) trên mọi
mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung cấp, và
những worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace.
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
settings file, memory file, marketplace, keybinding và statusline mà Claude
@@ -66,11 +75,11 @@ Internal build — all rights reserved.
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho workspace dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub workspace ci status` |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
@@ -80,8 +89,8 @@ cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
### Cài tmux
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
thì **Start Run** và `agenthub workspace shell` sẽ lỗi. Phần còn lại (session,
analytics, workspace, stage detection, CLI) vẫn chạy bình thường.
```bash
sudo apt install tmux # Debian / Ubuntu
@@ -92,6 +101,12 @@ sudo pacman -S tmux # Arch
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
```bash
@@ -99,28 +114,11 @@ tmux -V # ví dụ: tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
Mỗi workspace chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub workspace
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
### Plugin superpowers
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
thì skill mới resolve được (session sau tự nhận). Cài tay:
```bash
claude plugin install superpowers@claude-plugins-official
```
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
## Cài như Claude Code plugin
Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không cần
@@ -191,19 +189,18 @@ agenthub status # dashboard có đang chạy không
agenthub start # start ngầm (background) và chờ tới khi healthy
agenthub service install # tự khởi động khi boot máy qua systemd --user (Linux)
agenthub sessions # session gần đây
agenthub lanes # danh sách lane kèm stage và progress
agenthub lanes pipeline # pipeline template của lane này, hoặc đổi nó
agenthub lanes node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
# (trong session: /intake, /qc, /ship-full, /ship-quick …)
agenthub workspaces # danh sách workspace kèm stage và progress
agenthub workspace pipeline # pipeline template của lane này, hoặc đổi nó
agenthub workspace node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
agenthub stage <name> # khai báo stage hiện tại của lane
agenthub tail # xem live event feed
```
`agenthub --help` liệt kê phần còn lại.
## Lane
## Workspace
Lane là một working directory mà dashboard theo dõi. Hai loại:
Workspace là một working directory mà dashboard theo dõi. Hai loại:
- **adopted** (nhận nuôi) — một thư mục bạn đã có sẵn. Dashboard chỉ đọc nó;
không bao giờ reset hay xóa.
@@ -213,46 +210,53 @@ Lane là một working directory mà dashboard theo dõi. Hai loại:
gọi phải echo lại.
```bash
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
```
Mỗi lane còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
chọn trong modal Add lane và cố định suốt vòng đời của lane. Lane Codex có panel
Mỗi workspace còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
chọn trong modal Add workspace và cố định suốt vòng đời của workspace. Workspace Codex có panel
chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và
resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên
lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
**Stage detection thì vẫn chạy**, đọc từ rollout journal của chính Codex trên đĩa
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, poll mỗi `DASHBOARD_CODEX_POLL_MS` ms,
mặc định 15000) — thứ lane Codex không tự có là stage **khai báo**, vì chỉ chính
session đó mới gọi được `agenthub stage`.
workspace Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
Workspace Codex cũng **không có pipeline** — không stage, không pipeline map, không
suy luận hay tự nhảy stage; dashboard chỉ phản ánh trạng thái running/idle từ
rollout journal của chính Codex trên đĩa (`$CODEX_HOME/sessions/**/rollout-*.jsonl`,
poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
nào, nên workspace không có stage cho tới khi bạn thả một file template JSON vào
`DASHBOARD_PIPELINES_DIR` (xem
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
này sẽ được phát triển tiếp.
Stage khai báo (declared) đến từ `agenthub stage`. Stage suy luận (inferred) đến
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên workspace có
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
báo, và node suy luận không bao giờ render thành `done`.
Pipeline template của lane có thể đổi sau khi tạo — `agenthub lanes
Pipeline template của workspace có thể đổi sau khi tạo — `agenthub workspace
pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template
cạnh tiêu đề lane trong panel chi tiết của trang Workspace. Cả hai đều
re-resolve lại stage đã khai báo hiện tại của lane theo node-list của template
cạnh tiêu đề workspace trong panel chi tiết của trang Workspace. Cả hai đều
re-resolve lại stage đã khai báo hiện tại của workspace theo node-list của template
mới, và cảnh báo nếu nó không còn khớp node nào.
Lane cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
lane, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi lane
được cấp một slot, port và thư mục riêng theo lane được suy ra từ đó:
Workspace cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
workspace, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi workspace
được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó:
```bash
agenthub lanes up # boot stack của lane sở hữu thư mục hiện tại
agenthub lanes runtime # slot, port, tình trạng service
agenthub lanes logs api # tail log của một service
agenthub lanes down
agenthub workspace up # boot stack của lane sở hữu thư mục hiện tại
agenthub workspace runtime # slot, port, tình trạng service
agenthub workspace logs api # tail log của một service
agenthub workspace down
```
Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng
lane đang chạy. Đây là namespacing tài nguyên trên host, không phải
container: các lane chạy chung user và share network.
workspace đang chạy. Đây là namespacing tài nguyên trên host, không phải
container: các workspace chạy chung user và share network.
[`docs/LANES.md`](docs/LANES.md) có mô hình pipeline, destroy guard, hợp đồng
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
@@ -272,16 +276,16 @@ thêm Prettier.
| Đường dẫn | Là gì |
|---|---|
| `server/` | Express API, schema SQLite, hook ingest, thư viện lane và worktree |
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
| `client/` | Dashboard React 18 + Vite + Tailwind |
| `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, lanes, database |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
## Tài liệu
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
- [`docs/LANES.md`](docs/LANES.md) — lane, pipeline, stage detection
- [`docs/LANES.md`](docs/LANES.md) — workspace, pipeline, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
- [`INSTALL.md`](INSTALL.md)
+6 -2
View File
@@ -12,7 +12,7 @@ Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup. Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
@@ -132,7 +132,11 @@ npm run clear-data
### Data management via Settings page
The Settings page (`/settings`) provides a UI for:
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
File diff suppressed because one or more lines are too long
-27
View File
@@ -1,27 +0,0 @@
---
name: backend-reviewer
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
tools: Read, Grep, Glob, Bash
model: opus
memory: project
---
You are a backend reviewer for this repository. Review ONLY files under `server/` in the diff you are given — ignore `client/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
Focus on:
- Hook event lifecycle correctness.
- Session/agent state-machine regressions.
- API contract compatibility.
- Transaction and persistence correctness.
- Missing or weak verification coverage.
## Output (MANDATORY — the caller parses your last line)
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
- End with exactly one of:
- `BACKEND-REVIEW: CLEAN`
- `BACKEND-REVIEW: FINDINGS — <n>`
## Agent memory
Record recurring backend defect patterns and false-positive traps you find here (e.g. hook shapes that look wrong but aren't, common state-machine edge cases). Future backend-reviewer runs read this.
-27
View File
@@ -1,27 +0,0 @@
---
name: frontend-reviewer
description: Review React UI changes for behavior regressions, state consistency, and UX breakage.
tools: Read, Grep, Glob, Bash
model: sonnet
memory: project
---
You are a frontend reviewer for this repository. Review ONLY files under `client/` in the diff you are given — ignore `server/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
Focus on:
- Routing and navigation consistency.
- State updates from websocket and API responses.
- Loading/empty/error state correctness.
- Breaking visual or interaction regressions.
- Missing tests for changed UI behavior.
## Output (MANDATORY — the caller parses your last line)
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
- End with exactly one of:
- `FRONTEND-REVIEW: CLEAN`
- `FRONTEND-REVIEW: FINDINGS — <n>`
## Agent memory
Record recurring frontend defect patterns and false-positive traps you find here (e.g. websocket race conditions that look wrong but aren't, sibling components that should stay in sync). Future frontend-reviewer runs read this.
-74
View File
@@ -1,74 +0,0 @@
---
name: "qc-local"
description: "Local browser-QC agent for the ship-feature-lane pipeline in AgentHub. Launched by the ship-feature-lane skill at Stage 6 (foreground — it gates the pipeline) after e2e, against the lane's OWN feature-branch stack (booted via `agenthub lanes up --qc`; the branch is cut from origin/development, so the stack is development + this feature). Exercises the feature + smoke + reload/re-login coverage via the lane's local-QC Playwright MCP, saves every screenshot to `proof/<feature-slug>/qc-local/` (the AgentHub proof gallery's canonical path), and returns a parseable LOCAL-QC: PASS/FAIL verdict that doubles as the feature user-flow review for the senior gate. <example>Context: ship-feature-lane in a lane finished Stage 3's e2e green. assistant: 'e2e is green — launching the qc-local agent to browser-QC the feature-branch stack and capture the proof gallery.' <commentary>Stage-6 browser QC is the qc-local agent's job; the main session stays off the browser.</commentary></example>"
model: opus
color: green
memory: project
---
You are an elite QA automation engineer validating ONE lane's feature on that lane's OWN local stack (isolated FE/API/DB, nothing shared with other lanes), right after the feature branch's e2e passed (the branch is cut from `origin/development`, so this stack IS development + the feature as of the branch point). You are rigorous, evidence-driven, and autonomous: every claim in your report is backed by a screenshot. You never commit, push, or fix code — you verify, capture proof, and report. Your report IS the feature user-flow review the senior gate reads.
## Context you will be given (by the ship-feature-lane skill)
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically; there is no marker file to check).
- **Feature slug** (the `<slug>` of `feat/<slug>`), feature title, and the acceptance points to verify (from the lane spec at `docs/superpowers/specs/lane-<slug>.md` — read it yourself if the summary is thin; `git diff origin/development...HEAD` shows the change surface).
Setup: your cwd is the lane clone. You do NOT write `stage`/`status`/`notes` — the main session owns all state; you have no heartbeat call to make (you run as a synchronous subagent inside the driving session's own turn, so the lane is never seen as stalled while you work).
## Target environment
- **App**: the lane's own booted frontend — check `agenthub lanes runtime` (or the `FE_PORT`/equivalent env the skill already resolved) for the actual port; it is isolated per lane. If it's down, don't boot it yourself blindly — return `LOCAL-QC: FAIL — lane stack down (re-run: agenthub lanes up --no-build)`.
- **QC boot env**: the QC stack boots via `agenthub lanes up --qc`, which applies the profile's `QC_BOOT_ENV` (typically mock/stub flags) — LLM/paid-backend features then return CANNED responses. Canned/stub content is EXPECTED there, not a bug; judge the surrounding UX (loading states, rendering, persistence), not the stub text itself.
- **Account / login.** Two paths:
- **If a credentials block is embedded at the TOP of this agent** ("⚙ This lane's local-QC credentials"): those ARE your login — type them into the form yourself (`browser_type`, submit) when you hit a login page. You already have them; never read a file/script to fetch them.
- **If there is NO embedded block:** the app should already be in a usable/logged-in state (or requires no login for the flows you're testing). If you hit a login page you cannot get past, this profile has no seeded QC account configured yet — return `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile`. Do not guess at credentials or invent a login flow.
- **Tooling**: the **dedicated local-QC Playwright MCP server** (isolated Chromium profile — e.g. `playwright-qa-local` if the profile names it that). You MUST use that server's `mcp__<name>__browser_*` tool family for ALL browser interaction — never the unscoped `mcp__playwright__browser_*` or any other Playwright server (other agents/sessions own those browsers concurrently). If the tools aren't available, return `LOCAL-QC: FAIL — local-QC Playwright MCP not loaded (configure it in .mcp.json and restart the lane session — see the ship-feature-lane skill's Setup section)`.
- **Upload fixtures**: if an **"Upload fixtures dir"** line is injected at the TOP of this agent, use the files in that dir for any data-upload scenario (quote the path; it may contain spaces). If no such line is present, no fixture dataset is configured for this stack — skip data-upload-only scenarios and note that in the report. Put ad-hoc non-fixture files in the lane's `.playwright-mcp/` dir; never source uploads from `/tmp/`.
## Procedure
1. **Load the QC Plan, then execute against it.** The ship-feature-lane skill wrote a `## QC Plan` section into the lane spec (`docs/superpowers/specs/lane-<slug>.md`) at Stage 5 — it is the authoritative scope. Read it first.
- **Cover every in-scope scenario** in the plan — skip none.
- **Stay in scope** — don't wander into the plan's out-of-scope areas (that over-testing is exactly what the plan exists to prevent); the only unaffected areas you touch are the plan's smoke set.
- **If the plan is missing or thin**, derive scenarios yourself from the acceptance points + `git diff origin/development...HEAD` and proceed, covering the same shape: **primary** (each acceptance point, positive AND negative — invalid input, empty state, permission edges), **adjacent** flows sharing routes/components/data, **smoke** (login + main nav + ≥3 unaffected major areas), and **state coverage** (a reload (Cmd+R) on every stateful screen touched, one logout → re-login cycle, back-and-forth navigation between key pages).
- **When you discover a real scenario the plan missed** (a genuine risk it didn't anticipate), TEST it AND list it in your report under **"Scenarios discovered during QC"** so the skill folds it into the plan. Do NOT edit the plan file yourself — the skill is its single writer.
- **UI/UX & layout rigor — apply to EVERY form/screen the feature touches** (never just a representative one; sibling forms drift):
- **Overflow in BOTH axes.** Resize the window NARROWER (width) AND SHORTER (height — e.g. ~560px then ~350px tall), and ALSO grow the content by opening every expandable thing (dropdowns, collapsible sections, "add row" repeaters, multi-select pickers) so a step becomes taller than the viewport. Then confirm: nothing is cut off at the **top** or bottom; any fixed/sticky chrome (page header, wizard **stepper/tabs**, toolbars) stays visible and is NOT clipped; the scroll container actually scrolls; and the primary actions (Save/Next/Submit/Cancel) stay reachable and clickable. A form taller than the page must never hide its header, its step nav, or its buttons.
- **Every control has a visible label.** Each input/toggle/select/picker shows a field label. Compare sibling forms — if one labels a control and the other doesn't, that's a defect.
- **Visual hierarchy is correct.** Section/group headers are MORE prominent than the field labels inside them (size/weight/color); field labels are consistent across steps and across sibling forms; helper text is least prominent. Flag any inverted or inconsistent hierarchy.
- No text truncation/overlap; spacing, alignment, and contrast are reasonable; the form reads as natural, polished UI.
2. **Execute with the local-QC MCP's tools**:
- Use `browser_type` for React controlled inputs — never direct DOM value assignment (it doesn't fire onChange).
- After each meaningful action: `browser_snapshot` to verify state, then screenshot (see proof convention).
- Don't trust `browser_network_requests` alone for HTTP verification (it double-lists requests); prefer the lane's own backend/service logs if the profile exposes them (`agenthub lanes logs <svc>`).
- **Failure triage**: capture a failure screenshot + `browser_snapshot` + `browser_console_messages`, note repro steps, expected vs actual, severity (blocker/major/minor/cosmetic) — then CONTINUE with remaining scenarios unless the app is unusable.
3. **Proof convention (mandatory — the path is fixed infrastructure, NOT your choice):** EVERY screenshot via `browser_take_screenshot` with `filename: "proof/<feature-slug>/qc-local/<NN>-<what>.png"`, numbered in flow order (e.g. `proof/edit-report/qc-local/03-dialog-open.png`). The MCP server's pinned `--output-dir` lands these in `<lane clone>/.playwright-mcp/proof/...` — the ONLY place the dashboard's gallery reads (`agenthub lanes proof-link` converges a stray `proof/` symlink if needed). Absolute paths, other folders, or invented layouts = the proof is lost.
## Checkpoint & resume *(rate-limits and crashes must not zero your work)*
- **Checkpoint as you go:** after EACH scenario, append one line to `proof/<feature-slug>/qc-local/RESULTS.partial.md` (via the Write/Edit tools on `<lane clone>/.playwright-mcp/proof/...`): `<NN> <scenario> — ✅|❌ — <proof file> — <HEAD sha>`. A mid-run tool error or rate-limit then leaves a resumable trail instead of zeroing the run.
- **Resume on start:** if `RESULTS.partial.md` already exists for this feature AND its HEAD sha matches the tree you're QCing, treat its ✅ scenarios as done (spot-check one) and continue from the first unfinished one. Sha differs → start fresh (delete the stale partial).
- **Bounded stalls:** a single browser step hanging >2 min → close the browser, reload, retry that step ONCE; still stuck → record ❌ with proof and move on. If the run approaches ~45 min, finish the current scenario, mark the rest ⚠️ not-run, and return honestly rather than stalling silently.
## Quality bar (verify before returning)
- [ ] Every in-scope QC-Plan item was executed (or marked N/A with a reason); any discovered scenarios are listed for fold-back.
- [ ] Every primary scenario has a screenshot of its end state (pass or fail).
- [ ] Reload tested on every stateful screen touched; one full logout → re-login cycle done.
- [ ] Smoke covered login, navigation, and ≥3 unaffected areas.
- [ ] UI/UX pass done on EACH touched form: tested at narrow AND short viewports AND with dropdowns/expandables open (content taller than the page) — fixed header/stepper/buttons never cut off, scroll works, actions reachable; every control labeled; section headers more prominent than field labels; consistent label styling; no overlap/truncation; natural polished layout.
- [ ] All screenshots under `proof/<feature-slug>/qc-local/` (check with `ls .playwright-mcp/proof/<feature-slug>/qc-local/`).
- [ ] No commits made; password never echoed anywhere.
## Output format (MANDATORY — the skill parses your last line)
A scannable report (this doubles as the user-flow review for the senior gate): a **QC-Plan coverage** view (each in-scope plan item → ✅/❌/⚠️), a **Scenarios discovered during QC** list (anything you tested that wasn't in the plan, for the skill to fold back), issues with severity + repro + which proof file shows each, proof filename list, reload/re-login coverage note, UX observations worth fixing. Then end with exactly one of:
- `LOCAL-QC: PASS`
- `LOCAL-QC: FAIL — <comma-separated reasons>`
A FAIL must be specific enough for the lane's fix-loop to act on (page/flow + what broke + proof file).
## Agent memory
Record local-stack QA knowledge as you find it: flaky selectors/flows, features that break after reload or re-login, stub-mode quirks (what canned responses look like per feature), data-setup prerequisites, upload-fixture behaviors, noise-vs-real console errors. Future qc-local runs (any lane) read this.
-52
View File
@@ -1,52 +0,0 @@
---
name: "senior-gate-reviewer"
description: "Final GO/NO-GO publish-readiness gate for the ship-feature-lane pipeline in AgentHub. Invoke AFTER the code-review and feature-user-flow-review (qc-local) and AFTER local CI gates + e2e + QC have passed, immediately BEFORE the feature branch is pushed and its PR is opened (base `development`). Returns a single GO or NO-GO verdict with a required-fix list. A GO is the sole authorization to publish the PR. <example>Context: ship-feature-lane in a lane has finished implementation, gates, and reviews and is about to publish the PR. assistant: 'I'll launch the senior-gate-reviewer agent to make the final go/no-go decision before pushing the branch and opening the PR.' <commentary>This is the publish gate — it decides whether the feature is allowed in front of reviewers.</commentary></example>"
model: opus
color: red
memory: project
---
You are the **Senior Gate Reviewer** — the final, independent publish-readiness authority for the ship-feature-lane pipeline. Nothing is pushed or PR'd without your **GO**; the PR a human later merges into `development` is exactly what you approved. You are deliberately skeptical: when in doubt, you return **NO-GO** with a precise, actionable fix list. You do NOT make fixes yourself — you judge, decide, and report.
## Context you will be given (by the ship-feature-lane skill)
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically).
- **The original requirement** and the **upfront Q&A answers** (acceptance criteria).
- The **feature branch name** (cut from `origin/development` — the PR base it will target).
- A summary of the **two prior reviews** (code-review + the qc-local user-flow review) and which findings were resolved.
- The **`agenthub lanes sync-base --check` preflight output** (migration-collision check + how far `origin/development` moved since the branch's merge-base).
- Confirmation that **local CI gates** (the profile's lint / test / contract checks), **e2e**, and **browser QC** already passed.
If any of this context is missing, gather it yourself (read the lane spec file the skill wrote, run `git -C <lane> log/diff`).
## Your checks
**Review the PR the reviewers will see.** The PR is `<feature-branch>` based on `origin/development` — judge the change against **the PR diff**: `git -C <lane> diff origin/development...<feature-branch>` (`<feature-branch>` is in your context). There is NO local integration tree in this flow — the branch itself is what ships, and the first combined validation after a human merges it is dev CI + dev-QC; your merge-safety check (#3) is what stands between this branch and that merge. Run checks with `git -C <lane> ...`, `Read`, `Grep`, `make` as needed.
1. **Acceptance** — Does the implementation actually satisfy the original requirement + every upfront Q&A answer? Inspect **the PR diff** (defined above) and, where feasible, confirm the acceptance criteria are met in code. Missing/partial requirement coverage ⇒ NO-GO.
2. **Findings resolved** — Were the PR-code-review and user-flow-review findings actually addressed (not just acknowledged)? Spot-check **the PR diff** for each claimed fix. Unresolved material findings ⇒ NO-GO.
3. **Merge-safety** — Independently verify the branch is safe for a human to merge into `development`. **General principle (any stack):** "the branch is green standalone" is NOT proof the MERGE will be — check for collisions that DON'T surface as git conflicts — duplicate migration identifiers, duplicate fixture/test IDs, API/schema-contract drift, lockfile divergence — in whatever form this stack expresses them, comparing the branch against the CURRENT `origin/development`. Re-run the deterministic preflight yourself if the provided output is stale: `git -C <lane> fetch origin && agenthub lanes sync-base --check <feature-branch>` (exit 5 = collision ⇒ NO-GO with the printed rename; also read its `DEV_DELTA`/`DEV_OVERLAP` — a large overlapping upstream delta means the locally-QC'd behavior may not match post-merge dev, weigh it).
- **Stack-specific checks (from the active profile):** read `<lane>/.agenthub/profile/review-checks.md` with the Read tool if it exists, and apply every check it lists (e.g. migration-identifier collisions, API/contract regeneration). Missing or empty → derive the equivalents yourself from the general principle by inspecting the branch vs `origin/development`.
- **Obvious regressions / scope creep**: scan **the PR diff** for debug code, secrets, commented-out blocks, unrelated churn, `console.log`/`print` debugging, TODO/FIXME left in critical paths.
4. **UI/UX diligence (when the feature touches any form/screen)** — don't rubber-stamp the QC report; confirm it actually exercised layout rigor, because these defects slip through happy-path QC:
- **Overflow** was tested at narrow AND **short** viewports, AND with dropdowns/collapsibles/repeaters **open** so content exceeds the viewport — and no fixed chrome (page header, wizard **stepper/tabs**, action bar) is cut off (especially at the **top**), scroll works, and primary actions stay reachable. A report that only shows one viewport / the happy path has NOT verified this.
- Every control is **labeled**, and **section headers are more prominent than field labels** (no inverted hierarchy); labels are consistent across **sibling forms** (if the feature changed two similar forms, they must match).
- Spot-check **the PR diff** yourself for UI/UX regressions in these classes (a scroll container that lost `min-h-0`, a removed/altered label, a muted section header, a form-wide container that scrolls the whole page instead of an inner region).
If the feature is UI-heavy and the QC didn't demonstrably check the above ⇒ NO-GO with a specific "re-QC: verify <X> at short viewport / with <dropdown> open" instruction.
5. **QC-plan coverage** — read the `## QC Plan` section of the lane spec and the qc-local report (the user-flow review; on a follow-up fix run, also the parent's dev-QC findings the follow-up exists to fix). Confirm every **in-scope** plan item has a result (pass/fail, with proof) — any uncovered in-scope item ⇒ NO-GO ("re-QC: cover <item>"). If a QC report spent effort on the plan's **out-of-scope** areas while leaving in-scope items thin, flag it. Scenarios the agents discovered mid-run should appear folded into the plan.
## Output format (MANDATORY — the skill parses your last line)
Write a short report (acceptance ✓/✗, findings ✓/✗, merge-safety ✓/✗ with one line each), then end with **exactly one** of these as the FINAL line:
- `VERDICT: GO`
- `VERDICT: NO-GO — <comma-separated required fixes>`
Rules:
- Only return `GO` when all three checks pass with high confidence.
- A NO-GO fix list must be specific and actionable (file/area + what to do), so the skill's fix-loop can act and re-submit.
- Never push, merge, commit, or modify files. You are read-only. Your verdict is the deliverable.
+204 -273
View File
@@ -167,11 +167,13 @@ async function api(method, pathname, body, options = {}) {
const url = `${baseUrl()}${pathname}`;
let res;
try {
// A FormData body (a file upload) sets its own multipart Content-Type.
const form = body instanceof FormData;
res = await fetch(url, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
body: body ? (form ? body : JSON.stringify(body)) : undefined,
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
});
} catch {
throw new ServerDownError();
@@ -1289,7 +1291,7 @@ async function cmdDoctor() {
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
);
} catch {
@@ -1340,8 +1342,8 @@ async function cmdExport(positional) {
}
// Restore a bundle produced by `agenthub export` (or the dashboard's Export data).
// The local server reads the file from disk, so we pass an absolute path rather
// than uploading — idempotent and non-destructive (existing sessions skipped).
// Uploaded like the dashboard's Restore button does — the server never reads a
// path from a request. Idempotent and non-destructive (existing sessions skipped).
async function cmdImportData(positional) {
const target = positional[0];
if (!target) {
@@ -1353,7 +1355,9 @@ async function cmdImportData(positional) {
console.error(c.red(`✖ File not found: ${abs}`));
process.exit(1);
}
const r = await post("/api/settings/import", { path: abs });
const form = new FormData();
form.append("file", new Blob([fs.readFileSync(abs)]), path.basename(abs));
const r = await post("/api/settings/import", form, { timeoutMs: 10 * 60_000 });
console.log(
`${c.green("✔")} Restored: ${r.sessions_imported ?? 0} sessions added, ` +
`${r.sessions_skipped ?? 0} already present, ${r.events ?? 0} events, ` +
@@ -1463,16 +1467,24 @@ async function cmdStart(flags) {
}
/**
* Installs/removes a systemd `--user` service so the dashboard survives
* reboots without anything (Claude Code included) needing to start it first.
* Linux/systemd only; other platforms are told to keep using `agenthub start`.
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
* (macOS) so the dashboard survives reboots without anything (Claude Code
* included) needing to start it first. Elsewhere, `agenthub start` by hand.
*/
async function cmdService(sub, flags) {
const svc = require(path.join(REPO_ROOT, "scripts", "systemd-service.js"));
const svc = require(path.join(REPO_ROOT, "scripts", "service-manager.js"));
if (!svc.systemctlAvailable()) {
console.error(c.red("✖ systemd --user is not available on this machine."));
console.error(
c.dim(" Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead.")
c.red(
`✖ ${svc.kind === "launchd" ? "launchd (a GUI login session)" : "systemd --user"} is not available here.`
)
);
console.error(
c.dim(
svc.kind === "launchd"
? " A LaunchAgent needs the user logged in at the Mac itself, not only over SSH. Use `agenthub start` meanwhile."
: " Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead."
)
);
process.exit(1);
}
@@ -1493,24 +1505,27 @@ async function cmdService(sub, flags) {
description: "AgentHub dashboard server",
workingDir: REPO_ROOT,
execStart: `${process.execPath} ${path.join(REPO_ROOT, "server", "index.js")}`,
argv: [process.execPath, path.join(REPO_ROOT, "server", "index.js")],
env,
});
if (result === "foreign") {
console.error(
c.red(
`✖ ~/.config/systemd/user/${name}.service already exists and wasn't written by agenthub.`
)
c.red(`✖ ${svc.unitPath(name)} already exists and wasn't written by agenthub.`)
);
console.error(c.dim(" Remove or rename it, then re-run `agenthub service install`."));
process.exit(1);
}
if (result === "failed") {
console.error(c.red("✖ Failed to write/enable the systemd unit."));
console.error(c.red(`✖ Failed to write/enable the ${svc.label}.`));
process.exit(1);
}
console.log(
`${c.green("✔")} systemd --user service '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
`${c.green("✔")} ${svc.label} '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
);
if (svc.kind === "launchd") {
console.log(c.dim(` It starts at every login. Server log: ${svc.logPath()}`));
return;
}
const lingered = svc.enableLinger();
if (lingered) {
console.log(
@@ -1531,20 +1546,24 @@ async function cmdService(sub, flags) {
if (sub === "uninstall") {
const result = svc.uninstallUserService(name);
if (result === "foreign") {
console.error(
c.red(
`✖ ~/.config/systemd/user/${name}.service wasn't written by agenthub — not touching it.`
)
);
console.error(c.red(`✖ ${svc.unitPath(name)} wasn't written by agenthub — not touching it.`));
process.exit(1);
}
console.log(
`${c.green("✔")} systemd service ${result === "absent" ? "was not installed" : "removed"}.`
`${c.green("✔")} ${svc.label} ${result === "absent" ? "was not installed" : "removed"}.`
);
return;
}
if (sub === "status") {
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], { stdio: "inherit" });
if (svc.kind === "launchd") {
spawn("launchctl", ["print", `gui/${process.getuid()}/io.smartgift.${name}`], {
stdio: "inherit",
});
} else {
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], {
stdio: "inherit",
});
}
return;
}
console.error(c.red("✖ Usage: agenthub service install|uninstall|status [--port N]"));
@@ -1593,10 +1612,11 @@ async function cmdLanesAdd(args) {
const pipeline = flag("pipeline");
const base = flag("base");
const slug = flag("slug");
const tool = flag("tool");
if (repo) {
if (cwd || pipeline) {
console.error(
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]"
"usage: agenthub workspace add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
);
process.exitCode = 1;
return;
@@ -1604,6 +1624,7 @@ async function cmdLanesAdd(args) {
const body = { sourceRepo: repo, title: title || path.basename(repo) };
if (base) body.base = base;
if (slug) body.slug = slug;
if (tool) body.tool = tool;
const { lane: provisioning } = await post("/api/lanes/worktree", body);
const deadline = Date.now() + 30_000;
let lane = provisioning;
@@ -1612,52 +1633,36 @@ async function cmdLanesAdd(args) {
({ lane } = await get(`/api/lanes/${provisioning.id}`));
}
if (lane.status === "provisioning") {
console.error(`✖ Timed out waiting for worktree lane #${lane.id} to finish provisioning.`);
console.error(
`✖ Timed out waiting for worktree workspace #${lane.id} to finish provisioning.`
);
process.exitCode = 1;
return;
}
if (lane.status === "failed") {
console.error(
`✖ Worktree lane #${lane.id} failed: ${lane.notes || "no failure details reported"}`
`✖ Worktree workspace #${lane.id} failed: ${lane.notes || "no failure details reported"}`
);
process.exitCode = 1;
return;
}
console.log(
`${c.green("✔")} Worktree lane #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
`${c.green("✔")} Worktree workspace #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
);
return;
}
if (!cwd || !title) {
console.error("usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>]");
console.error(
"usage: agenthub workspace add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
);
process.exitCode = 1;
return;
}
const body = { cwd, title };
if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body);
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
}
/**
* `agenthub skills install` — copy this checkout's `skills/ship-feature-lane/`
* into `~/.claude/skills/ship-feature-lane/`, so `/ship-feature-lane` is
* discoverable from a session running inside any lane's own working
* directory, not just inside this repo. Pure filesystem action; does not
* talk to the dashboard server at all. Reinstall, not merge — overwrites an
* existing global copy with the current one.
*/
function cmdSkillsInstall() {
const { installShipFeatureLaneSkill } = require(
path.join(REPO_ROOT, "server", "lib", "skills-install.js")
);
try {
const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
} catch (err) {
console.error(`✖ ${err.message}`);
process.exitCode = 1;
}
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
}
/**
@@ -1699,7 +1704,7 @@ function cmdLanesProfileInit(args) {
const repo = args.find((arg) => !arg.startsWith("--"));
const force = args.includes("--force");
if (!repo) {
console.error("usage: agenthub lanes profile init <repo> [--force]");
console.error("usage: agenthub workspace profile init <repo> [--force]");
process.exitCode = 1;
return;
}
@@ -1741,7 +1746,7 @@ function cmdLanesProfileInit(args) {
console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
for (const todo of result.todos) console.log(` ${todo}`);
}
console.log(`\nNext: agenthub lanes profile check ${repo}`);
console.log(`\nNext: agenthub workspace profile check ${repo}`);
}
/**
@@ -1790,39 +1795,46 @@ async function cmdLanesLifecycle(action, args) {
const fields = LANE_PREFLIGHT_FIELDS[action];
if (!id) {
const extra = action === "reset" ? " [--keep-db]" : "";
console.error(`usage: agenthub lanes ${action} <id> [--force]${extra} --yes`);
console.error(`usage: agenthub workspace ${action} <id> [--force]${extra} --yes`);
process.exitCode = 1;
return;
}
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
console.log(` database: ${preflight.database} (${fate})`);
}
if (action === "remove") {
console.log(
" Only the dashboard's record is dropped — the working tree, its branch and the database are left for you to delete yourself."
);
}
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
console.log("Warnings:");
for (const warning of preflight.warnings) console.log(` ${warning}`);
}
if (!args.includes("--yes")) {
console.error(`Refusing to ${action} lane #${id} without --yes. Re-run with --yes to proceed.`);
console.error(
`Refusing to ${action} workspace #${id} without --yes. Re-run with --yes to proceed.`
);
process.exitCode = 1;
return;
}
// Only `reset` is impossible for an adopted lane. `remove` IS supported by the
// server: it drops the dashboard's record and never touches the directory
// (server/routes/lanes.js scopes the worktree teardown to managed lanes), so
// refusing it here would make a permitted action unreachable from the CLI.
// server for every lane kind: it drops the dashboard's record and never touches
// the directory, so refusing it here would make a permitted action unreachable
// from the CLI.
if (
action === "reset" &&
Array.isArray(preflight.blocked) &&
preflight.blocked.includes("adopted")
) {
console.error(
`Lane #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub workspace remove ${id} --yes\` to drop only the dashboard's record of it.`
);
process.exitCode = 1;
return;
@@ -1835,7 +1847,7 @@ async function cmdLanesLifecycle(action, args) {
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
if (result.status) {
const error = result.data?.error || {};
console.error(`✖ ${action} lane #${id} → ${error.message || `HTTP ${result.status}`}`);
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
if (result.status === 409 && error.code === "ESTALE") {
printLaneFacts("Expected preflight:", error.expected || {}, fields);
printLaneFacts("Current preflight:", error.current || {}, fields);
@@ -1844,12 +1856,12 @@ async function cmdLanesLifecycle(action, args) {
return;
}
if (action === "reset") console.log(`${c.green("✔")} Reset lane #${id}.`);
if (action === "remove") console.log(`${c.green("✔")} Removed lane #${id}.`);
if (action === "reset") console.log(`${c.green("✔")} Reset workspace #${id}.`);
if (action === "remove") console.log(`${c.green("✔")} Removed workspace #${id}.`);
if (action === "purge") {
const purged = result.purged || {};
console.log(
`${c.green("✔")} Purged lane #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
`${c.green("✔")} Purged workspace #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
);
}
}
@@ -1889,7 +1901,9 @@ function detectionLeadsDeclaration(l) {
async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) {
console.log("no lanes yet — create one with: agenthub lanes add --cwd <path> --title <text>");
console.log(
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
);
return;
}
for (const l of lanes) {
@@ -1902,7 +1916,7 @@ async function cmdLanes() {
);
}
console.log(
`\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
`\n${counts.total} workspaces · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
);
}
@@ -1943,7 +1957,7 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane");
const explicit = flagValue("lane") || flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
@@ -1967,7 +1981,9 @@ async function resolveLaneArg(args) {
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) {
console.error(`no lane owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
console.error(
`no workspace owns ${cwd} — create one with: agenthub workspace add --cwd ${cwd}`
);
process.exitCode = 1;
return null;
}
@@ -1977,7 +1993,7 @@ async function resolveLaneArg(args) {
const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest;
if (!candidates.length) {
console.error(
`no ${tool} lane owns ${owning[0].cwd} — lanes here: ${nearest
`no ${tool} workspace owns ${owning[0].cwd} — workspaces here: ${nearest
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
@@ -1986,9 +2002,9 @@ async function resolveLaneArg(args) {
}
if (candidates.length > 1) {
console.error(
`${candidates.length} lanes own ${owning[0].cwd} — pass --tool <${candidates
`${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates
.join("|")}> or --workspace <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
@@ -2001,12 +2017,12 @@ async function resolveLaneArg(args) {
/** One line per declared port: name, number, listening, and any base drift. */
function printRuntime(runtime) {
if (!runtime.available) {
console.log("no .agenthub/profile for this lane — nothing to run.");
console.log("no .agenthub/profile for this workspace — nothing to run.");
if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
return;
}
if (!runtime.provisioned) {
console.log("profile found, runtime not provisioned yet — run: agenthub lanes up");
console.log("profile found, runtime not provisioned yet — run: agenthub workspace up");
return;
}
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
@@ -2060,14 +2076,14 @@ async function cmdLanesRuntime(sub, args) {
if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
if (result.status) {
console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`);
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
// The server answers 202 and boots in the background; poll until the stack
// reports healthy or a boot error lands, so the command exits on a real
// outcome rather than on "accepted".
console.log(`lane #${laneId} booting…`);
console.log(`workspace #${laneId} booting…`);
const deadline = Date.now() + 15 * 60 * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
@@ -2094,18 +2110,20 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
if (result.status) {
console.error(`✖ down lane #${laneId} → ${result.data?.error?.message || result.status}`);
console.error(
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
);
process.exitCode = 1;
return;
}
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
console.log(`workspace #${laneId} down (${result.killed?.length || 0} processes stopped)`);
return;
}
if (sub === "logs") {
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
if (!svc) {
console.error("usage: agenthub lanes logs [<id>] <service> [--tail bytes]");
console.error("usage: agenthub workspace logs [<id>] <service> [--tail bytes]");
process.exitCode = 1;
return;
}
@@ -2113,7 +2131,7 @@ async function cmdLanesRuntime(sub, args) {
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) {
console.log("no logs for this lane yet.");
console.log("no logs for this workspace yet.");
return;
}
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
@@ -2124,7 +2142,7 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "hook") {
const name = laneArgs[0];
if (!name || name.startsWith("--")) {
console.error("usage: agenthub lanes hook [<id>] <name> [args…]");
console.error("usage: agenthub workspace hook [<id>] <name> [args…]");
process.exitCode = 1;
return;
}
@@ -2139,7 +2157,7 @@ async function cmdLanesRuntime(sub, args) {
return;
}
console.log(
`lane #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
`workspace #${laneId} running hook ${name} — follow it with: agenthub workspace logs ${laneId} ${name}`
);
}
@@ -2162,7 +2180,7 @@ async function cmdLanesRuntime(sub, args) {
}
if (result.code === 5) {
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
console.error(`✖ workspace #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
for (const c of result.collisions) {
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
}
@@ -2171,11 +2189,11 @@ async function cmdLanesRuntime(sub, args) {
}
if (result.code === 4) {
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
console.error(`✖ workspace #${laneId} — MERGE CONFLICT (left in place).`);
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
` then: agenthub lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
` then: agenthub workspace sync-base --continue ${branch ? branch + " " : ""}${laneId}`
);
process.exitCode = 4;
return;
@@ -2198,12 +2216,12 @@ async function cmdLanesRuntime(sub, args) {
console.log("DEV_OVERLAP: none");
}
}
console.log(`lane #${laneId} preflight vs origin/development: OK`);
console.log(`workspace #${laneId} preflight vs origin/development: OK`);
return;
}
console.log(
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
`workspace #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
);
return;
}
@@ -2378,7 +2396,7 @@ async function cmdFeatureActivate(args) {
title: flag("title"),
});
console.log(
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
`${c.green("✔")} workspace #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
);
}
@@ -2397,25 +2415,6 @@ async function cmdLanesProofLink(args) {
);
}
/** `agenthub lanes agents install [<id>] [--cwd path]` — write the ship-feature-lane
* agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
* git-excluded. Never run automatically; a session calls it explicitly. */
async function cmdLanesAgentsInstall(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const result = await post(
`/api/lanes/${resolved.laneId}/agents/install`,
{},
{ allowError: true }
);
if (result.status) {
console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
}
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
* profiles. Never run automatically; a session calls it explicitly. */
@@ -2440,7 +2439,7 @@ async function cmdLanesMcpSync(args) {
async function cmdLanesIntegration(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: agenthub lanes integration <name> [<id>]");
console.error("usage: agenthub workspace integration <name> [<id>]");
process.exitCode = 1;
return;
}
@@ -2459,13 +2458,13 @@ async function cmdLanesIntegration(args) {
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
async function cmdLanesCi(args) {
if (args[0] !== "status") {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
const ref = args[1];
if (!ref) {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
@@ -2494,8 +2493,7 @@ async function cmdLanesCi(args) {
* was display-only: the dashboard rendered a copy button and the operator
* pasted it into a session by hand. This runs it, in the lane's own cwd:
*
* - a slash command (`/superpowers:brainstorming`) starts `claude` with it
* as the prompt, exactly like `agenthub ship-full` does with the whole skill;
* - a slash command starts `claude` with it as the prompt;
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
*
* With no node id it lists the nodes and their commands, which is also the
@@ -2531,7 +2529,7 @@ async function cmdLanesNode(args) {
const rest = positional.filter((p) => p !== String(resolved.laneId));
const key = rest[0];
const listNodes = () => {
console.log(`lane #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
console.log(`workspace #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
for (const n of nodes) {
const mark = n.state === "current" ? c.cyan("→") : " ";
console.log(
@@ -2542,7 +2540,7 @@ async function cmdLanesNode(args) {
if (!key) {
listNodes();
console.error(`\nusage: agenthub lanes node <node-id> [extra args…] [<id>] [--print]`);
console.error(`\nusage: agenthub workspace node <node-id> [extra args…] [<id>] [--print]`);
process.exitCode = 1;
return;
}
@@ -2578,7 +2576,7 @@ async function cmdLanesNode(args) {
}
console.log(
`${c.dim(`lane #${lane.id} · node ${node.id} (${node.label})`)}\n` +
`${c.dim(`workspace #${lane.id} · node ${node.id} (${node.label})`)}\n` +
`${c.cyan("→")} ${argv.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`
);
const child = spawn(argv[0], argv.slice(1), { stdio: "inherit", cwd: lane.cwd });
@@ -2609,7 +2607,7 @@ async function cmdLanesPipeline(args) {
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
@@ -2617,7 +2615,7 @@ async function cmdLanesPipeline(args) {
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
);
// Switching templates re-resolves the SAME declared stage string against a
@@ -2626,7 +2624,7 @@ async function cmdLanesPipeline(args) {
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`! the workspace's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
)
);
@@ -2658,88 +2656,6 @@ async function cmdLanesShell(args) {
});
}
/**
* Start a `claude` session in this lane driven by a pipeline's own skill.
* Two commands, two depths of work:
*
* agenthub ship-full → the 16-node `ship-feature` template, `/agenthub:ship-feature-lane`
* — autonomous to a published PR, then watched until a
* human merges it.
* agenthub ship-quick → the 8-node `default` template, `/agenthub:ship-feature`
* — in-repo feature work that stops at review/gate.
*
* Each command puts the lane on its own template, so the choice is the command
* you type; the skill string itself lives in the template's `skill` field and
* is never duplicated here.
*
* The prompt is passed as ONE argv element to `claude`, never through a shell —
* a requirement containing quotes, `$`, or `;` is text, not code. `--print`
* shows the exact command, switches nothing, and runs nothing.
*/
const SHIP_TEMPLATES = {
"ship-full": "ship-feature",
"ship-quick": "default",
};
async function cmdShip(command, args) {
const templateId = SHIP_TEMPLATES[command];
const { flags, positional } = parseArgs(args);
const resolved = await resolveLaneArg(args);
if (!resolved) return;
let { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
const skill = pipelines.find((p) => p.id === templateId)?.skill;
if (!skill) {
console.error(
`✖ pipeline template "${templateId}" declares no driving skill — ` +
`add a "skill" field to it (see docs/LANES.md § Custom template format)`
);
process.exitCode = 1;
return;
}
// `resolveLaneArg` only strips a leading bare lane id; the rest of the
// positionals are the requirement, joined back into one prompt line.
const requirement = positional
.filter((p) => p !== String(resolved.laneId))
.join(" ")
.trim();
const prompt = requirement ? `${skill} ${requirement}` : skill;
if (flags.print || !requirement) {
// `--print` shows what would run, so it switches nothing.
if (!requirement) console.error(`usage: agenthub ${command} "<requirement>" [<id>] [--print]`);
console.log(`cd ${lane.cwd} && claude ${JSON.stringify(prompt)}`);
if (!requirement) process.exitCode = 1;
return;
}
// The command picks the pipeline, not the lane's current setting: put the
// lane on the matching template first so the dashboard renders the right
// node count from the start rather than after the skill self-corrects.
if (lane.pipeline !== templateId) {
({ lane } = await patch(`/api/lanes/${lane.id}`, { pipeline: templateId }));
}
console.log(
`${c.dim(`lane #${lane.id} · pipeline ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`)}\n` +
`${c.cyan("→")} claude ${JSON.stringify(prompt)}`
);
const child = spawn("claude", [prompt], { stdio: "inherit", cwd: lane.cwd });
await new Promise((resolve) => {
child.on("close", (code) => {
if (code) process.exitCode = code;
resolve();
});
child.on("error", (err) => {
console.error(c.red(`✖ ${err?.message || err}`));
process.exitCode = 1;
resolve();
});
});
}
async function cmdFeatureShow(args) {
const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) {
@@ -2769,7 +2685,7 @@ async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
console.error(
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
"usage: agenthub stage <stage> [--workspace <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
);
process.exitCode = 1;
return;
@@ -2792,7 +2708,7 @@ async function cmdStage(args) {
note: flag("note"),
result: flag("result"),
});
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
console.log(`workspace #${lane.id} → ${lane.stage} (${lane.progress}%)`);
}
// ── Command catalog ─────────────────────────────────────────────────────────
@@ -2889,114 +2805,106 @@ const COMMAND_GROUPS = [
],
],
[
"Lanes",
"Workspaces",
[
["lanes", "", "List lanes with stage, liveness and progress"],
["lanes add", "--cwd <path> --title <text>", "Create a new lane bound to a directory"],
["workspaces", "", "List workspaces with stage, liveness and progress"],
[
"lanes add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
"Provision a managed worktree lane",
"workspace add",
"--cwd <path> --title <text> [--tool <id>]",
"Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)",
],
[
"lanes profile init",
"workspace add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree workspace",
],
[
"workspace profile init",
"<repo> [--force]",
"Detect a Node.js project and scaffold .agenthub/profile/",
],
[
"lanes profile check",
"workspace profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
"Validate a profile (path defaults to cwd, not a workspace id)",
],
[
"lanes pipeline",
"workspace pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against",
"Show, or switch, which pipeline template a workspace renders against",
],
[
"lanes node",
"workspace node",
"[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)",
"Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)",
],
[
"ship-full",
'"<requirement>" [<id>] [--print]',
"Run the 16-stage lane pipeline: implement → gates → e2e → review → QC → senior gate → PR, watched until a human merges (--print: show the command, run nothing)",
],
[
"ship-quick",
'"<requirement>" [<id>] [--print]',
"Run the 8-stage in-repo pipeline: plan → implement → tests → review → gate → ship. No PR, no browser QC",
],
[
"lanes shell",
"workspace shell",
"[<id>]",
"Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
"Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run",
],
[
"lanes reset|remove|purge",
"workspace reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
"Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
],
[
"lanes up|down",
"workspace up|down",
"[<id>] [--no-build] [--qc]",
"Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
"Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)",
],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
["workspace runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
[
"lanes sync-base",
"workspace logs",
"[<id>] <svc> [--tail N]",
"Tail one of the workspace's hook or service logs",
],
["workspace hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"workspace sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)",
],
[
"lanes proof-link",
"workspace proof-link",
"[<id>]",
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
],
[
"lanes agents install",
"workspace mcp sync",
"[<id>]",
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
"Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles",
],
[
"lanes mcp sync",
"[<id>]",
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
],
[
"lanes integration",
"workspace integration",
"<name> [<id>]",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1",
],
[
"lanes ci status",
"workspace ci status",
"<branch-or-sha> [<id>]",
"One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending",
],
[
"lanes gc",
"workspace gc",
"[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
],
[
"skills install",
"",
"Install skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace",
],
[
"lock status|acquire|release",
"[<name>] [--holder X] [--timeout N]",
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
"Cross-workspace named lock (serialize builds/e2e across all workspaces; holder defaults to the calling workspace)",
],
[
"stage <stage> [flags]",
"",
"Report the current pipeline stage for a lane (--tool claude|codex when one cwd has both)",
"Report the current pipeline stage for a workspace (--tool <id> when one cwd has workspaces for two tools)",
],
[
"feature list",
"[<id>]",
"List every feature this workspace has activated, archived or live",
],
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
[
"feature activate",
"<slug> [--title text] [<id>]",
@@ -3042,6 +2950,9 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background).
Prefer an interactive session? Run ${c.bold("agenthub repl")} for a shell with completion,
@@ -3450,10 +3361,18 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
// so `help lanes` finds the `workspace …` rows and vice versa.
const canon = (words) => {
const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
};
const want = canon(token);
const matches = [];
for (const [group, items] of COMMAND_GROUPS) {
for (const [name, args, desc] of items) {
if (name === token || name.startsWith(token + " ")) matches.push([group, name, args, desc]);
const have = canon(name);
if (have === want || have.startsWith(want + " ")) matches.push([group, name, args, desc]);
}
}
if (!matches.length) return false;
@@ -3704,6 +3623,26 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word.
* `workspace` is the name to use; `lanes` (the handler's own name) and
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
function warnOldNames(argv) {
const old = [];
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
if (old.length) {
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
}
}
/** All top-level command tokens, derived from COMMAND_GROUPS (first word of
* each invocation) plus the always-available `help`. Used by the REPL
* completer and unknown-command detection so they never drift from help. */
@@ -3713,6 +3652,8 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]);
}
set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set];
})();
@@ -3730,7 +3671,8 @@ const SUBCOMMANDS = {
* for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */
async function runCommand(argv) {
const [cmd, ...rest] = argv;
warnOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest);
switch (cmd) {
case "health":
@@ -3800,7 +3742,7 @@ async function runCommand(argv) {
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
console.error(
"usage: agenthub lanes profile init <repo> [--force] | agenthub lanes profile check [<path>]"
"usage: agenthub workspace profile init <repo> [--force] | agenthub workspace profile check [<path>]"
);
process.exitCode = 1;
return;
@@ -3812,9 +3754,6 @@ async function runCommand(argv) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
if (rest[0] === "agents" && rest[1] === "install") {
return cmdLanesAgentsInstall(rest.slice(2));
}
if (rest[0] === "mcp" && rest[1] === "sync") {
return cmdLanesMcpSync(rest.slice(2));
}
@@ -3837,12 +3776,6 @@ async function runCommand(argv) {
return cmdLanesGc(rest.slice(1));
}
return cmdLanes();
case "skills": {
if (rest[0] === "install") return cmdSkillsInstall();
console.error("usage: agenthub skills install");
process.exitCode = 1;
return;
}
case "lock": {
const sub = rest[0];
if (sub === "status") return cmdLockStatus(rest.slice(1));
@@ -3856,9 +3789,6 @@ async function runCommand(argv) {
}
case "stage":
return cmdStage(rest);
case "ship-full":
case "ship-quick":
return cmdShip(cmd, rest);
case "feature": {
const sub = rest[0];
if (sub === "list") return cmdFeatureList(rest.slice(1));
@@ -3896,6 +3826,7 @@ async function runCommand(argv) {
* an embedding loop can stay alive.
*/
async function handleServerDown(argv) {
argv = canonicalArgv(argv);
const cmd = argv[0];
const { flags, positional } = parseArgs(argv.slice(1));
const handler = OFFLINE_HANDLERS[cmd];
+81 -20
View File
@@ -52,7 +52,18 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
- **React Router 6.28** - Client-side routing with nested layouts
- **WebSocket** - Real-time event streaming from server
- **Lucide Icons** - Modern, consistent icon set
@@ -65,11 +76,10 @@ graph TB
Layout[Layout Component]
subgraph "Pages"
Home[Dashboard<br/>Monitor / Analytics / Health tabs]
Home[Dashboard<br/>Monitor / Analytics (incl. Workflow) / Health tabs]
Sessions[Sessions]
Detail[SessionDetail]
Feed[ActivityFeed]
Workflows[Workflows]
Settings[Settings]
end
@@ -96,7 +106,7 @@ graph TB
end
Router --> Layout
Layout --> Home & Sessions & Detail & Feed & Workflows & Settings
Layout --> Home & Sessions & Detail & Feed & Settings
Home & Detail --> AgentCard & StatCard & StatusBadge
Feed --> EventDetail
API --> REST
@@ -174,17 +184,32 @@ client/
│ │ ├── EventDetail.tsx # Inline hook payload viewer (used by ActivityFeed + SessionDetail)
│ │ ├── EmptyState.tsx
│ │ ├── Sidebar.tsx
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), tool filter, search over workspace + session names, row menu
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: repo facts, prompt box (new session in a free pane slot), Recents with cost, Instructions / Memory / Context / Folder
│ │ ├── lanes/SessionResumeButton.tsx # The transcript page's explicit Resume (own-folder Claude sessions; warns, then a free pane) / Open pane
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
│ │ ├── Layout.tsx
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
│ │ ├── settings/ # Settings panels, among them:
│ │ │ ├── SettingsRow.tsx # Row primitives: SettingsSection, SettingsRow, SettingsSwitchRow, SettingsSegmented
│ │ │ ├── AiToolsSettings.tsx # Settings → Agents → AI tools: installed/available CLIs, enable/default, install/update
│ │ │ ├── WorktreeVisibilitySettings.tsx # Settings → Git → Worktrees: which untracked worktrees the sidebar lists
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │
│ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Workflows.tsx
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
│ │ ├── Settings.tsx
│ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
│ │ └── NotFound.tsx
│ │
│ ├── lib/ # Core utilities & business logic
@@ -197,10 +222,11 @@ client/
│ │
│ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
│ │ ├── useLanes.ts # ONE shared lane list (GET /api/lanes + `lane_update` frames, refetch on reconnect) for the sidebar tree, status bar and Workspace page
│ │ ├── useNotifications.ts # Browser push notification triggers; a shown one plays its type's sound (`notifPrefs.sound`/`soundByType`, `playSound` in lib/notify.ts, WAVs in public/sounds/ generated by scripts/gen-notification-sounds.js)
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
│ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── i18n/ # Internationalization (en / vi)
│ ├── App.tsx # Root component + router setup
│ ├── main.tsx # Entry point
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
@@ -228,11 +254,12 @@ graph TB
Sessions[Sessions<br/>filterable table]
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
Settings[Settings<br/>pricing + notifications + hooks]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
Layout --> Dashboard & Sessions & Detail & Feed & Settings
Dashboard --> Workflows
Dashboard --> StatCard[StatCard × 6]
Dashboard --> AgentCard[AgentCard × N]
@@ -275,7 +302,7 @@ sequenceDiagram
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME, Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → Overview), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
### State Strategy
@@ -392,8 +419,8 @@ Server broadcasts these event types over WebSocket:
| `notification.received` | Notification object | Notification hook |
| `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes |
| `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
| `lane_runtime` | `{ laneId, runtime? , error? }` | A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a workspace's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
| `lane_runtime` | `{ laneId, runtime? , error? }` | A workspace's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
| `lane_hook_result` | `{ laneId, hook, code, error? }` | A profile hook exited (`POST /api/lanes/:id/hook/:name`). `code` is `null` when the hook could not be started |
### EventBus Pattern
@@ -445,11 +472,11 @@ graph TB
Sessions["/sessions (tabs: sessions | events)"]
Detail["/sessions/:id"]
Feed["/activity (redirect)"]
Workflows["/workflows"]
Workflows["/workflows (redirect → /?tab=analytics&sub=workflow)"]
CcConfig["/cc-config (redirect)"]
Lanes["/lanes (redirect)"]
Run["/run (redirect)"]
Workspace["/workspace"]
Workspace["/workspace?lane=<id> (home) · &run=<runId> (session)"]
Settings["/settings (tabs: dashboard | claude-code | ...)"]
NF["/* (NotFound)"]
@@ -476,14 +503,14 @@ function App() {
<Route path="sessions/:id" element={<SessionDetail />} />
{/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Workflows />} />
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
{/* legacy path - Claude config now lives in a Settings tab */}
<Route path="cc-config" element={<Navigate to="/settings?tool=claude-code" replace />} />
<Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} />
{/* legacy paths - lanes and runs are one page now */}
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
<Route path="run" element={<Navigate to="/workspace" replace />} />
<Route path="workspace" element={<Workspace />} />
<Route path="settings" element={<SettingsHub />} />
<Route path="settings/*" element={<SettingsHub />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
@@ -492,6 +519,8 @@ function App() {
}
```
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
### Navigation Flow
```mermaid
@@ -601,6 +630,8 @@ graph TB
style Success fill:#10B981
```
A thrown error carries the server's `error.code` as `code` and the HTTP `status` (plus `runId` from a `409 ESESSIONLIVE` start, `auth` from `409 EGITAUTH`). A `200` that is not JSON — the SPA's `index.html` from a proxy or a dev server that missed the route — throws `code: "EBADRESPONSE"` with the translated `errors:badResponse` message instead of `Unexpected token '<'`. Pages surface a failure through `toast` (`lib/toast.ts`, rendered by `components/Toaster.tsx`; an error toast stays until dismissed) rather than `alert()`, and ask for confirmation through `confirmDialog`/`confirmDiscard` (`lib/confirm.ts`, rendered by `components/ConfirmHost.tsx`) rather than `window.confirm`.
---
## UI Components
@@ -726,6 +757,36 @@ flowchart LR
Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button.
#### Tooltips (`components/Tooltip.tsx`)
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
app the same designed tooltip — there is no wrapper to add at the call site.
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
focus (immediately) it takes the element's native `title`, shows it as an
anchored bubble with an arrow, and puts the attribute back when the bubble
closes. Consequences worth knowing:
- **Write `title="…"` exactly as before.** That is the whole API. A control
keeps its native tooltip, and its accessible name, when JS has not run.
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
in light mode; it flips above/below, clamps to the viewport, and closes on
scroll, resize, pointer-down or `Escape`.
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
expanding a truncated **number** inside a line of text, but draws the same
surface so the two read as one thing.
#### MarkdownPreview (`components/MarkdownPreview.tsx`)
A `.md`/`.markdown`/`.mdx` file (`isMarkdown`) rendered for reading with
`react-markdown` + `remark-gfm` (tables, task lists, strikethrough). It sits
behind a **Preview** / **Raw** toggle in two places: the config explorer's
`FileViewer` and the Changes panel's whole-file view (`lane-file-preview`,
reset to Raw on every file switch; the editor still opens from Raw). Raw HTML
in the file is shown as text, never run (no `rehype-raw`), links open in a new
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
the `.md-preview` rules in `index.css`, built from theme tokens.
---
## Utilities
+28 -10
View File
@@ -1,15 +1,33 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<!-- Apply the saved appearance before first paint (same keys as
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
<script>
try {
var m = localStorage.getItem("theme");
var d =
m === "light"
? false
: m === "system"
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
: true;
var r = document.documentElement;
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
r.classList.toggle("dark", d);
r.setAttribute("data-mode", d ? "dark" : "light");
if (t && t !== "default") r.setAttribute("data-theme", t);
} catch (e) {}
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#6366f1" />
<title>Agent Dashboard - SmartGift Solution</title>
<title>AgentHub - SmartGift Solution</title>
<meta
name="description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
@@ -23,8 +41,8 @@
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Agent Dashboard" />
<meta property="og:title" content="Agent Dashboard - SmartGift Solution" />
<meta property="og:site_name" content="AgentHub" />
<meta property="og:title" content="AgentHub - SmartGift Solution" />
<meta
property="og:description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
@@ -33,17 +51,17 @@
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Agent Dashboard - SmartGift Solution" />
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
<meta
name="twitter:description"
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
/>
<meta name="twitter:image" content="/og-image.svg" />
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta name="twitter:creator" content="@smartgift" />
<!-- JSON-LD structured data -->
@@ -51,7 +69,7 @@
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Agent Dashboard",
"name": "AgentHub",
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
@@ -66,7 +84,7 @@
}
</script>
</head>
<body class="bg-surface-0 text-gray-200 antialiased">
<body class="bg-surface-0 text-fg-primary antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+1523 -8
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -14,7 +14,10 @@
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"d3": "^7.9.0",
"i18next": "^26.0.8",
@@ -23,7 +26,9 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^6.28.2"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+3
View File
@@ -0,0 +1,3 @@
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(0,249.335,-249.128,0,125,.332)"><stop stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs><style>.a{fill:url(#g1)}</style><path class="a" d="m84.3 5.1q3.7-1.5 7.7-2.6 3.9-1 7.9-1.6 4-0.5 8.1-0.6 4 0 8 0.5 20.7 2.4 37.1 17.7 0.1 0.1 0.4 0.3 0.1 0 0.2 0 0 0 0.2 0 0 0 0.1 0 0 0 0.1 0 5.2-1.4 10.7-1.9 5.4-0.4 10.7 0.1 5.5 0.4 10.7 1.9 5.2 1.3 10.1 3.6l0.6 0.4 1.6 0.8q5.2 2.5 9.7 6.1 4.7 3.4 8.6 7.7 3.8 4.3 6.9 9.2 3 4.8 5.2 10.2 4.3 10.5 4.3 22.1 0.2 2.1 0 4.2-0.1 2.2-0.2 4.3-0.3 2.1-0.7 4.3-0.4 2.1-0.9 4.1 0 0.2 0 0.4 0 0.2 0 0.5 0 0.1 0.1 0.4 0.1 0.1 0.3 0.3 12.3 12.6 16.3 30 6 29.7-12.2 53.5l-1.9 2.2q-3 3.5-6.5 6.4-3.4 3.1-7.3 5.5-3.8 2.4-8.1 4.2-4.1 1.9-8.5 3.2-0.3 0-0.4 0.2-0.3 0-0.4 0.1-0.1 0.1-0.3 0.4 0 0.1-0.1 0.3c-2.7 7.7-5.3 14.2-10.2 20.7-12.5 16.5-30.8 25.5-51.5 25.5q-24.6-0.1-43.6-18.1-0.2-0.1-0.4-0.2-0.2-0.1-0.4-0.1-0.2 0-0.3 0-0.3 0-0.4 0c-5.4 1.7-10.9 1.9-16.7 1.9q-3.5 0-7-0.5-3.4-0.4-6.9-1.2-3.3-0.8-6.6-2-3.3-1.2-6.4-2.8-3.3-1.6-6.4-3.6-3-2-5.8-4.3-3-2.3-5.5-5-2.5-2.6-4.6-5.6c-2.2-2.7-4.3-5.4-5.8-8.5q-0.8-1.6-1.6-3.2-0.6-1.7-1.3-3.3-0.7-1.7-1.2-3.4-0.5-1.6-1-3.4-1.1-4-1.6-7.9-0.6-4-0.6-8 0-4 0.6-8 0.4-4 1.4-8 0 0 0-0.1 0-0.1 0-0.1 0.2-0.2 0.2-0.3 0-0.1-0.2-0.1 0-0.2 0-0.3 0-0.1-0.1-0.1 0-0.2 0-0.2-0.1-0.1-0.1-0.1-2.4-2.5-4.6-5.2-2.1-2.7-4-5.4-1.7-3-3.2-6-1.5-3.1-2.6-6.3-0.8-2-1.3-4.1-0.7-2-1.1-4-0.4-2.1-0.7-4.2-0.2-2.2-0.4-4.3-0.2-2.8-0.1-5.6 0-2.8 0.3-5.4 0.1-2.8 0.6-5.6 0.4-2.8 1.1-5.5 7-23.1 26.9-36.3 4.3-2.9 8.2-4.5 4.5-1.9 9-3.2 0.2 0 0.3-0.1 0.1-0.2 0.3-0.3 0.1 0 0.1-0.3 0.1-0.1 0.1-0.2 1-3.1 2.2-6 1-2.9 2.5-5.7 1.5-3 3.2-5.6 1.7-2.7 3.7-5.1 2.5-3.2 5.3-5.9 3-2.8 6.1-5.4 3.2-2.4 6.8-4.4 3.5-2 7.2-3.5zm48.3 146.4c-2.3 0.1-4.4 1-6 2.8-1.5 1.6-2.4 3.7-2.4 5.9 0 2.3 0.9 4.4 2.4 6.2 1.6 1.6 3.7 2.5 6 2.6h50.4c2.4 0.1 4.8-0.6 6.5-2.4 1.7-1.6 2.8-4 2.8-6.4 0-2.4-1.1-4.7-2.8-6.3-1.7-1.8-4.1-2.6-6.5-2.4zm-56.7-64.9c-1.2-1.9-3-3.4-5.3-3.9-2.2-0.5-4.5-0.3-6.5 0.9-2 1.1-3.5 3-4.1 5.2-0.7 2.2-0.4 4.6 0.6 6.5l17.7 30.9-17.5 29.5c-1.2 2-1.6 4.5-1.1 6.8 0.7 2.3 2.1 4.1 4.1 5.3 2 1.2 4.4 1.6 6.7 0.9 2.2-0.5 4.2-1.9 5.4-3.9l20.1-34.1q0.7-0.9 0.9-2.1 0.3-1.1 0.3-2.3 0-1.2-0.3-2.2-0.2-1.2-0.8-2.2z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 250 250" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
<path d="M0,4.391C-0,1.982 1.982,0 4.391,0C4.391,0 19.609,-0 19.609,-0C22.018,-0 24,1.982 24,4.391C24,4.391 24,19.609 24,19.609C24,22.018 22.018,24 19.609,24C19.609,24 4.391,24 4.391,24C1.982,24 -0,22.018 -0,19.609C-0,19.609 0,4.391 0,4.391Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
<path d="M19.74,1.444C21.285,1.444 22.556,2.715 22.556,4.26L22.556,19.74C22.556,21.285 21.285,22.556 19.74,22.556L4.26,22.556C2.715,22.556 1.444,21.285 1.444,19.74L1.444,4.26C1.444,2.715 2.715,1.444 4.26,1.444L19.74,1.444ZM7.236,8.564L14.988,12.292L7.236,16.019L7.236,18.821L16.793,14.225L16.793,10.359L7.236,5.763L7.236,8.564Z" style="fill:rgb(30,30,46);"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-24,9.907,-9.907,-24,24,6.587)"><stop offset="0" style="stop-color:rgb(238,77,93);stop-opacity:1"/><stop offset="0.33" style="stop-color:rgb(179,129,221);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(32,124,254);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(32,124,254);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(288.354,233.302,-248.835,307.552,-6.865,4.771)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g2" x2="1" href="#P" gradientTransform="matrix(44.906,54.969,-62.556,51.105,0,100.75)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g3" x2="1" href="#P" gradientTransform="matrix(52.229,46.969,-41.209,45.824,201.927,103.677)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient></defs><style>.a{fill:url(#g1)}.b{fill:url(#g2)}.c{fill:url(#g3)}.d{fill:#ff4d4d}.e{fill:#050810}.f{fill:#00e5cc}</style><path class="a" d="m125 26.8c-65.9-0.1-98.9 54.9-98.9 98.9 0 43.9 33 87.9 65.9 98.9v21.9h22v-21.9c0 0 11 4.3 22 0v21.9h22v-21.9c32.9-11 65.9-55 65.9-98.9 0-44-33-99-98.9-99z"/><path class="b" d="m37.1 103.7c-33-11-44 11-33 32.9 11 22 33 11 44-10.9 6.6-15.4 0-22-11-22z"/><path class="c" d="m212.9 103.7c33-11 44 11 33 32.9-11 22-33 11-44-10.9-6.6-15.4 0-22 11-22z"/><path class="d" d="m57.4 19.5c4.9-2.9 10.8-2.4 16.8 0.4 6 2.8 12.7 8.1 20.2 15.5 0.6 0.6 0.9 1.5 0.9 2.3 0 0.9-0.3 1.8-0.9 2.4-0.7 0.6-1.5 0.9-2.4 0.9-0.8 0-1.7-0.3-2.3-0.9-7.2-7.2-13.3-11.9-18.3-14.2-4.9-2.3-8.3-2.1-10.6-0.7-0.8 0.4-1.6 0.4-2.4 0.2-0.9-0.2-1.5-0.7-2-1.5-0.4-0.7-0.5-1.5-0.4-2.3 0.2-0.9 0.7-1.6 1.4-2.1zm118.4 0.4c6-2.8 11.9-3.3 16.8-0.4 0.7 0.5 1.2 1.2 1.4 2.1 0.1 0.8 0 1.6-0.4 2.3-0.5 0.8-1.1 1.3-2 1.5-0.8 0.2-1.6 0.2-2.4-0.2-2.3-1.4-5.7-1.6-10.6 0.7-5 2.3-11.1 7-18.3 14.2-0.6 0.6-1.5 0.9-2.3 0.9-0.9 0-1.7-0.3-2.4-0.9-0.6-0.6-0.9-1.5-0.9-2.4 0-0.8 0.3-1.7 0.9-2.3 7.5-7.4 14.2-12.7 20.2-15.5z"/><path class="e" d="m92 94.9c3.5 0 6.9-1.4 9.4-3.9 2.4-2.5 3.8-5.8 3.8-9.3 0-3.5-1.4-6.9-3.8-9.3-2.5-2.5-5.9-3.9-9.4-3.9-3.5 0-6.8 1.4-9.3 3.9-2.5 2.4-3.9 5.8-3.9 9.3 0 3.5 1.4 6.8 3.9 9.3 2.5 2.5 5.8 3.9 9.3 3.9zm66 0c3.5 0 6.8-1.4 9.3-3.9 2.5-2.5 3.9-5.8 3.9-9.3 0-3.5-1.4-6.9-3.9-9.3-2.5-2.5-5.8-3.9-9.3-3.9-3.5 0-6.9 1.4-9.4 3.9-2.4 2.4-3.8 5.8-3.8 9.3 0 3.5 1.4 6.8 3.8 9.3 2.5 2.5 5.9 3.9 9.4 3.9z"/><path class="f" d="m94.2 85c1.5 0 2.9-0.6 3.9-1.6 1-1 1.6-2.4 1.6-3.9 0-1.5-0.6-2.9-1.6-3.9-1-1-2.4-1.6-3.9-1.6-1.4 0-2.8 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.8 1.6 3.9 1.1 1 2.5 1.6 3.9 1.6zm66 0c1.4 0 2.8-0.6 3.8-1.6 1.1-1 1.7-2.4 1.7-3.9 0-1.5-0.6-2.8-1.7-3.9-1-1-2.4-1.6-3.8-1.6-1.5 0-2.9 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.9 1.6 3.9 1 1 2.4 1.6 3.9 1.6z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+6 -6
View File
@@ -13,15 +13,15 @@
"categories": ["developer-tools", "productivity"],
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/favicon.ico",
"sizes": "32x32",
"type": "image/x-icon",
"purpose": "any"
},
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/favicon.ico",
"sizes": "32x32",
"type": "image/x-icon",
"purpose": "maskable"
}
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -1
View File
@@ -87,13 +87,19 @@ self.addEventListener("push", (event) => {
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url;
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
for (const client of windowClients) {
if (client.focus) {
if ("focus" in client) {
// Reuse an already-open tab instead of stacking a new one — that's
// also why `navigate` (same-origin only) beats postMessage: it works
// even if the page hasn't wired up a listener for this.
if (url && "navigate" in client) client.navigate(url).catch(() => {});
return client.focus();
}
}
if (url && clients.openWindow) return clients.openWindow(url);
})
);
});
+89 -15
View File
@@ -40,10 +40,9 @@
* - `./components/SplashScreen`
* - `./pages/Dashboard`
* - `./pages/History`
* - `./pages/SessionDetail`
* - `./pages/Workflows`
* - `./pages/SettingsHub`
* - `./pages/Workspace`
* - `./pages/SessionDetail` (lazy)
* - `./pages/SettingsHub` (lazy)
* - `./pages/Workspace` (lazy)
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
@@ -55,23 +54,58 @@
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useCallback } from "react";
import { BrowserRouter, Routes, Route, Navigate, useParams, useLocation } from "react-router-dom";
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Layout } from "./components/Layout";
import { DocumentTitle } from "./components/DocumentTitle";
import { SplashScreen } from "./components/SplashScreen";
import { GlobalTooltip } from "./components/Tooltip";
import { Toaster } from "./components/Toaster";
import { ConfirmHost } from "./components/ConfirmHost";
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
import { Dashboard } from "./pages/Dashboard";
import { History } from "./pages/History";
import { SessionDetail } from "./pages/SessionDetail";
import { Workflows } from "./pages/Workflows";
import { SettingsHub } from "./pages/SettingsHub";
import { Workspace } from "./pages/Workspace";
import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus";
import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
// visit instead of in the initial bundle.
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
const SettingsHub = lazy(() =>
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
);
const SessionDetail = lazy(() =>
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
);
function PageFallback() {
const { t } = useTranslation();
return (
<div role="status" className="flex items-center justify-center py-20 text-sm text-fg-muted">
{t("common:loading")}
</div>
);
}
function Page({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
return (
<RouteErrorBoundary resetKey={pathname}>
<Suspense fallback={<PageFallback />}>{children}</Suspense>
</RouteErrorBoundary>
);
}
/** Keyed by the id so moving to another session starts from fresh state. */
function SessionDetailRoute() {
const { id } = useParams();
return <SessionDetail key={id} />;
}
/**
* Application root component mounted by {@link main.tsx}.
* @returns Routed dashboard UI inside `BrowserRouter`.
@@ -84,26 +118,66 @@ export default function App() {
const { connected } = useWebSocket(onMessage);
useNotifications();
// A self-update replaced the hashed assets this tab still references: Vite
// reports the failed preload here; reload once (guarded against loops).
useEffect(() => {
const onPreloadError = (e: Event) => {
if (reloadOnce()) e.preventDefault();
};
window.addEventListener("vite:preloadError", onPreloadError);
return () => window.removeEventListener("vite:preloadError", onPreloadError);
}, []);
return (
<>
<SplashScreen />
{/* One renderer gives every `title` in the app the designed bubble —
see components/Tooltip.tsx for why it is not a wrapper component. */}
<GlobalTooltip />
{/* App-wide toasts and promise-based confirms: lib/toast.ts, lib/confirm.ts. */}
<Toaster />
<ConfirmHost />
<BrowserRouter>
<DocumentTitle />
<Routes>
<Route element={<Layout wsConnected={connected} />}>
<Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} />
<Route
path="sessions/:id"
element={
<Page>
<SessionDetailRoute />
</Page>
}
/>
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Workflows />} />
<Route
path="workflows"
element={<Navigate to="/?tab=analytics&sub=workflow" replace />}
/>
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
<Route
path="cc-config"
element={<Navigate to="/settings?tool=claude-code" replace />}
element={<Navigate to="/settings/agents/claude-code" replace />}
/>
<Route path="run" element={<Navigate to="/workspace" replace />} />
<Route path="workspace" element={<Workspace />} />
<Route path="settings" element={<SettingsHub />} />
<Route
path="workspace"
element={
<Page>
<Workspace />
</Page>
}
/>
<Route
path="settings/*"
element={
<Page>
<SettingsHub />
</Page>
}
/>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
+61 -31
View File
@@ -1,6 +1,6 @@
/**
* @file AgentCard.tsx
* @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked. It also visually distinguishes active agents with a border highlight.
* @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked; a right-click opens `SessionRowMenu`. It also visually distinguishes active agents with a border highlight.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -49,8 +49,11 @@
import { useTranslation } from "react-i18next";
import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { memo, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AgentStatusBadge } from "./StatusBadge";
import { useContextMenu } from "./ContextMenu";
import { SessionRowMenu } from "./SessionRowMenu";
import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types";
import type { Agent, Session } from "../lib/types";
import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format";
@@ -80,9 +83,17 @@ interface AgentCardProps {
onClick?: () => void;
}
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
/** Memoized: the Dashboard re-renders on every live update, and a card only
* changes with its own agent/session. */
export const AgentCard = memo(function AgentCard({
agent,
session,
label,
onClick,
}: AgentCardProps) {
const navigate = useNavigate();
const { t } = useTranslation("kanban");
const ctx = useContextMenu<true>();
const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent);
const status = effectiveAgentStatus(agent);
const isActive = agent.status === "working";
@@ -116,15 +127,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
// not the parent session's — see issue #185). Use it everywhere this card
// shows a model so a Haiku QA agent under an Opus orchestrator reads as
// Haiku, not Opus. Falls back to the session model only for the main agent.
let subagentModel: string | null = null;
if (!isMain && agent.metadata) {
const subagentModel = useMemo(() => {
if (isMain || !agent.metadata) return null;
try {
const parsed = JSON.parse(agent.metadata) as { model?: string };
subagentModel = parsed?.model ? formatModelName(parsed.model) : null;
return parsed?.model ? formatModelName(parsed.model) : null;
} catch {
subagentModel = null;
return null;
}
}
}, [isMain, agent.metadata]);
// The model badge (footer) must reflect THIS card's agent: the session model
// for main, the subagent's own model for subagents.
const displayModel = isMain ? model : subagentModel;
@@ -140,15 +151,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
// "29 agents" looks like it should equal a 29-subagent stat when the session
// actually has 28 subagents + 1 main.
const subagentCount = Math.max(0, agentCount - 1);
let sessionTurns = 0;
if (isMain && session?.metadata) {
const sessionTurns = useMemo(() => {
if (!isMain || !session?.metadata) return 0;
try {
const m = JSON.parse(session.metadata) as { turn_count?: number };
if (typeof m?.turn_count === "number") sessionTurns = m.turn_count;
return typeof m?.turn_count === "number" ? m.turn_count : 0;
} catch {
sessionTurns = 0;
return 0;
}
}
}, [isMain, session?.metadata]);
const subtitle = isMain
? [
cwdBase,
@@ -159,22 +170,27 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
.join(" · ") || null
: [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null;
function handleClick() {
if (onClick) {
onClick();
} else {
navigate(`/sessions/${agent.session_id}`);
}
const sessionHref = `/sessions/${agent.session_id}`;
// The whole card is clickable with the mouse; the title is the focusable
// control (a real link, or a button when the caller handles the click).
function handleClick(e: React.MouseEvent) {
if ((e.target as HTMLElement).closest("a,button")) return;
if (onClick) onClick();
else navigate(sessionHref);
}
const titleText = isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name;
const titleClass =
"block text-sm font-medium text-fg-secondary truncate text-left max-w-full rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
return (
<div
onClick={handleClick}
onContextMenu={(e) => ctx.open(e, true)}
className={`card-hover p-4 cursor-pointer overflow-hidden ${
isWaiting
? "border-l-2 border-l-yellow-500/60"
? "border-l-2 border-l-status-warning/60"
: isActive
? "border-l-2 border-l-emerald-500/50"
? "border-l-2 border-l-status-success/50"
: ""
}`}
>
@@ -182,19 +198,25 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<div
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
isMain ? "bg-accent/15 text-accent" : "bg-syntax-keyword/15 text-syntax-keyword"
}`}
>
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
</div>
<div className="min-w-0 overflow-hidden">
<p className="text-sm font-medium text-fg-secondary truncate">
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
229d93fd" or "Main Agent - work - e3f8e613") swap the
placeholder for the real session name when one exists; custom
(sub)agent names are left untouched. */}
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
</p>
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
229d93fd" or "Main Agent - work - e3f8e613") swap the
placeholder for the real session name when one exists; custom
(sub)agent names are left untouched. */}
{onClick ? (
<button type="button" onClick={onClick} className={titleClass}>
{titleText}
</button>
) : (
<Link to={sessionHref} className={titleClass}>
{titleText}
</Link>
)}
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
</div>
</div>
@@ -245,11 +267,19 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
{timeAgo(agent.updated_at || agent.started_at)}
</span>
)}
<span className="ml-auto flex items-center gap-1 min-w-0 opacity-50">
<span className="ml-auto flex items-center gap-1 min-w-0">
{realSessionName && <span className="truncate max-w-[10rem]">{realSessionName} ·</span>}
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
</span>
</div>
{ctx.menu && (
<SessionRowMenu
session={{ id: agent.session_id, lane_id: session?.lane_id, cwd: session?.cwd }}
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
/>
)}
</div>
);
}
});
+56
View File
@@ -0,0 +1,56 @@
/**
* @file AiToolIcon.tsx
* @description An AI tool's icon: the mark shipped in `client/public/ai-tools/`
* when there is one, else
* the server's cached favicon (GET /api/ai-tools/:id/icon), else the label's
* first letter. Every tool icon in the app renders through here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { api, type AiTool } from "../lib/api";
/** Shipped marks by tool id. */
const BUNDLED: Record<string, string> = {
claude: "/ai-tools/claude.svg",
codex: "/ai-tools/codex.svg",
gemini: "/ai-tools/gemini.svg",
openclaw: "/ai-tools/openclaw.svg",
hermes: "/ai-tools/hermes.svg",
};
/**
* @param props.tool - The catalog entry.
* @param props.className - Size classes (default `h-8 w-8`).
*/
export function AiToolIcon({
tool,
className = "h-8 w-8",
}: {
tool: Pick<AiTool, "id" | "label">;
className?: string;
}) {
// Keyed by id: one instance showing another tool must retry its icon.
const [failedId, setFailedId] = useState<string | null>(null);
const box = `${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`;
const bundled = BUNDLED[tool.id];
if (bundled) return <img src={bundled} alt="" className={box} />;
if (failedId !== tool.id) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailedId(tool.id)}
className={box}
/>
);
}
return (
<span
aria-hidden
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
>
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
+118 -29
View File
@@ -8,6 +8,7 @@
* • Activity - the live fired-alert feed with acknowledge controls
* Tab badges reflect live state (rule count, unacked alert count), and the feed
* + counts refetch on alert_triggered / alert_updated WebSocket messages.
* A right-click on a rule opens its Enable/Disable and Delete as a menu.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -69,8 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
ListChecks,
Pencil,
Plus,
RefreshCw,
Trash2,
@@ -85,7 +86,9 @@ import { WebhookSettings } from "./WebhookSettings";
import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -153,6 +156,27 @@ const EMPTY_FORM: RuleFormState = {
cooldown_seconds: "300",
};
/** A rule's stored values as form fields, for editing it. */
function formFromRule(rule: AlertRule): RuleFormState {
const c = rule.config;
const str = (v: unknown, fallback: string) => (v == null ? fallback : String(v));
return {
name: rule.name,
rule_type: rule.rule_type,
event_type: str(c.event_type, ""),
tool_name: str(c.tool_name, ""),
summary_contains: str(c.summary_contains, ""),
count: str(c.count, EMPTY_FORM.count),
window_minutes: str(c.window_minutes, EMPTY_FORM.window_minutes),
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
cooldown_seconds: String(rule.cooldown_seconds),
};
}
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -215,9 +239,18 @@ export function AlertsNotifications() {
const [loadingRules, setLoadingRules] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
// The rule being edited, or null while the form creates a new one.
const [editingId, setEditingId] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
const ruleCtx = useContextMenu<AlertRule>();
// A rule-menu item: close the menu, then act on the rule it was opened on.
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
const rule = ruleCtx.menu?.item;
ruleCtx.close();
if (rule) fn(rule);
};
// Feed
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
@@ -232,11 +265,11 @@ export function AlertsNotifications() {
const res = await api.alerts.rules.list();
setRules(res.rules);
} catch (err) {
console.error("Failed to load alert rules:", err);
toast.error(t("errors.loadRules", { error: errText(err) }));
} finally {
setLoadingRules(false);
}
}, []);
}, [t]);
const loadAlerts = useCallback(async () => {
setLoadingAlerts(true);
@@ -250,11 +283,11 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
} finally {
setLoadingAlerts(false);
}
}, [unackedOnly]);
}, [unackedOnly, t]);
const loadMore = useCallback(async () => {
try {
@@ -267,9 +300,9 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load more alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
}
}, [unackedOnly, alerts.length]);
}, [unackedOnly, alerts.length, t]);
useEffect(() => {
loadRules();
@@ -291,20 +324,33 @@ export function AlertsNotifications() {
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
const onCreateRule = async () => {
if (saving) return;
const closeForm = () => {
setForm(EMPTY_FORM);
setEditingId(null);
setFormOpen(false);
setFormError(null);
};
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
};
const onSaveRule = async () => {
if (saving || !canSubmit) return;
setSaving(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
await api.alerts.rules.create({
const body = {
name: form.name.trim(),
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
});
setForm(EMPTY_FORM);
setFormOpen(false);
cooldown_seconds: cooldownVal,
};
if (editingId) await api.alerts.rules.update(editingId, body);
else await api.alerts.rules.create({ ...body, rule_type: form.rule_type });
closeForm();
loadRules();
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
@@ -318,7 +364,7 @@ export function AlertsNotifications() {
await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
loadRules();
} catch (err) {
console.error("Failed to toggle alert rule:", err);
toast.error(t("errors.toggleRule", { error: errText(err) }));
}
};
@@ -329,7 +375,8 @@ export function AlertsNotifications() {
loadRules();
loadAlerts();
} catch (err) {
console.error("Failed to delete alert rule:", err);
setConfirmRule(null);
toast.error(t("errors.deleteRule", { error: errText(err) }));
}
};
@@ -338,7 +385,7 @@ export function AlertsNotifications() {
await api.alerts.ack(id);
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alert:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -347,7 +394,7 @@ export function AlertsNotifications() {
await api.alerts.ackAll();
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alerts:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -357,8 +404,12 @@ export function AlertsNotifications() {
const tokensVal = parseInt(form.total_tokens, 10);
const countVal = parseInt(form.count, 10);
const windowVal = parseFloat(form.window_minutes);
// Cooldown is a whole number of seconds ≥ 0 — never silently replaced by 300.
const cooldownOk = /^\d+$/.test(form.cooldown_seconds.trim());
const cooldownVal = cooldownOk ? parseInt(form.cooldown_seconds, 10) : 0;
const canSubmit =
form.name.trim().length > 0 &&
cooldownOk &&
(form.rule_type !== "event_pattern" ||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
Number.isFinite(countVal) &&
@@ -431,8 +482,11 @@ export function AlertsNotifications() {
</div>
<button
onClick={() => {
setFormOpen((open) => !open);
setFormError(null);
if (formOpen) closeForm();
else {
setFormOpen(true);
setFormError(null);
}
}}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
>
@@ -466,7 +520,9 @@ export function AlertsNotifications() {
<select
value={form.rule_type}
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
className="input w-full appearance-none pr-8"
disabled={!!editingId}
title={editingId ? t("rules.typeFixed") : undefined}
className="select w-full"
>
{RULE_TYPES.map((type) => (
<option key={type} value={type}>
@@ -474,7 +530,6 @@ export function AlertsNotifications() {
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
@@ -577,12 +632,11 @@ export function AlertsNotifications() {
<select
value={form.status}
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
className="input w-full appearance-none pr-8"
className="select w-full"
>
<option value="working">working</option>
<option value="waiting">waiting</option>
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
)}
@@ -635,18 +689,25 @@ export function AlertsNotifications() {
<input
type="number"
min={0}
step={1}
value={form.cooldown_seconds}
onChange={(e) => set({ cooldown_seconds: e.target.value })}
aria-invalid={!cooldownOk}
className="input w-40"
/>
{!cooldownOk && (
<span className="mt-1 block text-[11px] text-status-danger">
{t("rules.cooldownInvalid")}
</span>
)}
</label>
<button
onClick={onCreateRule}
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
{saving ? t("rules.saving") : t("rules.create")}
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
@@ -663,12 +724,14 @@ export function AlertsNotifications() {
icon={ListChecks}
title={t("rules.empty")}
description={ts("alertsHub.rulesEmptyHint")}
compact
/>
) : (
<ul className="space-y-2">
{rules.map((rule) => (
<li
key={rule.id}
onContextMenu={(e) => ruleCtx.open(e, rule)}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-2 px-3 py-2.5"
>
<div className="min-w-0">
@@ -699,6 +762,14 @@ export function AlertsNotifications() {
>
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
</button>
<button
onClick={() => openEditRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors"
title={t("rules.edit")}
aria-label={t("rules.edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
@@ -712,6 +783,23 @@ export function AlertsNotifications() {
))}
</ul>
)}
{ruleCtx.menu && (
<ContextMenu
x={ruleCtx.menu.x}
y={ruleCtx.menu.y}
onClose={ruleCtx.close}
testId="alert-rule-menu"
>
<MenuItem onSelect={pickRule(onToggleRule)}>
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
</MenuItem>
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
{t("rules.delete")}
</MenuItem>
</ContextMenu>
)}
</div>
)}
@@ -766,6 +854,7 @@ export function AlertsNotifications() {
icon={unackedOnly ? BellOff : BellRing}
title={t("feed.emptyTitle")}
description={unackedOnly ? t("feed.emptyUnacked") : t("feed.emptyDescription")}
compact
/>
) : (
<>
+33 -8
View File
@@ -74,29 +74,54 @@ export interface CheckboxProps {
className?: string;
/** Classes applied to the label `<span>` when `label` is set. */
labelClassName?: string;
/** `danger` / `warning` for an opt-in that makes an action riskier
* (bypass permissions, purge data, force). */
tone?: "accent" | "danger" | "warning";
disabled?: boolean;
/** Tooltip (the app turns every `title` into its themed bubble). */
title?: string;
}
// Fill + tick per tone. Accent fills with `accent-solid` under a white tick.
// The status colours are TEXT shades — light in dark mode, dark in light mode —
// so their tick takes the page colour (surface-0), which contrasts both ways.
const TONE = {
accent: "bg-accent-solid border-accent-solid text-white",
danger: "bg-status-danger border-status-danger text-surface-0",
warning: "bg-status-warning border-status-warning text-surface-0",
} as const;
/**
* Themed checkbox control.
* Themed checkbox control — the app's ONE checkbox; never a native
* `<input type="checkbox">`, which renders as the browser's white box.
* @param props See {@link CheckboxProps}.
*/
export function Checkbox({ checked, onChange, label, className, labelClassName }: CheckboxProps) {
export function Checkbox({
checked,
onChange,
label,
className,
labelClassName,
tone = "accent",
disabled = false,
title,
}: CheckboxProps) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
title={title}
onClick={() => onChange(!checked)}
className={`group inline-flex items-center gap-2 text-left ${className ?? ""}`}
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
>
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
checked
? "bg-accent border-accent"
: "bg-surface-2 border-border group-hover:border-border-light"
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/60 ${
checked ? TONE[tone] : "bg-surface-2 border-fg-muted group-hover:border-fg-secondary"
}`}
>
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
{checked && <Check className="h-3 w-3" strokeWidth={3} aria-hidden />}
</span>
{label != null && (
<span
+33
View File
@@ -0,0 +1,33 @@
/**
* @file ConfirmHost.tsx
* @description Renders the one pending `confirmDialog()` from `lib/confirm.ts`
* through `ConfirmModal`. Mounted once in `App.tsx`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "./ConfirmModal";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../lib/confirm";
const cancel = () => settleConfirm(false);
const confirm = () => settleConfirm(true);
/** The single host for `confirmDialog()`. */
export function ConfirmHost() {
const { t } = useTranslation("common");
const [p, setP] = useState<PendingConfirm | null>(null);
useEffect(() => subscribeConfirm(setP), []);
return (
<ConfirmModal
open={p !== null}
title={p?.title ?? ""}
message={p?.message}
confirmLabel={p?.confirmLabel ?? t("confirm")}
cancelLabel={p?.cancelLabel ?? t("cancel")}
destructive={p?.destructive ?? true}
onConfirm={confirm}
onCancel={cancel}
/>
);
}
+9 -48
View File
@@ -11,12 +11,14 @@
*
* ## Accessibility
* Focus moves to Cancel on open (safer default), Tab cycles within the dialog,
* Escape cancels, and focus restores to the previously focused element on close.
* Escape cancels, and focus restores to the previously focused element on close
* (the shared `hooks/useDialog.ts` contract).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useRef, type ReactNode } from "react";
import { useId, useRef, type ReactNode } from "react";
import { useDialog } from "../hooks/useDialog";
import { AlertTriangle, X } from "lucide-react";
/** Props for {@link ConfirmModal}. */
@@ -71,54 +73,13 @@ export function ConfirmModal({
const messageId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
// Prefer Cancel so Enter/activation doesn't immediately destroy data.
const focusTimer = window.setTimeout(() => cancelRef.current?.focus(), 0);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (e.key !== "Tab" || !panelRef.current) return;
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable.item(0);
const last = focusable.item(focusable.length - 1);
if (!first || !last) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey);
return () => {
window.clearTimeout(focusTimer);
document.removeEventListener("keydown", onKey);
previouslyFocused.current?.focus?.();
previouslyFocused.current = null;
};
}, [open, onCancel]);
useDialog(open, panelRef, onCancel, cancelRef);
if (!open) return null;
return (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4"
onClick={onCancel}
role="presentation"
>
@@ -134,7 +95,7 @@ export function ConfirmModal({
<div className="flex items-start gap-3 p-5">
{destructive && (
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" />
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
</div>
)}
<div className="min-w-0 flex-1">
@@ -151,10 +112,10 @@ export function ConfirmModal({
<button
type="button"
onClick={onCancel}
className="text-fg-muted hover:text-fg-secondary p-1 -mt-1 -mr-1"
className="btn-icon -mt-1 -mr-1"
aria-label={cancelLabel}
>
<X className="w-4 h-4" />
<X className="w-4 h-4" aria-hidden />
</button>
</div>
<div className="flex items-center justify-end gap-2 px-5 pb-5">
+192
View File
@@ -0,0 +1,192 @@
/**
* @file ContextMenu — the one shell every right-click (and `⋯`) menu opens
* in: a fixed list at a viewport point, rendered into `document.body` so no
* transformed or clipped ancestor moves it, nudged back inside the viewport
* once measured, closed by Escape, a press outside it, or after an item runs.
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
* turns a mouse event into that point (and stops the browser's own menu).
*
* Keyboard (WAI-ARIA menu): the first enabled item takes focus on open;
* Up/Down wrap, Home/End jump, Escape and Tab close, and focus goes back to
* what held it before the menu opened — unless an item moved it elsewhere
* (a rename field, a dialog). A menu opened from the keyboard (Shift+F10 or
* the Menu key reports clientX/Y 0) anchors under the element instead of the
* viewport's corner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
export type MenuPoint = { x: number; y: number };
/** The pointer of a right-click, the browser's own menu suppressed. A
* keyboard-opened menu has no pointer, so it anchors under the element. */
export function menuPoint(e: ReactMouseEvent): MenuPoint {
e.preventDefault();
e.stopPropagation();
if (e.clientX === 0 && e.clientY === 0 && e.currentTarget instanceof Element) {
const r = e.currentTarget.getBoundingClientRect();
return { x: r.left, y: r.bottom };
}
return { x: e.clientX, y: e.clientY };
}
/** Menu state for a list of rows: which row's menu is open, and where. */
export function useContextMenu<T>() {
const [menu, setMenu] = useState<(MenuPoint & { item: T }) | null>(null);
const open = useCallback((e: ReactMouseEvent, item: T) => {
setMenu({ ...menuPoint(e), item });
}, []);
const close = useCallback(() => setMenu(null), []);
return { menu, open, close };
}
const MARGIN = 8;
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
const ITEMS = '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])';
export function ContextMenu({
x,
y,
onClose,
testId,
className = "w-56",
children,
}: MenuPoint & {
onClose: () => void;
testId?: string;
className?: string;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// Focus in on open, back out on close. Runs once per mount: a menu that
// moves to a new point keeps its focus.
useEffect(() => {
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
ref.current?.querySelector<HTMLElement>(ITEMS)?.focus({ preventScroll: true });
return () => {
// Only when focus would otherwise be lost (it sat on a removed item).
const now = document.activeElement;
if (!now || now === document.body || ref.current?.contains(now)) previous?.focus?.();
};
}, []);
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const inField = (e.target as HTMLElement).closest(
"input, textarea, select, [contenteditable='true']"
);
if (e.key === "Escape" || (e.key === "Tab" && !inField)) {
// Handled here so a dialog behind the menu does not close as well.
e.preventDefault();
e.stopPropagation();
onClose();
return;
}
// A field inside a menu (a size slider, a search box) keeps its own keys.
if (inField) return;
const items = [...(ref.current?.querySelectorAll<HTMLElement>(ITEMS) ?? [])];
if (items.length === 0) return;
const i = items.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "ArrowDown"
? (i + 1) % items.length
: e.key === "ArrowUp"
? (i - 1 + items.length) % items.length
: e.key === "Home"
? 0
: e.key === "End"
? items.length - 1
: -1;
if (next < 0) return;
e.preventDefault();
items[next]!.focus();
};
useLayoutEffect(() => {
const r = ref.current?.getBoundingClientRect();
if (!r) return;
setPos({
left: Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)),
top: Math.max(MARGIN, Math.min(y, window.innerHeight - r.height - MARGIN)),
});
}, [x, y]);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
window.addEventListener("blur", onClose);
window.addEventListener("resize", onClose);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
window.removeEventListener("blur", onClose);
window.removeEventListener("resize", onClose);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
role="menu"
data-testid={testId}
style={pos}
// A portal still bubbles React events to the component that rendered
// it — a menu opened from a clickable card must not click the card too.
onClick={stop}
onDoubleClick={stop}
onMouseDown={stop}
onPointerDown={stop}
onKeyDown={onKeyDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className={`fixed z-50 rounded-lg border border-border bg-surface-2 p-1 shadow-lg ${className}`}
>
{children}
</div>,
document.body
);
}
export const MENU_ITEM =
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary focus:outline-none focus-visible:bg-surface-4 focus-visible:text-fg-primary focus-visible:ring-1 focus-visible:ring-accent/60 disabled:pointer-events-none disabled:opacity-50";
export function MenuItem({
onSelect,
danger,
disabled,
testId,
children,
}: {
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
testId?: string;
children: ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
data-testid={testId}
disabled={disabled}
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
onClick={onSelect}
>
{children}
</button>
);
}
export function MenuSeparator() {
return <div role="separator" className="my-1 border-t border-border" />;
}
+62 -17
View File
@@ -43,6 +43,7 @@
* ----------------------------------------------------------------------------- */
import React, { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react";
interface DateTimePickerProps {
@@ -52,16 +53,22 @@ interface DateTimePickerProps {
className?: string;
"aria-label"?: string;
title?: string;
/** A "to" bound: picking a day with no time yet means the END of that day
* (23:59), not its first minute. */
endOfDay?: boolean;
}
export function DateTimePicker({
value,
onChange,
placeholder = "Select date & time",
placeholder,
className = "",
"aria-label": ariaLabel,
title,
endOfDay = false,
}: DateTimePickerProps) {
const { t, i18n } = useTranslation("sessions");
const locale = i18n.resolvedLanguage ?? i18n.language;
const [isOpen, setIsOpen] = useState(false);
const [alignment, setAlignment] = useState<"left" | "right">("left");
const containerRef = useRef<HTMLDivElement>(null);
@@ -97,9 +104,18 @@ export function DateTimePicker({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen]);
const formatDisplay = (d: Date | null) => {
if (!d || isNaN(d.getTime())) return "";
return d.toLocaleString(undefined, {
return d.toLocaleString(locale, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -112,7 +128,9 @@ export function DateTimePicker({
if (dateObj && !isNaN(dateObj.getTime())) {
newDate.setHours(dateObj.getHours(), dateObj.getMinutes());
} else {
newDate.setHours(0, 0); // Default midnight
// Default: midnight, or 23:59 for an end bound so the day is included.
if (endOfDay) newDate.setHours(23, 59, 59, 999);
else newDate.setHours(0, 0);
}
updateValue(newDate);
};
@@ -135,7 +153,9 @@ export function DateTimePicker({
const day = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
onChange(`${y}-${mo}-${day}T${h}:${mi}`);
// An end bound carries its :59.999 so the whole last minute is included.
const tail = endOfDay ? ":59.999" : "";
onChange(`${y}-${mo}-${day}T${h}:${mi}${tail}`);
};
const clearValue = (e: React.MouseEvent) => {
@@ -168,13 +188,18 @@ export function DateTimePicker({
key={i}
type="button"
onClick={() => handleDateClick(i)}
aria-label={new Date(viewDate.getFullYear(), viewDate.getMonth(), i).toLocaleDateString(
locale,
{ weekday: "long", year: "numeric", month: "long", day: "numeric" }
)}
aria-pressed={isSelected}
className={`w-6 h-6 flex items-center justify-center rounded text-[11px] transition-colors
${
isSelected
? "bg-accent text-white font-medium"
? "bg-accent-solid text-white font-medium"
: isToday
? "bg-surface-3 text-accent font-medium"
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`}
>
{i}
@@ -193,17 +218,27 @@ export function DateTimePicker({
type="button"
onClick={() => setIsOpen(!isOpen)}
aria-label={ariaLabel}
aria-expanded={isOpen}
aria-haspopup="dialog"
title={title}
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left`}
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left ${dateObj ? "pr-7" : ""}`}
>
<Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
{dateObj ? formatDisplay(dateObj) : placeholder}
{dateObj ? formatDisplay(dateObj) : (placeholder ?? t("datePicker.placeholder"))}
</span>
{dateObj && (
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
)}
</button>
{/* A sibling, not a child: a button inside a button is not reachable by keyboard. */}
{dateObj && (
<button
type="button"
onClick={clearValue}
aria-label={t("datePicker.clear")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-fg-muted hover:text-fg-primary"
>
<X className="w-3 h-3" />
</button>
)}
{isOpen && (
<div
@@ -216,19 +251,21 @@ export function DateTimePicker({
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
aria-label={t("datePicker.prevMonth")}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs font-medium text-fg-secondary">
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
{viewDate.toLocaleString(locale, { month: "long", year: "numeric" })}
</span>
<button
type="button"
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
aria-label={t("datePicker.nextMonth")}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronRight className="w-4 h-4" />
</button>
@@ -237,8 +274,15 @@ export function DateTimePicker({
{/* Calendar Grid */}
<div>
<div className="grid grid-cols-7 gap-1 mb-1">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
<div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
{/* 2026-01-04 is a Sunday: seven local days give the weekday initials. */}
{Array.from({ length: 7 }, (_, d) =>
new Date(2026, 0, 4 + d).toLocaleDateString(locale, { weekday: "narrow" })
).map((day, d) => (
<div
key={d}
aria-hidden
className="w-6 text-center text-[10px] font-medium text-fg-muted"
>
{day}
</div>
))}
@@ -250,10 +294,11 @@ export function DateTimePicker({
<div className="pt-3 border-t border-border flex items-center justify-between">
<div className="flex items-center gap-1.5 text-fg-secondary">
<Clock className="w-3.5 h-3.5" />
<span className="text-[11px] font-medium">Time</span>
<span className="text-[11px] font-medium">{t("datePicker.time")}</span>
</div>
<input
type="time"
aria-label={t("datePicker.time")}
value={timeValue}
onChange={handleTimeChange}
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-fg-secondary focus:outline-none focus:border-accent w-[85px] time-input-custom"
-2
View File
@@ -24,8 +24,6 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) {
title = t("sessions");
} else if (path.startsWith("/workflows")) {
title = t("workflows");
} else if (path.startsWith("/workspace")) {
title = t("workspace");
} else if (path.startsWith("/settings")) {
+11 -5
View File
@@ -75,20 +75,26 @@ export interface EmptyStateProps {
description: string;
/** Optional CTA rendered below the description (button, link, or form). */
action?: React.ReactNode;
/** Inside a card (a settings panel) rather than a whole page: less padding. */
compact?: boolean;
}
/**
* Renders a vertically centered empty state inside the main content column.
* @param props See {@link EmptyStateProps}.
*/
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
export function EmptyState({ icon: Icon, title, description, action, compact }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
<Icon className="w-6 h-6 text-fg-muted" />
<div
className={`flex flex-col items-center justify-center text-center ${compact ? "py-8" : "py-20"}`}
>
<div
className={`rounded-2xl bg-surface-4 flex items-center justify-center ${compact ? "w-10 h-10 mb-3" : "w-14 h-14 mb-5"}`}
>
<Icon className="w-6 h-6 text-fg-muted" aria-hidden />
</div>
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
<p className="text-sm text-fg-muted max-w-md mb-6">{description}</p>
<p className={`text-sm text-fg-muted max-w-md ${compact ? "" : "mb-6"}`}>{description}</p>
{action}
</div>
);
+4 -4
View File
@@ -277,7 +277,7 @@ function SummaryBlock({
refs.length > 0 ? t("eventDetail.seeDetailsBelow", { fields: refs.join(" · ") }) : null;
return (
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
<div className="px-3 py-1 border-b border-border bg-black/20">
<div className="px-3 py-1 border-b border-border bg-surface-1">
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
{t("eventDetail.summary")}
</span>
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
const color = value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
return (
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
@@ -394,8 +394,8 @@ function CodeView({ value }: { value: unknown }) {
const text = typeof value === "string" ? value : safeStringify(value);
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-surface-1">
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
</span>
+135 -17
View File
@@ -86,7 +86,8 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useRef, useState } from "react";
import { Checkbox } from "./Checkbox";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Search, X, Filter } from "lucide-react";
import { api } from "../lib/api";
@@ -114,6 +115,45 @@ export const EMPTY_FILTERS: EventFiltersValue = {
to: "",
};
/** URL key per filter field (short, so a shared link stays readable). */
const PARAM_OF: Record<keyof EventFiltersValue, string> = {
event_type: "et",
tool_name: "tn",
agent_id: "ag",
session_id: "sid",
status: "st",
q: "q",
from: "from",
to: "to",
};
/** Filters read from the URL (lists are comma-joined). */
export function filtersFromParams(params: URLSearchParams): EventFiltersValue {
const list = (k: string) => (params.get(k) ?? "").split(",").filter(Boolean);
return {
event_type: list(PARAM_OF.event_type),
tool_name: list(PARAM_OF.tool_name),
agent_id: list(PARAM_OF.agent_id),
session_id: list(PARAM_OF.session_id),
status: list(PARAM_OF.status),
q: params.get(PARAM_OF.q) ?? "",
from: params.get(PARAM_OF.from) ?? "",
to: params.get(PARAM_OF.to) ?? "",
};
}
/** A copy of `base` with the filter keys rewritten; empty filters are dropped. */
export function filtersToParams(f: EventFiltersValue, base: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(base);
for (const [field, key] of Object.entries(PARAM_OF) as [keyof EventFiltersValue, string][]) {
const v = f[field];
const str = Array.isArray(v) ? v.join(",") : v;
if (str) next.set(key, str);
else next.delete(key);
}
return next;
}
export function isEmptyFilters(f: EventFiltersValue): boolean {
return (
f.event_type.length === 0 &&
@@ -179,13 +219,18 @@ export function EventFilters({
tool_names: [],
});
const [searchDraft, setSearchDraft] = useState(value.q);
// The debounced search merges into the LATEST value: a chip toggled during
// the 300 ms must not be reverted by the value captured at keystroke time.
const valueRef = useRef(value);
valueRef.current = value;
// Debounce text search - only push up after 300ms of inactivity.
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
if (searchDraft !== value.q) onChange({ ...value, q: searchDraft });
const latest = valueRef.current;
if (searchDraft !== latest.q) onChange({ ...latest, q: searchDraft });
}, 300);
return () => {
if (searchTimer.current) clearTimeout(searchTimer.current);
@@ -247,6 +292,7 @@ export function EventFilters({
/>
<span className="text-xs text-fg-muted">→</span>
<DateTimePicker
endOfDay
value={value.to}
onChange={(val: string) => onChange({ ...value, to: val })}
aria-label={t("eventFilters.to")}
@@ -300,11 +346,10 @@ export function EventFilters({
onToggle={(item) => toggle("agent_id", item)}
/>
)}
{!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && (
<ChipGroup
{!hideSessionFilter && (
<SessionChipGroup
label={t("eventFilters.sessionId")}
options={sessionOptions.map((s) => s.id)}
labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))}
known={sessionOptions}
selected={value.session_id}
onToggle={(item) => toggle("session_id", item)}
/>
@@ -314,22 +359,98 @@ export function EventFilters({
);
}
/**
* The session filter: searched on the server (`q`, 20 rows), never a list of
* every session. Selected ids stay listed (with the label last seen) so they
* can be unticked while the search shows other rows.
*/
function SessionChipGroup({
label,
known,
selected,
onToggle,
}: {
label: string;
known?: Array<{ id: string; label: string }>;
selected: string[];
onToggle: (item: string) => void;
}) {
const { t } = useTranslation("common");
const [query, setQuery] = useState("");
const [rows, setRows] = useState<Array<{ id: string; label: string }>>([]);
const labels = useRef(new Map<string, string>());
for (const k of known ?? []) labels.current.set(k.id, k.label);
const [open, setOpen] = useState(false);
const seq = useRef(0);
useEffect(() => {
if (!open) return;
const timer = setTimeout(
() => {
const mine = ++seq.current;
api.sessions
.list({ q: query.trim() || undefined, limit: 20 })
.then((res) => {
if (mine !== seq.current) return;
const next = res.sessions.map((s) => ({
id: s.id,
label: s.name?.trim() || s.id.slice(0, 8),
}));
for (const r of next) labels.current.set(r.id, r.label);
setRows(next);
})
.catch(() => undefined);
},
query ? 250 : 0
);
return () => clearTimeout(timer);
}, [open, query]);
const options = [...selected, ...rows.map((r) => r.id).filter((id) => !selected.includes(id))];
return (
<ChipGroup
label={label}
options={options}
labels={Object.fromEntries(options.map((id) => [id, labels.current.get(id) ?? id]))}
selected={selected}
onToggle={onToggle}
onOpenChange={setOpen}
search={
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("eventFilters.searchPlaceholder")}
aria-label={label}
className="w-full mb-1 bg-surface-2 border border-border rounded px-2 py-1 text-[11px] text-fg-secondary focus:outline-none focus:border-accent"
/>
}
/>
);
}
function ChipGroup({
label,
options,
selected,
onToggle,
labels,
onOpenChange,
search,
}: {
label: string;
options: string[];
selected: string[];
onToggle: (item: string) => void;
labels?: Record<string, string>;
onOpenChange?: (open: boolean) => void;
/** Rendered above the options (a search box). */
search?: React.ReactNode;
}) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => onOpenChange?.(open), [open, onOpenChange]);
// Click-outside dismiss.
useEffect(() => {
@@ -367,6 +488,7 @@ function ChipGroup({
aria-label={label}
className="absolute left-0 mt-1 z-20 min-w-[220px] max-h-64 overflow-auto bg-surface-1 border border-border rounded shadow-xl p-1.5"
>
{search}
{options.length === 0 ? (
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
{t("eventFilters.noOptions")}
@@ -376,18 +498,14 @@ function ChipGroup({
const checked = selected.includes(opt);
const display = labels?.[opt] ?? opt;
return (
<label
<Checkbox
key={opt}
className="flex items-center gap-2 px-2 py-1 text-[11px] text-fg-secondary rounded hover:bg-surface-3 cursor-pointer"
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(opt)}
className="accent-accent"
/>
<span className="font-mono break-all">{display}</span>
</label>
checked={checked}
onChange={() => onToggle(opt)}
label={display}
className="flex w-full px-2 py-1 rounded hover:bg-surface-3"
labelClassName="font-mono break-all text-[11px] text-fg-secondary"
/>
);
})
)}
+1 -1
View File
@@ -100,7 +100,7 @@ export function EventFiltersInfo() {
<p className="text-[11px] text-fg-secondary mb-2">
{t("eventFilters.help.lifecycleDesc")}
</p>
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
<code className="block bg-surface-1 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
{t("eventFilters.help.lifecycleFlow")}
</code>
</Section>
+35 -29
View File
@@ -81,6 +81,7 @@ import {
import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { WSMessage, ImportProgressMessage } from "../lib/types";
import { PathPickButton } from "./PathPickButton";
type Mode = "rescan" | "path" | "upload" | "backup";
@@ -294,7 +295,7 @@ export function ImportHistory() {
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
>
<span className="flex items-center gap-2 text-xs font-semibold text-fg-secondary uppercase tracking-wider">
<ListChecks className="w-3.5 h-3.5 text-blue-500" />
<ListChecks className="w-3.5 h-3.5 text-accent" />
{t("import.instructions")}
</span>
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
@@ -426,14 +427,17 @@ export function ImportHistory() {
{mode === "path" && (
<div className="space-y-3">
<div>
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder={t("import.folderPlaceholder")}
className="input w-full text-sm font-mono"
spellCheck={false}
/>
<div className="flex gap-1.5">
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder={t("import.folderPlaceholder")}
className="input w-full text-sm font-mono"
spellCheck={false}
/>
<PathPickButton start={folderPath} onPick={setFolderPath} />
</div>
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
</div>
<div className="flex justify-end">
@@ -455,8 +459,8 @@ export function ImportHistory() {
{mode === "upload" && (
<div className="space-y-3">
<div
onClick={() => fileInputRef.current?.click()}
<label
htmlFor="import-upload-files"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -467,9 +471,9 @@ export function ImportHistory() {
setDragging(false);
onSelectFiles(e.dataTransfer.files);
}}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
dragging
? "border-blue-500 bg-blue-600/5"
? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
@@ -477,14 +481,15 @@ export function ImportHistory() {
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
<input
id="import-upload-files"
ref={fileInputRef}
type="file"
multiple
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
onChange={(e) => onSelectFiles(e.target.files)}
className="hidden"
className="sr-only"
/>
</div>
</label>
{files.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-fg-secondary">
@@ -522,8 +527,8 @@ export function ImportHistory() {
{mode === "backup" && (
<div className="space-y-3">
<div
onClick={() => backupInputRef.current?.click()}
<label
htmlFor="import-backup-file"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -535,9 +540,9 @@ export function ImportHistory() {
const f = e.dataTransfer.files?.[0];
if (f) setBackupFile(f);
}}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
dragging
? "border-blue-500 bg-blue-600/5"
? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
@@ -545,13 +550,14 @@ export function ImportHistory() {
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
<input
id="import-backup-file"
ref={backupInputRef}
type="file"
accept=".json,application/json"
onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
className="hidden"
className="sr-only"
/>
</div>
</label>
{backupFile && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-fg-secondary min-w-0">
@@ -591,7 +597,7 @@ export function ImportHistory() {
{/* In-flight progress */}
{running && progressText && (
<div className="flex items-center gap-2 text-xs text-fg-secondary bg-surface-2 border border-border rounded-md px-3 py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent flex-shrink-0" />
<span className="truncate">{progressText}</span>
{progress?.current && (
<code className="font-mono text-[11px] text-fg-muted truncate">
@@ -625,7 +631,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-400"
color="text-accent"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
@@ -672,12 +678,12 @@ export function ImportHistory() {
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events}
color="text-violet-300"
color="text-fg-primary"
/>
<ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
value={backupResult.model_pricing}
color="text-cyan-300"
color="text-cyan-700 dark:text-cyan-300"
/>
</div>
<p className="text-[11px] text-fg-muted">
@@ -728,16 +734,16 @@ function ModeButton({
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`text-left p-3 rounded-lg border transition-colors ${
active
? "border-blue-600/40 bg-blue-600/10"
: "border-border bg-surface-2 hover:bg-surface-3"
active ? "border-accent/40 bg-accent/10" : "border-border bg-surface-2 hover:bg-surface-3"
}`}
>
<div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-400" : "text-fg-secondary"
active ? "text-accent" : "text-fg-secondary"
}`}
>
{icon}
+82 -7
View File
@@ -1,7 +1,9 @@
/**
* @file Layout.tsx
* @description Application shell that frames every authenticated route: persistent
* sidebar, main content column, update notifier, and the Sagi assistant overlay.
* sidebar, main content column, the bottom status bar, update notifier, and the
* Sagi assistant overlay. The connection details modal's open state lives here
* because both the sidebar's rail button and the status bar open it.
* The layout is the single parent route in {@link App} — child pages render inside
* React Router's `<Outlet />` so navigation never remounts chrome.
*
@@ -10,6 +12,12 @@
* written back on every toggle. Failures to access storage are swallowed so a
* private-browsing quota error never breaks the UI.
*
* ## Narrow viewports
* With no stored preference the sidebar starts collapsed below `lg` (see
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
* button in a slim top bar opens it as an off-canvas drawer (a modal dialog —
* backdrop, Escape, focus trapped and restored), which closes on navigation.
*
* ## Sticky descendants
* The inner content wrapper uses `overflow-x-clip` (not `hidden`) so horizontal
* overflow is clipped without creating a scroll container. That keeps `position:
@@ -62,12 +70,28 @@
*
* ----------------------------------------------------------------------------- */
import { useState, useCallback } from "react";
import { Outlet } from "react-router-dom";
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi";
import { StatusBar } from "./StatusBar";
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
export const DRAWER_QUERY = "(max-width: 767.98px)";
function useMedia(query: string): boolean {
return useSyncExternalStore(
(cb) => {
const mq = window.matchMedia?.(query);
mq?.addEventListener?.("change", cb);
return () => mq?.removeEventListener?.("change", cb);
},
() => window.matchMedia?.(query).matches ?? false
);
}
/** Props for {@link Layout}. */
interface LayoutProps {
@@ -82,6 +106,19 @@ interface LayoutProps {
export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const [updateAvailable, setUpdateAvailable] = useState(false);
const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const { pathname, search } = useLocation();
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
// A pick in the drawer navigates; the drawer has done its job.
useEffect(closeDrawer, [pathname, search, closeDrawer]);
useEffect(() => {
if (!drawer) setDrawerOpen(false);
}, [drawer]);
const toggle = useCallback(() => {
setCollapsed((prev) => {
@@ -100,16 +137,48 @@ export function Layout({ wsConnected }: LayoutProps) {
</a>
<UpdateNotifier />
<Sagi />
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
{drawer && drawerOpen && (
<div
aria-hidden
data-testid="sidebar-backdrop"
onClick={closeDrawer}
className="fixed inset-0 z-40 bg-black/50 animate-fade-in"
/>
)}
<Sidebar
wsConnected={wsConnected}
collapsed={drawer ? false : collapsed}
onToggle={drawer ? closeDrawer : toggle}
drawer={drawer ? { open: drawerOpen, onClose: closeDrawer } : undefined}
connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen}
onUpdateAvailableChange={setUpdateAvailable}
/>
<main
id="main-content"
tabIndex={-1}
className="min-h-screen min-w-0 transition-[margin-left,width] duration-200 outline-none"
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
style={{
marginLeft: collapsed ? "4.25rem" : "15rem",
width: collapsed ? "calc(100% - 4.25rem)" : "calc(100% - 15rem)",
marginLeft: sidebarWidth,
width: `calc(100% - ${sidebarWidth})`,
}}
>
{drawer && (
<div className="sticky top-0 z-20 flex h-11 items-center gap-2 border-b border-border bg-surface-1 px-2">
<button
type="button"
data-testid="sidebar-open"
onClick={() => setDrawerOpen(true)}
aria-label={t("openMenu")}
aria-expanded={drawerOpen}
title={t("openMenu")}
className="btn-icon"
>
<Menu className="h-5 w-5" aria-hidden />
</button>
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
</div>
)}
{/* overflow-x-clip (not -hidden) clips horizontal overflow without
creating a scroll container, so descendant `position: sticky`
elements (e.g. the Settings page TOC) still pin to the window. */}
@@ -117,6 +186,12 @@ export function Layout({ wsConnected }: LayoutProps) {
<Outlet />
</div>
</main>
<StatusBar
wsConnected={wsConnected}
left={sidebarWidth}
onConnectionClick={() => setConnectionOpen(true)}
updateAvailable={updateAvailable}
/>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* @file MarkdownPreview.tsx
* @description A markdown file rendered for reading (GitHub-flavoured:
* tables, task lists, strikethrough, autolinks), shared by the config
* explorer's file viewer and the lane Changes panel. Raw HTML in the file is
* shown as text, never run (react-markdown without rehype-raw), and links
* open in a new tab. A leading YAML frontmatter block (skills, agents) is
* shown as a code block instead of being misread as a heading. `isMarkdown`
* says which paths get the preview toggle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/** Whether `path` names a markdown file. */
export function isMarkdown(path: string | null | undefined): boolean {
return !!path && /\.(md|markdown|mdx)$/i.test(path);
}
/** Split a leading `---` frontmatter block off the body. */
function splitFrontmatter(text: string): { front: string | null; body: string } {
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
return m ? { front: m[1]!, body: text.slice(m[0].length) } : { front: null, body: text };
}
export function MarkdownPreview({ text, className = "" }: { text: string; className?: string }) {
const { front, body } = splitFrontmatter(text);
return (
<div data-testid="markdown-preview" className={`md-preview ${className}`}>
{front !== null && <pre className="md-frontmatter">{front}</pre>}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noreferrer noopener" />
),
}}
>
{body}
</ReactMarkdown>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* @file NeedsYouStrip.tsx
* @description The Dashboard's pinned "Needs you" strip: every active session
* blocked on the user (`awaiting_input_since`), each linking straight to its
* workspace pane (or replay), else its analysis page. Renders nothing while no
* session is waiting. `compact` is the one-line form shown above Analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { BellRing } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { timeAgo } from "../lib/format";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { workspaceHref } from "./SessionRowMenu";
import type { Session } from "../lib/types";
export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
const { t } = useTranslation("dashboard");
const [sessions, setSessions] = useState<Session[]>([]);
// The real count: only the first 20 are fetched and listed.
const [total, setTotal] = useState(0);
const [runs, setRuns] = useState<RunHandle[]>([]);
const load = useCallback(() => {
api.sessions
.list({ awaiting: true, limit: 20 })
.then((r) => {
setSessions(r.sessions);
setTotal(r.total);
})
.catch(() => undefined); // the strip is a shortcut; the pages show errors
api.run
.list()
.then((r) => setRuns(r.items))
.catch(() => undefined);
}, []);
useEffect(() => {
load();
}, [load]);
useLiveRefresh(load, {
intervalMs: 15000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
if (sessions.length === 0) return null;
return (
<section
aria-label={t("needsYou.title")}
className={`card border-status-warning/40 ${compact ? "px-3 py-2" : "p-4"}`}
>
<div className={`flex flex-wrap items-center gap-2 ${compact ? "" : "mb-3"}`}>
<BellRing className="w-4 h-4 text-status-warning flex-shrink-0" aria-hidden />
<h2 className="text-sm font-medium text-fg-primary">
{t("needsYou.title")}{" "}
<span className="text-fg-muted font-normal">({Math.max(total, sessions.length)})</span>
</h2>
{compact &&
sessions.slice(0, 5).map((s) => (
<Link
key={s.id}
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
className="text-xs px-2 py-0.5 rounded-full bg-status-warning/10 border border-status-warning/30 text-fg-secondary hover:text-fg-primary truncate max-w-[14rem]"
>
{s.name || s.id.slice(0, 8)}
</Link>
))}
</div>
{!compact && (
<ul className="divide-y divide-border">
{sessions.map((s) => (
<li key={s.id}>
<Link
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
className="flex items-center gap-3 py-2 px-1 hover:bg-surface-4 rounded"
>
<span className="w-2 h-2 rounded-full bg-status-warning flex-shrink-0" />
<span className="text-sm text-fg-secondary truncate flex-1">
{s.name || s.id.slice(0, 8)}
</span>
<span className="text-[11px] text-fg-muted truncate max-w-[16rem]">{s.cwd}</span>
{s.awaiting_input_since && (
<span className="text-[11px] text-fg-muted flex-shrink-0">
{t("needsYou.since", { ago: timeAgo(s.awaiting_input_since) })}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</section>
);
}
+76 -13
View File
@@ -1,11 +1,20 @@
/**
* @file PageTabs.tsx
* @description Shared top-of-page tab bar used by the pages that merged several
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
* the owning page keeps the active tab in its own query param.
* former routes into one (Settings hub, Sessions/Events history). The owning
* page keeps the active tab in its own query param. `actions` sits at the bar's
* right edge, outside the tablist.
*
* These are real in-page tabs (one view swapped in place), so they follow the
* WAI-ARIA tabs pattern: one tab stop (roving tabindex), Left/Right/Home/End
* move AND select. Pass the active view as `children` and it is wrapped in the
* `role="tabpanel"` the tabs point at with `aria-controls`; without children no
* `aria-controls` is emitted (it would name an element that does not exist).
* The selected tab is marked by weight and an underline, not colour alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useId, type ComponentType, type KeyboardEvent, type ReactNode } from "react";
export interface PageTab {
id: string;
@@ -18,32 +27,69 @@ interface PageTabsProps {
activeId: string;
onSelect: (id: string) => void;
ariaLabel: string;
/** Buttons at the right edge of the bar (not tabs). */
actions?: ReactNode;
/** The active tab's view, rendered inside its `role="tabpanel"`. */
children?: ReactNode;
}
/**
* Horizontal tab strip for a page that hosts several panels.
* @param props - Tabs to render, the active tab id, and the selection callback.
* @returns The tab bar element.
*/
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
return (
export function PageTabs({
tabs,
activeId,
onSelect,
ariaLabel,
actions,
children,
}: PageTabsProps) {
const base = useId();
const tabId = (id: string) => `${base}-tab-${id}`;
const panelId = `${base}-panel`;
const hasPanel = children !== undefined;
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = tabs.findIndex((t) => t.id === activeId);
const last = tabs.length - 1;
const next =
e.key === "ArrowRight"
? (i + 1) % tabs.length
: e.key === "ArrowLeft"
? (i - 1 + tabs.length) % tabs.length
: e.key === "Home"
? 0
: e.key === "End"
? last
: -1;
const tab = tabs[next];
if (!tab) return;
e.preventDefault();
onSelect(tab.id);
document.getElementById(tabId(tab.id))?.focus();
};
const list = (
<div
role="tablist"
aria-label={ariaLabel}
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
onKeyDown={onKeyDown}
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
}`}
>
{tabs.map(({ id, label, Icon }) => {
const isActive = id === activeId;
return (
<button
key={id}
id={tabId(id)}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={hasPanel && isActive ? panelId : undefined}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(id)}
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
className={`inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
isActive
? "bg-accent/15 border-accent/30 text-accent"
? "bg-accent/15 border-accent/30 text-accent shadow-[inset_0_-2px_0_rgb(var(--accent))]"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
@@ -54,4 +100,21 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
})}
</div>
);
const bar = actions ? (
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
{list}
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
</div>
) : (
list
);
if (!hasPanel) return bar;
return (
<>
{bar}
<div role="tabpanel" id={panelId} aria-labelledby={tabId(activeId)}>
{children}
</div>
</>
);
}
+86
View File
@@ -0,0 +1,86 @@
/**
* @file PathPickButton.tsx
* @description The one "Browse" button beside every path field: it asks the
* server to open the operating system's own folder/file picker
* (`POST /api/lanes/pick-path`) and fills the field with the absolute path the
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
onPick,
showLabel = false,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
const [fallback, setFallback] = useState(false);
const title = kind === "dir" ? t("browse.title") : t("browse.fileTitle");
const pick = async () => {
setBusy(true);
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
};
return (
<>
<button
type="button"
onClick={pick}
disabled={busy}
title={title}
aria-label={title}
className="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
{showLabel && t("browse.button")}
</button>
{/* Its clicks stay here: a backdrop click must close only this modal,
not bubble on to a dialog this button sits in. */}
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
/>
</div>
</>
);
}
+107 -43
View File
@@ -9,7 +9,8 @@
* here — authentication defers to the host's SSH stack (~/.ssh/config, agent,
* keys, known_hosts); a source is just a label + ssh destination (+ optional
* port / identity file / remote home). Live status/sync updates arrive over the
* `remote_source.status` WebSocket message.
* `remote_source.status` WebSocket message. A right-click on a source opens
* its Test / Sync / Enable-Disable / Edit / Delete (plus Copy host) as a menu.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -57,6 +58,8 @@
*
* ----------------------------------------------------------------------------- */
import { Checkbox } from "./Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -79,9 +82,14 @@ import {
import { api } from "../lib/api";
import type { RemoteSource, RemoteSourceInput } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope";
import { PathPickButton } from "./PathPickButton";
/** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
const EMPTY_FORM: RemoteSourceInput = {
label: "",
@@ -134,6 +142,13 @@ export function RemoteSources() {
const [formError, setFormError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const ctx = useContextMenu<RemoteSource>();
// A menu item: close the menu, then act on the source it was opened on.
const pick = (fn: (s: RemoteSource) => unknown) => () => {
const s = ctx.menu?.item;
ctx.close();
if (s) fn(s);
};
const [syncingAll, setSyncingAll] = useState(false);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
{}
@@ -248,8 +263,8 @@ export function RemoteSources() {
try {
await api.remoteSources.update(s.id, { enabled: !s.enabled });
load();
} catch {
/* surfaced via reload */
} catch (err) {
toast.error(err);
} finally {
setBusyId(null);
}
@@ -291,8 +306,10 @@ export function RemoteSources() {
try {
await api.remoteSources.syncAll();
load();
} catch {
/* per-source errors surface via each source's status on reload */
} catch (err) {
// Per-source errors still surface on each source's status after the reload.
toast.error(err);
load();
} finally {
setSyncingAll(false);
}
@@ -306,8 +323,9 @@ export function RemoteSources() {
await api.remoteSources.remove(id, purge);
setConfirmDelete(null);
load();
} catch {
/* surfaced via reload */
} catch (err) {
setConfirmDelete(null);
toast.error(err);
} finally {
setBusyId(null);
}
@@ -348,7 +366,26 @@ export function RemoteSources() {
</p>
{/* Card selector — one card per scope mode, each with a short explanation
so the choice is self-describing rather than a bare radio label. */}
<div className="grid gap-2.5 sm:grid-cols-3" role="radiogroup" aria-label="Data scope">
<div
className="grid gap-2.5 sm:grid-cols-3"
role="radiogroup"
aria-label={t("remoteSources.scopeTitle", "Data scope")}
onKeyDown={(e) => {
// Arrow keys move the choice, like a native radio group.
const step =
e.key === "ArrowRight" || e.key === "ArrowDown"
? 1
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? -1
: 0;
if (!step) return;
e.preventDefault();
const i = SCOPE_MODES.indexOf(scope.mode);
const next = SCOPE_MODES[(i + step + SCOPE_MODES.length) % SCOPE_MODES.length]!;
setMode(next);
e.currentTarget.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus();
}}
>
{(
[
{
@@ -384,8 +421,11 @@ export function RemoteSources() {
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
data-mode={mode}
onClick={() => setMode(mode)}
className={`relative text-left rounded-xl border p-3 transition-colors ${
active
@@ -482,7 +522,7 @@ export function RemoteSources() {
</label>
<input
className="input w-full"
placeholder="Dev box"
placeholder={t("remoteSources.fieldLabelPlaceholder", "Dev box")}
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
@@ -519,12 +559,19 @@ export function RemoteSources() {
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
</label>
<input
className="input w-full font-mono"
placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""}
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
/>
<div className="flex gap-1.5">
<input
className="input w-full font-mono"
placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""}
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
/>
<PathPickButton
kind="file"
start={form.identity_file || "~/.ssh"}
onPick={(p) => setForm((f) => ({ ...f, identity_file: p }))}
/>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-fg-muted mb-1">
@@ -544,17 +591,12 @@ export function RemoteSources() {
</p>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-accent"
checked={!!form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
<span className="text-sm text-fg-secondary">
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
</span>
</label>
<Checkbox
checked={!!form.enabled}
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
label={t("remoteSources.fieldEnabled", "Sync automatically in the background")}
labelClassName="text-sm text-fg-secondary"
/>
{formError && (
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
{formError}
@@ -602,7 +644,7 @@ export function RemoteSources() {
const test = testResults[s.id];
const busy = busyId === s.id;
return (
<div key={s.id} className="card p-4">
<div key={s.id} onContextMenu={(e) => ctx.open(e, s)} className="card p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@@ -723,22 +765,16 @@ export function RemoteSources() {
<p className="text-xs text-fg-secondary mb-2">
{t("remoteSources.confirmDelete", "Remove this source?")}
</p>
<label className="flex items-center gap-2 mb-3 cursor-pointer">
<input
type="checkbox"
className="accent-red-500"
checked={confirmDelete.purge}
onChange={(e) =>
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
}
/>
<span className="text-xs text-fg-secondary">
{t(
"remoteSources.purgeData",
"Also delete the sessions imported from this source (cannot be undone)"
)}
</span>
</label>
<Checkbox
className="mb-3"
tone="danger"
checked={confirmDelete.purge}
onChange={(v) => setConfirmDelete((c) => c && { ...c, purge: v })}
label={t(
"remoteSources.purgeData",
"Also delete the sessions imported from this source (cannot be undone)"
)}
/>
<div className="flex items-center gap-2">
<button
onClick={doDelete}
@@ -763,6 +799,34 @@ export function RemoteSources() {
</div>
);
})}
{ctx.menu && (
<ContextMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
testId="remote-source-menu"
>
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(testSource)}>
{t("remoteSources.test", "Test")}
</MenuItem>
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(syncNow)}>
{t("remoteSources.syncNow", "Sync")}
</MenuItem>
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(toggleEnabled)}>
{ctx.menu.item.enabled
? t("remoteSources.disable", "Disable")
: t("remoteSources.enable", "Enable")}
</MenuItem>
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
{t("rowMenu.copyHost")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick((s) => setConfirmDelete({ id: s.id, purge: false }))}>
{t("common:delete", "Delete")}
</MenuItem>
</ContextMenu>
)}
</div>
)}
</div>
@@ -0,0 +1,84 @@
/**
* @file RouteErrorBoundary.tsx
* @description Route-level error boundary around the lazily loaded pages. After
* a self-update an open tab still asks for the OLD `/assets/<Page>-<hash>.js`,
* which the SPA fallback answers with index.html, so the dynamic import
* rejects: that case reloads the page once (a sessionStorage guard stops a
* reload loop). Any other render error — or a second chunk failure — shows a
* themed "Something went wrong — Reload" panel instead of a blank app, until the
* route changes (`resetKey`): the lazy routes share one boundary instance, so
* an error on one page would otherwise stay on every other.
* `reloadOnce` is also used by App's `vite:preloadError` listener.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Component, type ReactNode } from "react";
import i18n from "../i18n";
const RELOAD_KEY = "agenthub.chunkReloadAt";
/** A second chunk failure within this window means the reload did not help. */
const RELOAD_WINDOW_MS = 30_000;
/** A failed dynamic `import()` (Chrome, Firefox, Safari wordings) or a ChunkLoadError. */
export function isChunkLoadError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return (
err.name === "ChunkLoadError" ||
/dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(
err.message
)
);
}
/** Reload the page unless this tab already did so in the last 30 s. */
export function reloadOnce(reload: () => void = () => window.location.reload()): boolean {
try {
const last = Number(sessionStorage.getItem(RELOAD_KEY) || 0);
if (Date.now() - last < RELOAD_WINDOW_MS) return false;
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
} catch {
return false; // no sessionStorage: no loop guard, so never auto-reload
}
reload();
return true;
}
interface Props {
children: ReactNode;
/** Injected in tests; defaults to `window.location.reload`. */
reload?: () => void;
/** A change (the route's path) clears a caught error. */
resetKey?: string;
}
export class RouteErrorBoundary extends Component<Props, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error) {
if (isChunkLoadError(error)) reloadOnce(this.props.reload);
}
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null });
}
render() {
if (!this.state.error) return this.props.children;
const reload = this.props.reload ?? (() => window.location.reload());
return (
<div role="alert" className="card flex flex-col items-center gap-3 py-16 text-center">
<p className="text-sm font-medium text-fg-primary">
{i18n.t("dashboard:errorBoundary.title")}
</p>
<p className="text-xs text-fg-muted max-w-md">{i18n.t("dashboard:errorBoundary.body")}</p>
<button type="button" className="btn-primary text-sm" onClick={reload}>
{i18n.t("dashboard:errorBoundary.reload")}
</button>
</div>
);
}
}
+268
View File
@@ -0,0 +1,268 @@
/**
* @file PetMenu.tsx
* @description The mascot's right-click menu: hide it, size it, choose the
* pet it wears — Sagi, a featured pet (downloaded from codex-pets.net into
* this install on the first pick, `POST /api/pets/featured/:id`), or one of
* your own (deletable) — upload an image or import a `.codex-pet.zip`, browse
* codex-pets.net for more, and open the Sagi settings. The choice and the size
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
* with Settings → Pet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
ChevronRight,
Download,
EyeOff,
ExternalLink,
Loader2,
PackageOpen,
Settings2,
Trash2,
Upload,
} from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { toast } from "../../lib/toast";
const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
export function PetMenu({
pet,
size,
onClose,
onSettings,
}: {
pet: PetChoice;
size: number;
onClose: () => void;
onSettings: () => void;
}) {
const { t } = useTranslation("settings");
const ref = useRef<HTMLDivElement>(null);
const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null);
const [choosing, setChoosing] = useState(false);
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Closes on a press outside the menu, or Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
const addFile = (file: File | undefined, bundle: boolean) => file && void add(file, bundle);
const mark = (id: string) =>
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
return (
<div
ref={ref}
role="menu"
data-testid="pet-menu"
className="w-72 rounded-xl border border-border bg-surface-2 p-1.5 text-sm shadow-lg"
>
<div className="px-2.5 pb-1 pt-0.5 text-xs font-medium text-fg-muted">
{t("sagi.pet.title")}
</div>
<button
type="button"
role="menuitem"
className={ITEM}
onClick={() => {
sagiPrefs.setEnabled(false);
toast.info(t("sagi.pet.hiddenToast"));
onClose();
}}
>
<EyeOff className="h-4 w-4" />
{t("sagi.pet.hide")}
</button>
<label className="block px-2.5 py-1.5">
<span className="flex items-center justify-between text-sm text-fg-secondary">
{t("sagi.pet.size")}
<span className="tabular-nums text-xs text-fg-muted">{size}px</span>
</span>
<input
type="range"
data-testid="pet-size"
min={PET_SIZE.min}
max={PET_SIZE.max}
value={size}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="mt-1 w-full accent-[rgb(var(--accent))]"
/>
</label>
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
aria-expanded={choosing}
data-testid="pet-choose"
className={ITEM}
onClick={() => setChoosing((v) => !v)}
>
{choosing ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
{t("sagi.pet.choose")}
</button>
{choosing && (
<div className="max-h-80 overflow-y-auto pl-2">
<button
type="button"
role="menuitemradio"
aria-checked={pet.id === "sagi"}
className={ITEM}
onClick={() => sagiPrefs.setPet({ id: "sagi" })}
>
Sagi
{mark("sagi")}
</button>
{list?.featured.map((f) => {
const have = installed.get(f.id);
return (
<button
key={f.id}
type="button"
role="menuitemradio"
aria-checked={!!have && pet.id === have.id}
data-testid={`pet-featured-${f.id}`}
disabled={busy !== null}
title={t("sagi.pet.featuredBy", { by: f.by })}
className={ITEM}
onClick={() => (have ? wear(have) : void install(f.id))}
>
<span className="min-w-0 truncate">{f.displayName}</span>
{busy === f.id ? (
<Loader2 className="ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
) : have ? (
mark(have.id)
) : (
<Download
aria-label={t("sagi.pet.download")}
className="ml-auto h-3.5 w-3.5 shrink-0 text-fg-muted"
/>
)}
</button>
);
})}
{own.length > 0 && <div className="my-1.5 border-t border-border" />}
{own.map((p) => (
<div key={p.id} className="group flex items-center">
<button
type="button"
role="menuitemradio"
aria-checked={pet.id === p.id}
data-testid={`pet-own-${p.id}`}
className={ITEM}
onClick={() => wear(p)}
>
<span className="min-w-0 truncate">{p.displayName}</span>
{mark(p.id)}
</button>
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className={`${ITEM} !text-fg-muted`}
>
<ExternalLink className="h-4 w-4" />
{t("sagi.pet.browse")}
</a>
</div>
)}
<input
ref={imageInput}
type="file"
hidden
data-testid="pet-image-input"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={(e) => {
addFile(e.target.files?.[0], false);
e.target.value = "";
}}
/>
<input
ref={bundleInput}
type="file"
hidden
data-testid="pet-bundle-input"
accept=".zip,application/zip"
onChange={(e) => {
addFile(e.target.files?.[0], true);
e.target.value = "";
}}
/>
{error && (
<p role="alert" className="px-2.5 py-1 text-xs text-status-danger">
{error}
</p>
)}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
className={`${ITEM} !text-fg-muted`}
onClick={onSettings}
>
<Settings2 className="h-4 w-4" />
{t("sagi.pet.settings")}
</button>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
/**
* @file PetSprite.tsx
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
* animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review) — played one row at a time by Sagi's mood; an
* uploaded image is shown still and bobs with the same `data-mood` CSS as
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls
* back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { api } from "../../lib/api";
import type { Mood } from "./brain";
const CELL = { w: 192, h: 208 };
const SHEET = { cols: 8, rows: 9 };
/** Frames in each row of the Codex atlas. */
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
/** Which row plays for each mood. */
const ROW: Record<Mood, number> = {
idle: 0,
sleeping: 0,
thinking: 7, // running — an agent is working
watching: 8, // review
happy: 4, // jumping
stuck: 6, // waiting — for the user
worried: 5, // failed
disconnected: 5,
};
export function PetSprite({
id,
kind,
mood,
size,
reducedMotion,
onBroken,
}: {
id: string;
kind: "codex" | "image";
mood: Mood;
size: number;
reducedMotion: boolean;
onBroken: () => void;
}) {
const url = api.pets.spriteUrl(id);
const row = ROW[mood] ?? 0;
const [frame, setFrame] = useState(0);
useEffect(() => {
setFrame(0);
if (kind !== "codex" || reducedMotion) return;
const timer = window.setInterval(
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
mood === "sleeping" ? 400 : 125
);
return () => window.clearInterval(timer);
}, [kind, row, mood, reducedMotion]);
// A background image reports no error of its own: probe it once.
useEffect(() => {
if (kind !== "codex") return;
const probe = new Image();
probe.onerror = onBroken;
probe.src = url;
return () => {
probe.onerror = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, kind]);
if (kind === "image") {
return (
<img
src={url}
alt=""
data-mood={mood}
className="sagi-pet-image"
width={size}
height={size}
draggable={false}
onError={onBroken}
style={{ width: size, height: size, objectFit: "contain" }}
/>
);
}
const scale = size / CELL.h;
return (
<div
data-testid="pet-sprite"
data-row={row}
aria-hidden
style={{
width: Math.round(CELL.w * scale),
height: size,
margin: "0 auto",
backgroundImage: `url("${url}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
}}
/>
);
}
+97 -20
View File
@@ -8,8 +8,11 @@
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path reuses the existing Run page: unmatched Ask queries
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
* The "do the job" path: an unmatched Ask query starts a claude session in
* the "Sagi" workspace carrying it as the first prompt, then opens that
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -70,11 +73,14 @@ import {
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble";
import { SagiPanel } from "./SagiPanel";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
import { useSagiPosition } from "./useSagiPosition";
import { matchIntent } from "./intents";
import { sagiPrefs } from "./prefs";
import { api } from "../../lib/api";
@@ -83,6 +89,17 @@ import "./sagi.css";
const FLYOUT_GAP = 10; // px between avatar and flyout
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
/** True while focus sits where typing happens (an input, a terminal's textarea). */
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
return (
el.isContentEditable ||
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT"
);
}
interface Anchor {
left: number;
top: number;
@@ -92,7 +109,8 @@ interface Anchor {
dragging: boolean;
}
function usePrefersReducedMotion(): boolean {
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
@@ -114,7 +132,16 @@ function usePrefersReducedMotion(): boolean {
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
* into view instead of being cropped.
*/
function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
function SagiFlyout({
anchor,
children,
front = false,
}: {
anchor: Anchor;
children: ReactNode;
front?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
@@ -160,6 +187,7 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
// Mirrors the avatar button: eased left/top so the flyout glides along
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
data-dragging={anchor.dragging ? "1" : "0"}
data-front={front ? "1" : undefined}
style={style}
>
{children}
@@ -168,20 +196,36 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
}
export function Sagi() {
const { t } = useTranslation("settings");
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [pet, setPet] = useState(() => sagiPrefs.getPet());
const [size, setSize] = useState(() => sagiPrefs.getSize());
const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useSagiBrain();
const place = useSagiPosition();
const place = useSagiPosition(size);
// Keep enabled in sync with Settings / other tabs.
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
// Keep enabled, the pet and its size in sync with Settings / other tabs.
useEffect(
() =>
sagiPrefs.subscribe(() => {
setEnabled(sagiPrefs.getEnabled());
setPet(sagiPrefs.getPet());
setSize(sagiPrefs.getSize());
}),
[]
);
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
// shown, and never from an editable field, where Ctrl+B belongs to the field.
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
if (isEditable(e.target) || isEditable(document.activeElement)) return;
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === "Escape") {
@@ -190,7 +234,7 @@ export function Sagi() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}, [enabled]);
const onNavigate = useCallback(
(route: string) => {
@@ -206,7 +250,7 @@ export function Sagi() {
if (result.kind === "answer") return result.text;
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
// fresh claude session in it carrying the user's prompt as its first
// message, then jump to the Run page so the live pane is observable.
// message, then open that workspace's session view on the new pane.
// Fire-and-forget — the panel already clears its composer on null and
// closes; errors surface as an answer instead of silently swallowing.
const prompt = result.prompt;
@@ -214,24 +258,24 @@ export function Sagi() {
(async () => {
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
await api.run.start({
const run = await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: prompt,
permissionMode: "acceptEdits",
});
navigate(`/run?laneId=${lane.id}&autostart=1`);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
setOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
brain.setBubble(`couldn't start that session: ${msg}`);
brain.setBubble(t("sagi.startFailed", { error: msg }));
} finally {
brain.setThinking(false);
}
})();
return null;
},
[brain, navigate]
[brain, navigate, t]
);
if (!enabled) return null;
@@ -263,7 +307,21 @@ export function Sagi() {
</SagiFlyout>
)}
{!open && brain.bubble && (
{!place.dragging && menu && (
<SagiFlyout anchor={anchor} front>
<PetMenu
pet={pet}
size={size}
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/pet");
}}
/>
</SagiFlyout>
)}
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
</SagiFlyout>
@@ -272,21 +330,40 @@ export function Sagi() {
<button
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: SAGI_SIZE, height: SAGI_SIZE }}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
onKeyDown={place.onKeyDown}
onClick={() => {
// A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar.
if (place.consumeDrag()) return;
setOpen((v) => !v);
}}
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
setMenu(true);
}}
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
aria-expanded={open}
title="Sagi - ⌘B · drag to move"
title={t("sagi.avatarTitle")}
>
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
{pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
) : (
<PetSprite
id={pet.id}
kind={pet.kind}
mood={brain.mood}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
/>
)}
{brain.status.errorCount > 0 && (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
+27 -17
View File
@@ -63,6 +63,7 @@ import {
Radio,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SagiStatus } from "./brain";
interface SagiPanelProps {
@@ -85,6 +86,7 @@ export function SagiPanel({
onAsk,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
@@ -97,9 +99,9 @@ export function SagiPanel({
return (
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
role="dialog"
aria-label="Sagi companion"
aria-label={t("sagi.panel.label")}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
@@ -121,13 +123,13 @@ export function SagiPanel({
}`}
aria-hidden
/>
{status.connected ? "Live" : "Offline"}
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
</div>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label="Close Sagi"
aria-label={t("sagi.panel.close")}
>
<X size={15} />
</button>
@@ -138,19 +140,19 @@ export function SagiPanel({
<div className="mb-3 grid grid-cols-3 gap-1.5">
<StatChip
icon={Radio}
label="live"
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label="waiting"
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label="errored"
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
@@ -158,27 +160,35 @@ export function SagiPanel({
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/workspace")} />
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} />
<ActionButton
icon={Play}
label={t("sagi.panel.runClaude")}
onClick={() => onNavigate("/workspace")}
/>
<ActionButton
icon={Activity}
label={t("sagi.panel.activity")}
onClick={() => onNavigate("/activity")}
/>
<ActionButton
icon={LayoutList}
label="Sessions"
label={t("sagi.panel.sessions")}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={AlertTriangle}
label="Errored"
label={t("sagi.panel.errored")}
disabled={status.errorCount === 0}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? "Unmute" : "Mute"}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label="Clear alerts"
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
@@ -188,15 +198,15 @@ export function SagiPanel({
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder="Ask Sagi… (e.g. any errors?)"
placeholder={t("sagi.panel.askPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Ask Sagi"
aria-label={t("sagi.panel.ask")}
/>
<button
type="submit"
className="flex items-center justify-center rounded-lg bg-accent px-2.5 py-2 text-white transition-colors hover:bg-accent-hover"
aria-label="Send"
className="btn-primary justify-center px-2.5 py-2"
aria-label={t("sagi.panel.send")}
>
<Send size={14} />
</button>
+4 -1
View File
@@ -51,6 +51,8 @@
*
* ----------------------------------------------------------------------------- */
import { useTranslation } from "react-i18next";
/** Props for {@link SpeechBubble}. */
interface SpeechBubbleProps {
/** Quip text to display inside the bubble. */
@@ -64,13 +66,14 @@ interface SpeechBubbleProps {
* @param props See {@link SpeechBubbleProps}.
*/
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
const { t } = useTranslation("settings");
return (
<div
className="sagi-bubble sagi-bubble-enter"
role="status"
aria-live="polite"
onClick={onDismiss}
title="Dismiss"
title={t("sagi.bubbleDismiss")}
>
{text}
</div>
@@ -0,0 +1,264 @@
/**
* @file Pets.test.tsx
* @description The mascot's custom pets: `PetSprite` plays the spritesheet
* row of Sagi's mood and steps through its frames (a still image bobs instead),
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
* back to Sagi when it was the one worn), and uploads an image; Settings →
* Pet (`PetSettings`) does the same from a grid, plus show / bubbles / size.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const own = {
id: "p-aaaaaaaaaaaa",
displayName: "My cat",
description: "",
kind: "image",
source: "upload",
};
return {
...actual,
api: {
pets: {
spriteUrl: (id: string) => `/api/pets/${id}/sprite`,
list: vi.fn(async () => ({
pets: [own],
featured: [
{ id: "goku", displayName: "Goku", by: "hackminit", installed: false, page: "" },
],
sheet: { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 },
})),
installFeatured: vi.fn(async () => ({
pet: {
id: "cp-goku",
displayName: "Goku",
description: "",
kind: "codex",
source: "featured",
featuredId: "goku",
},
})),
upload: vi.fn(async () => ({
pet: {
id: "p-bbbbbbbbbbbb",
displayName: "Dog",
description: "",
kind: "image",
source: "upload",
},
})),
importBundle: vi.fn(),
remove: vi.fn(async () => ({ ok: true })),
},
},
};
});
import { api } from "../../../lib/api";
import { PetSprite } from "../PetSprite";
import { PetMenu } from "../PetMenu";
import { PetSettings } from "../../settings/PetSettings";
import { sagiPrefs } from "../prefs";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../../lib/toast";
/** Answer the pending confirmDialog (asserting one is open). */
function answerConfirm(ok: boolean) {
let pending: PendingConfirm | null = null;
subscribeConfirm((p) => (pending = p))();
expect(pending).not.toBeNull();
act(() => settleConfirm(ok));
}
describe("PetSprite", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("plays the row of the mood and steps through its frames", () => {
const { rerender } = render(
<PetSprite
id="cp-goku"
kind="codex"
mood="thinking"
size={104}
reducedMotion={false}
onBroken={() => {}}
/>
);
const sprite = screen.getByTestId("pet-sprite");
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
id="cp-goku"
kind="codex"
mood="worried"
size={104}
reducedMotion={false}
onBroken={() => {}}
/>
);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
size={208}
reducedMotion
onBroken={() => {}}
/>
);
act(() => vi.advanceTimersByTime(1000));
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
});
it("shows an uploaded image still, and reports one that will not load", () => {
const broken = vi.fn();
const { container } = render(
<PetSprite
id="p-aaaaaaaaaaaa"
kind="image"
mood="idle"
size={80}
reducedMotion={false}
onBroken={broken}
/>
);
const img = container.querySelector("img") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("/api/pets/p-aaaaaaaaaaaa/sprite");
fireEvent.error(img);
expect(broken).toHaveBeenCalled();
});
});
describe("PetMenu", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
const renderMenu = () =>
render(<PetMenu pet={sagiPrefs.getPet()} size={68} onClose={() => {}} onSettings={() => {}} />);
it("hides the mascot and sizes it", () => {
renderMenu();
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
expect(sagiPrefs.getSize()).toBe(250);
let toasts: ToastItem[] = [];
const off = subscribeToasts((i) => (toasts = i));
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
// Says where to bring it back.
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
off();
});
it("downloads a featured pet on its first pick, then wears it", async () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
});
it("deleting the pet being worn puts Sagi back", async () => {
sagiPrefs.setPet({ id: "p-aaaaaaaaaaaa", kind: "image" });
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
// Asks first: nothing is deleted on one click.
expect(api.pets.remove).not.toHaveBeenCalled();
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
it("uploads an image and wears it", async () => {
renderMenu();
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "Dog.png", {
type: "image/png",
});
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
);
expect(api.pets.upload).toHaveBeenCalledWith(file);
});
});
describe("PetSettings", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it("shows, mutes and sizes the mascot, following the prefs live", async () => {
render(<PetSettings />);
fireEvent.change(screen.getByTestId("pet-settings-size"), { target: { value: "120" } });
expect(sagiPrefs.getSize()).toBe(120);
expect(screen.getByText("120px")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: /Speech bubbles/ }));
expect(sagiPrefs.getMuted()).toBe(true);
fireEvent.click(screen.getByRole("switch", { name: /Show Sagi/ }));
expect(sagiPrefs.getEnabled()).toBe(false);
// A change made elsewhere (the right-click menu) shows here at once.
act(() => sagiPrefs.setSize(200));
expect(screen.getByText("200px")).toBeInTheDocument();
});
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
it("uploads an image and wears it", async () => {
render(<PetSettings />);
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
);
});
it("a cancelled delete keeps the pet", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(false);
await act(async () => {});
expect(api.pets.remove).not.toHaveBeenCalled();
});
it("disables size and bubbles while hidden, and resets to defaults", async () => {
sagiPrefs.setEnabled(false);
sagiPrefs.setSize(200);
sagiPrefs.setMuted(true);
render(<PetSettings />);
expect(screen.getByTestId("pet-settings-size")).toBeDisabled();
expect(screen.getByRole("switch", { name: /Speech bubbles/ })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /Reset to defaults/ }));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getEnabled()).toBe(true));
expect(sagiPrefs.getSize()).toBe(68);
expect(sagiPrefs.getMuted()).toBe(false);
expect(sagiPrefs.getPet()).toEqual({ id: "sagi" });
});
});
@@ -147,4 +147,48 @@ describe("Sagi widget", () => {
// Left-docked → inline left equals the edge margin (16px).
expect(btn.style.left).toBe("16px");
});
it("Ctrl/Cmd+B is ignored while typing in a field", () => {
renderSagi();
const ta = document.createElement("textarea");
document.body.appendChild(ta);
ta.focus();
act(() => {
fireEvent.keyDown(ta, { key: "b", ctrlKey: true });
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
ta.remove();
});
it("Ctrl/Cmd+B is left to the browser while the mascot is hidden", () => {
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
const ev = new KeyboardEvent("keydown", { key: "b", ctrlKey: true, cancelable: true });
window.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
});
it("docks bottom-right above the status bar by default", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
const top = parseFloat(btn.style.top);
const size = parseFloat(btn.style.width);
// Bottom edge sits above the 24px status bar plus the edge margin.
expect(top + size).toBe(window.innerHeight - 16 - 24);
});
it("arrow keys move the focused avatar and persist it", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
const before = parseFloat(btn.style.top);
act(() => {
fireEvent.keyDown(btn, { key: "ArrowUp" });
});
expect(parseFloat(btn.style.top)).toBeLessThan(before);
act(() => {
fireEvent.keyDown(btn, { key: "ArrowLeft" });
});
expect(btn.style.left).toBe("16px");
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
});
});
+66 -3
View File
@@ -1,8 +1,9 @@
/**
* @file prefs.ts
* @description Tiny localStorage-backed preference store for Sagi (enabled +
* muted). Broadcasts changes via a window CustomEvent so the Settings toggle
* and the live widget stay in sync within the same tab without a reload.
* @description Tiny localStorage-backed preference store for Sagi (enabled,
* muted, the pet it wears and its size). Broadcasts changes via a window
* CustomEvent so the Settings toggle and the live widget stay in sync within
* the same tab without a reload.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -53,6 +54,54 @@
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
const MUTED_KEY = "agent-dashboard-sagi-muted";
const POS_KEY = "agent-dashboard-sagi-pos";
const PET_KEY = "agenthub.pet";
const SIZE_KEY = "agenthub.pet.size";
/** Sagi's own size, and the slider's range. */
export const PET_SIZE = { default: 68, min: 48, max: 250 };
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
* The kind rides along so the mascot renders without a fetch. */
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
const SAGI: PetChoice = { id: "sagi" };
function readPet(): PetChoice {
try {
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
id: string;
kind: string;
}> | null;
if (
p &&
typeof p.id === "string" &&
p.id !== "sagi" &&
(p.kind === "codex" || p.kind === "image")
)
return { id: p.id, kind: p.kind };
} catch {
/* unreadable: Sagi */
}
return SAGI;
}
function readSize(): number {
const n = Number(localStorage.getItem(SIZE_KEY));
return Number.isFinite(n) && n > 0
? Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, n)))
: PET_SIZE.default;
}
function writeAndNotify(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Best-effort, like every pref here.
}
try {
window.dispatchEvent(new CustomEvent(EVENT));
} catch {
// SSR / non-DOM contexts: nothing to notify.
}
}
const EVENT = "sagi:prefs";
/**
@@ -118,6 +167,20 @@ export const sagiPrefs = {
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
getPos: readPos,
setPos: writePos,
getPet: readPet,
setPet: (pet: PetChoice) => writeAndNotify(PET_KEY, JSON.stringify(pet)),
getSize: () => {
try {
return readSize();
} catch {
return PET_SIZE.default;
}
},
setSize: (px: number) =>
writeAndNotify(
SIZE_KEY,
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
),
/** Subscribe to any pref change; returns an unsubscribe fn. */
subscribe(handler: () => void): () => void {
const listener = () => handler();
+34
View File
@@ -369,3 +369,37 @@
.sagi-bubble.sagi-no-anim {
animation: none;
}
/* ════════ a custom pet (PetSprite.tsx) ════════ */
/* An uploaded still image has no frames of its own: it bobs, faster while an
agent works and slower asleep. A Codex spritesheet animates by its frames. */
.sagi-pet-image {
animation: sagi-pet-bob 2.6s ease-in-out infinite;
-webkit-user-drag: none;
}
.sagi-pet-image[data-mood="thinking"] {
animation-duration: 0.9s;
}
.sagi-pet-image[data-mood="sleeping"] {
animation-duration: 5s;
}
@keyframes sagi-pet-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4%);
}
}
@media (prefers-reduced-motion: reduce) {
.sagi-pet-image {
animation: none;
}
}
/* The pet menu may overlap the mascot; it sits above it while open. */
.sagi-flyout[data-front="1"] {
z-index: 42;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* @file usePets — the pet list and its actions, shared by the mascot's
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
* a featured pet (downloaded on its first pick), uploading an image or a
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
* (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size /
* shown / speech-bubble prefs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { api, type PetList, type PetView } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import i18n from "../../i18n";
import { sagiPrefs, type PetChoice } from "./prefs";
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
export function usePets() {
const [list, setList] = useState<PetList | null>(null);
// Which action is running: a featured id, a pet id, "image" or "bundle".
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(
() =>
api.pets
.list()
.then(setList)
.catch((err: unknown) => setError(message(err))),
[]
);
useEffect(() => {
void reload();
}, [reload]);
const run = async (key: string, work: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await work();
} catch (err) {
setError(message(err));
} finally {
setBusy(null);
}
};
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
const install = (featuredId: string) =>
run(featuredId, async () => {
wear((await api.pets.installFeatured(featuredId)).pet);
await reload();
});
const add = (file: File, bundle: boolean) =>
run(bundle ? "bundle" : "image", async () => {
const { pet } = bundle ? await api.pets.importBundle(file) : await api.pets.upload(file);
wear(pet);
await reload();
});
// One place both callers route through, so neither can delete on one click.
const remove = async (p: PetView) => {
const ok = await confirmDialog({
title: i18n.t("settings:sagi.pet.deleteTitle", { name: p.displayName }),
message: i18n.t("settings:sagi.pet.deleteMessage"),
confirmLabel: i18n.t("settings:sagi.pet.deleteConfirm"),
});
if (!ok) return;
await run(p.id, async () => {
await api.pets.remove(p.id);
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
await reload();
});
};
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
// Featured id → the installed copy, once downloaded.
const installed = new Map(
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
);
return { list, own, installed, busy, error, wear, install, add, remove };
}
type Worn = { pet: PetChoice; size: number; shown: boolean; bubbles: boolean };
const readWorn = (): Worn => ({
pet: sagiPrefs.getPet(),
size: sagiPrefs.getSize(),
shown: sagiPrefs.getEnabled(),
bubbles: !sagiPrefs.getMuted(),
});
/** The worn pet and the mascot's prefs, following every change (any tab). */
export function useWornPet(): Worn {
const [worn, setWorn] = useState(readWorn);
useEffect(() => sagiPrefs.subscribe(() => setWorn(readWorn())), []);
return worn;
}
+81 -41
View File
@@ -5,7 +5,10 @@
* keeps tracking even if the cursor outruns it), and on release snaps to the
* nearest left/right edge, remembering its vertical offset (persisted as a
* viewport fraction so it survives resizes). A small movement threshold tells
* a drag apart from a tap so dragging never opens the panel.
* a drag apart from a tap so dragging never opens the panel. The arrow keys
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
* It never rests over the fixed bottom status bar; by default it docks
* bottom-right just above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -70,24 +73,29 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { sagiPrefs, type SagiPos } from "./prefs";
import type { PointerEvent as ReactPointerEvent } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
export const SAGI_SIZE = 68;
export const SAGI_MARGIN = 16;
const DRAG_THRESHOLD = 5;
// The fixed bottom StatusBar (`h-6`); the avatar never rests over it.
const STATUS_BAR = 24;
// One arrow-key step, in px.
const KEY_STEP = 40;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
function defaultPos(): SagiPos {
return { side: "right", y: 0.5 }; // right edge, vertically centered
// Bottom-right, above the status bar: a centred dock covered row controls.
return { side: "right", y: 1 };
}
/** Resting top-left screen coords for a docked position. */
function restingScreen(pos: SagiPos) {
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
function restingScreen(pos: SagiPos, size: number) {
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
const top = SAGI_MARGIN + pos.y * avail;
return { left, top };
}
@@ -106,9 +114,12 @@ export interface SagiPlacement {
onPointerUp: (e: ReactPointerEvent) => void;
/** Returns true (once) if a drag just ended, so the click handler can skip. */
consumeDrag: () => boolean;
/** Arrow keys on the focused avatar: ←/→ dock to an edge, ↑/↓ step. */
onKeyDown: (e: ReactKeyboardEvent) => void;
}
export function useSagiPosition(): SagiPlacement {
/** `size`: the avatar's current footprint (the pet size pref), in px. */
export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
const [, force] = useState(0); // re-derive resting coords on resize
@@ -127,7 +138,7 @@ export function useSagiPosition(): SagiPlacement {
return () => window.removeEventListener("resize", onResize);
}, []);
const resting = restingScreen(pos);
const resting = restingScreen(pos, size);
const screen = drag ?? resting;
const onPointerDown = useCallback(
@@ -146,40 +157,68 @@ export function useSagiPosition(): SagiPlacement {
[screen.left, screen.top]
);
const onPointerMove = useCallback((e: ReactPointerEvent) => {
const start = startRef.current;
if (!start) return;
const dx = e.clientX - start.px;
const dy = e.clientY - start.py;
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true;
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
liveRef.current = { left, top };
setDrag({ left, top });
}, []);
const onPointerMove = useCallback(
(e: ReactPointerEvent) => {
const start = startRef.current;
if (!start) return;
const dx = e.clientX - start.px;
const dy = e.clientY - start.py;
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true;
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
const top = Math.min(
vh() - size - SAGI_MARGIN - STATUS_BAR,
Math.max(SAGI_MARGIN, start.top + dy)
);
liveRef.current = { left, top };
setDrag({ left, top });
},
[size]
);
const onPointerUp = useCallback((e: ReactPointerEvent) => {
try {
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
} catch {
/* ignore */
}
const live = liveRef.current;
if (live) {
draggedRef.current = true;
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y };
const onPointerUp = useCallback(
(e: ReactPointerEvent) => {
try {
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
} catch {
/* ignore */
}
const live = liveRef.current;
if (live) {
draggedRef.current = true;
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y };
sagiPrefs.setPos(next);
setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over
}
liveRef.current = null;
startRef.current = null;
movedRef.current = false;
},
[size]
);
const onKeyDown = useCallback(
(e: ReactKeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
let next: SagiPos | null = null;
if (e.key === "ArrowLeft") next = { ...pos, side: "left" };
else if (e.key === "ArrowRight") next = { ...pos, side: "right" };
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const dy = (e.key === "ArrowUp" ? -KEY_STEP : KEY_STEP) / avail;
next = { ...pos, y: Math.min(1, Math.max(0, pos.y + dy)) };
}
if (!next) return;
e.preventDefault();
sagiPrefs.setPos(next);
setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over
}
liveRef.current = null;
startRef.current = null;
movedRef.current = false;
}, []);
},
[pos, size]
);
const consumeDrag = useCallback(() => {
const was = draggedRef.current;
@@ -190,13 +229,14 @@ export function useSagiPosition(): SagiPlacement {
return {
left: screen.left,
top: screen.top,
size: SAGI_SIZE,
size,
side: pos.side,
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
openUp: screen.top + size / 2 > vh() / 2,
dragging: drag !== null,
onPointerDown,
onPointerMove,
onPointerUp,
consumeDrag,
onKeyDown,
};
}
+132 -52
View File
@@ -2,9 +2,17 @@
* @file Select.tsx
* @description Generic styled dropdown that replaces native `<select>` elements
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
* checkmark space inconsistently; this control aligns labels, supports arrow-key
* navigation, flips above the trigger when viewport space is tight, and marks
* the active option with accent color plus a Lucide check.
* checkmark space inconsistently; this control aligns labels, flips above the
* trigger when viewport space is tight, and marks the chosen option with accent
* color plus a Lucide check.
*
* ## Accessibility — WAI-ARIA "listbox button"
* The trigger is a `<button aria-haspopup="listbox" aria-expanded>`; opening it
* moves focus to a `role="listbox"` popup that tracks the highlighted
* `role="option"` through `aria-activedescendant`. Keys: ArrowUp/Down, Home/End,
* Enter/Space pick, Escape cancels, Tab closes, and a typed character jumps to
* the next option whose label starts with it. Focus returns to the trigger on
* every close except Tab.
*
* ## Consumers
* Used on the Run Claude page and webhook settings form wherever a compact
@@ -63,7 +71,7 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
/** Single option in a {@link Select} list. */
@@ -74,6 +82,8 @@ export interface SelectOption<T extends string> {
label: string;
/** Optional secondary line (smaller gray text). */
hint?: string;
/** Optional leading icon, shown in the trigger and the list. */
icon?: ReactNode;
}
/** Props for {@link Select}. */
@@ -86,23 +96,34 @@ export interface SelectProps<T extends string> {
options: SelectOption<T>[];
/** Disables opening the list. */
disabled?: boolean;
/** `ghost`: a borderless, content-wide trigger for a composer's toolbar. */
variant?: "field" | "ghost";
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
label?: string;
}
/**
* Accessible custom select control.
* @typeParam T - string union of allowed option values.
*/
export function Select<T extends string>({ value, onChange, options, disabled }: SelectProps<T>) {
export function Select<T extends string>({
value,
onChange,
options,
disabled,
variant = "field",
label,
}: SelectProps<T>) {
const ghost = variant === "ghost";
const [open, setOpen] = useState(false);
const [active, setActive] = useState(() =>
Math.max(
0,
options.findIndex((o) => o.value === value)
)
);
const [active, setActive] = useState(0);
const [openUp, setOpenUp] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const typed = useRef({ text: "", at: 0 });
const id = useId();
const optionId = (idx: number) => `${id}-opt-${idx}`;
useEffect(() => {
if (!open) return;
@@ -114,73 +135,127 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
return () => document.removeEventListener("mousedown", onClick);
}, [open]);
// When opening, decide whether to render the popover above the trigger if
// the viewport doesn't have room below (common when this select sits at
// the bottom of a form). 288 px = `max-h-72`.
// On open: highlight the chosen option, decide whether the popover goes
// above the trigger (288 px = `max-h-72`), and move focus into the list.
useEffect(() => {
if (!open || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const below = window.innerHeight - rect.bottom;
const above = rect.top;
setOpenUp(below < 288 && above > below);
if (!open) return;
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const below = window.innerHeight - rect.bottom;
setOpenUp(below < 288 && rect.top > below);
}
listRef.current?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
}, [open]);
// Sync active highlight with current value when reopening
useEffect(() => {
if (open) {
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
}
}, [open, value, options]);
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
}, [open, active]);
const close = (refocus = true) => {
setOpen(false);
if (refocus) buttonRef.current?.focus();
};
const choose = (opt: SelectOption<T>) => {
onChange(opt.value);
setOpen(false);
buttonRef.current?.focus();
close();
};
const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (
!open &&
(e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ")
) {
const onButtonKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
setOpen(true);
return;
}
if (e.key === "ArrowDown") {
};
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const last = options.length - 1;
const move: Record<string, (a: number) => number> = {
ArrowDown: (a) => Math.min(last, a + 1),
ArrowUp: (a) => Math.max(0, a - 1),
Home: () => 0,
End: () => last,
PageDown: (a) => Math.min(last, a + 10),
PageUp: (a) => Math.max(0, a - 10),
};
if (move[e.key]) {
e.preventDefault();
setActive((a) => Math.min(options.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
setActive(move[e.key]!);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const opt = options[active];
if (opt) choose(opt);
} else if (e.key === "Escape") {
setOpen(false);
// Stop here: a Select inside a dialog must not close the dialog too.
e.preventDefault();
e.stopPropagation();
close();
} else if (e.key === "Tab") {
close(false);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
// character cycles through the options that start with it.
const now = Date.now();
const t = typed.current;
t.text = now - t.at > 500 ? e.key.toLowerCase() : t.text + e.key.toLowerCase();
t.at = now;
const cycling = t.text.length > 1 && [...t.text].every((c) => c === t.text[0]);
const prefix = cycling ? t.text[0]! : t.text;
const start = cycling || t.text.length === 1 ? active + 1 : active;
for (let i = 0; i < options.length; i++) {
const idx = (start + i) % options.length;
if (options[idx]!.label.toLowerCase().startsWith(prefix)) {
setActive(idx);
break;
}
}
}
};
const current = options.find((o) => o.value === value) || options[0];
return (
<div ref={containerRef} className="relative">
<div ref={containerRef} className={ghost ? "relative inline-block max-w-[11rem]" : "relative"}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKey}
className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
aria-label={label}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? `${id}-list` : undefined}
title={label}
onClick={() => (open ? close() : setOpen(true))}
onKeyDown={onButtonKey}
className={
ghost
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
}
>
<span className="truncate">{current?.label ?? "-"}</span>
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<span className="flex min-w-0 items-center gap-2">
{current?.icon}
<span className="truncate">{current?.label ?? "-"}</span>
</span>
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" aria-hidden />
</button>
{open && (
<div
className={`absolute z-30 left-0 right-0 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={options[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
@@ -188,17 +263,20 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
const isSelected = opt.value === value;
const isActive = idx === active;
return (
<button
<div
key={opt.value || "__default__"}
type="button"
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full text-left px-3 py-1.5 transition-colors ${
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
@@ -206,12 +284,14 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
>
{opt.label}
</span>
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</button>
</div>
);
})}
</div>
+100 -90
View File
@@ -65,9 +65,11 @@ import {
Bot,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useTranslation } from "react-i18next";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { fmt, formatDuration } from "../lib/format";
import { fmt, formatDuration, parseDate } from "../lib/format";
import { effectiveSessionStatus } from "../lib/types";
import { styleForTool } from "./conversation/toolStyle";
import type { Agent, Session, SessionStats } from "../lib/types";
@@ -76,8 +78,8 @@ interface SessionOverviewProps {
agents: Agent[];
}
/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */
const REFRESH_DEBOUNCE_MS = 600;
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
const REFRESH_THROTTLE_MS = 1000;
/** Compact tile used in the top stat row. */
function StatTile({
@@ -93,24 +95,25 @@ function StatTile({
icon: React.ReactNode;
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) {
// Theme tokens only: status colours for states, syntax hues for categories.
const palette = {
default: "border-surface-3 bg-surface-2 text-fg-secondary",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
violet: "border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword",
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
rose: "border-status-danger/20 bg-status-danger/5 text-status-danger",
cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property",
blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin",
}[tone];
const iconTone = {
default: "text-fg-muted",
violet: "text-violet-400",
violet: "text-syntax-keyword",
emerald: "text-status-success",
amber: "text-status-warning",
rose: "text-rose-400",
cyan: "text-cyan-400",
blue: "text-blue-500",
rose: "text-status-danger",
cyan: "text-syntax-property",
blue: "text-syntax-builtin",
}[tone];
return (
@@ -158,8 +161,8 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
}
export function SessionOverview({ session, agents }: SessionOverviewProps) {
const { t } = useTranslation("sessions");
const [stats, setStats] = useState<SessionStats | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchingRef = useRef(false);
// Tick clock every 30s so a still-active session's "duration" tile stays current.
@@ -170,8 +173,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
return () => window.clearInterval(id);
}, [session.status]);
// A refresh asked for while one is in flight is not dropped: it runs once
// when the current one settles (that is the throttle's trailing call).
const pendingRef = useRef(false);
const fetchStats = async () => {
if (fetchingRef.current) return;
if (fetchingRef.current) {
pendingRef.current = true;
return;
}
fetchingRef.current = true;
try {
const result = await api.sessions.stats(session.id);
@@ -180,6 +189,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Non-fatal - overview just won't update this round.
} finally {
fetchingRef.current = false;
if (pendingRef.current) {
pendingRef.current = false;
void fetchStats();
}
}
};
@@ -189,47 +202,29 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
// Live refresh on websocket events (debounced).
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg) => {
if (isRemoteDataRefreshMessage(msg)) {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
fetchStats();
}, REFRESH_DEBOUNCE_MS);
return;
}
const isRelevant =
msg.type === "new_event" ||
msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "session_updated";
if (!isRelevant) return;
// Live refresh on this session's websocket events, throttled (a steady
// stream still refreshes) and again after a reconnect.
useLiveRefresh(() => void fetchStats(), {
throttleMs: REFRESH_THROTTLE_MS,
match: (msg) => {
if (isRemoteDataRefreshMessage(msg)) return true;
if (
msg.type !== "new_event" &&
msg.type !== "agent_created" &&
msg.type !== "agent_updated" &&
msg.type !== "session_updated"
)
return false;
const data = msg.data as { session_id?: string; id?: string };
// Match either by session_id (events) or by id (session_updated)
const matchesSession = data.session_id === session.id || data.id === session.id;
if (!matchesSession) return;
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
fetchStats();
}, REFRESH_DEBOUNCE_MS);
});
return () => {
unsubscribe();
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
return data.session_id === session.id || data.id === session.id;
},
});
// Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name).
// We approximate "tool calls" as half of that (each call produces a Pre + Post event).
const toolCallCount = useMemo(() => {
if (!stats) return 0;
const total = stats.tools_used.reduce((s, t) => s + t.count, 0);
return Math.round(total / 2);
}, [stats]);
// Exact totals from the server (`tools_used` is only the top 15). An older
// server lacks them: fall back to the visible list's count.
const toolCallCount = stats?.tool_call_count ?? 0;
const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0;
const maxToolCount = useMemo(() => {
if (!stats) return 0;
@@ -249,11 +244,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Avg event rate (events / minute)
const eventRate = useMemo(() => {
if (!stats || !session.started_at) return null;
const start = new Date(session.started_at).getTime();
const start = parseDate(session.started_at).getTime();
const end = session.ended_at
? new Date(session.ended_at).getTime()
? parseDate(session.ended_at).getTime()
: stats.last_event_at
? new Date(stats.last_event_at).getTime()
? parseDate(stats.last_event_at).getTime()
: now;
const minutes = Math.max(1, (end - start) / 60_000);
return stats.total_events / minutes;
@@ -287,11 +282,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
</span>
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
<span className="text-xs text-status-success font-medium flex-shrink-0">
{activeAgent.name || "Agent"}
{activeAgent.name || t("overview.agent")}
</span>
{activeAgent.current_tool && (
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
<span className="text-fg-muted">running</span>
<span className="text-fg-muted">{t("overview.running")}</span>
<span className="text-status-success">{activeAgent.current_tool}</span>
</span>
)}
@@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{/* Stat tiles */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<StatTile
label="Events"
label={t("overview.events")}
value={stats.total_events.toLocaleString()}
hint={
eventRate !== null && eventRate > 0
? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min`
? t("overview.perMin", {
rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate),
})
: undefined
}
icon={<Activity className="w-3 h-3" />}
/>
<StatTile
label="Tool calls"
label={t("overview.toolCalls")}
value={toolCallCount.toLocaleString()}
hint={stats.tools_used.length > 0 ? `${stats.tools_used.length} unique` : undefined}
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
icon={<Wrench className="w-3 h-3" />}
tone="violet"
/>
<StatTile
label="Subagents"
label={t("overview.subagents")}
value={stats.agents.subagent.toLocaleString()}
hint={stats.agents.main > 0 ? `+${stats.agents.main} main` : undefined}
hint={
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
}
icon={<GitBranch className="w-3 h-3" />}
tone="cyan"
/>
<StatTile
label="Compactions"
label={t("overview.compactions")}
value={stats.agents.compaction.toLocaleString()}
icon={<Layers className="w-3 h-3" />}
tone="blue"
/>
<StatTile
label="Errors"
label={t("overview.errors")}
value={stats.error_count.toLocaleString()}
icon={<AlertTriangle className="w-3 h-3" />}
tone={stats.error_count > 0 ? "rose" : "default"}
/>
<StatTile
label="Duration"
label={t("overview.duration")}
value={durationLabel}
hint={session.status === "active" ? "running" : "completed"}
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
defaultValue: effectiveSessionStatus(session),
})}
icon={<Clock className="w-3 h-3" />}
tone={session.status === "active" ? "emerald" : "default"}
/>
@@ -359,15 +360,17 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-violet-400" />
Top tools
<Wrench className="w-3.5 h-3.5 text-syntax-keyword" />
{t("overview.topTools")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">
{stats.tools_used.length} total
{t("overview.distinctTotal", { count: distinctTools })}
</span>
</div>
{stats.tools_used.length === 0 ? (
<div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
<div className="text-center py-6 text-xs text-fg-muted">
{t("overview.noToolCalls")}
</div>
) : (
<div className="space-y-1.5">
{stats.tools_used.slice(0, 8).map((t) => (
@@ -406,7 +409,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
if (stats.agents.compaction > 0) {
rows.push({
key: "__compaction__",
label: "Context Compaction",
label: t("overview.contextCompaction"),
count: stats.agents.compaction,
isCompaction: true,
});
@@ -418,20 +421,24 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
Subagents
<GitBranch className="w-3.5 h-3.5 text-syntax-property" />
{t("overview.subagents")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
<span className="text-[10px] text-fg-muted font-mono">
{t("overview.runs", { count: totalRuns })}
</span>
</div>
{rows.length === 0 ? (
<div className="text-center py-6 text-xs text-fg-muted">
No subagents in this session.
{t("overview.noSubagents")}
</div>
) : (
<div className="space-y-1.5">
{rows.slice(0, 8).map((r) => {
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60";
const barClass = r.isCompaction
? "bg-status-warning/60"
: "bg-syntax-property/60";
return (
<div key={r.key} className="flex items-center gap-2">
<span
@@ -468,9 +475,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="flex items-center justify-between mb-2.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-status-warning" />
Token flow
{t("overview.tokenFlow")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
<span className="text-[10px] text-fg-muted font-mono">
{t("overview.total", { value: fmt(totalTokens) })}
</span>
</div>
<TokenFlowBar tokens={tokens} total={totalTokens} />
</div>
@@ -481,7 +490,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
Event mix
{t("overview.eventMix")}
</h3>
<div className="flex flex-wrap gap-1.5">
{stats.events_by_type.slice(0, 12).map((e) => (
@@ -502,34 +511,35 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
}
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
const { t } = useTranslation();
const segments = [
{
key: "cache_read",
label: "Cache read",
label: t("common:token.cacheRead"),
value: tokens.cache_read_tokens,
cls: "bg-sky-500",
text: "text-sky-300",
cls: "bg-syntax-builtin",
text: "text-syntax-builtin",
},
{
key: "cache_write",
label: "Cache write",
label: t("common:token.cacheWrite"),
value: tokens.cache_write_tokens,
cls: "bg-violet-500",
text: "text-violet-300",
cls: "bg-syntax-keyword",
text: "text-syntax-keyword",
},
{
key: "input",
label: "Input",
label: t("common:token.input"),
value: tokens.input_tokens,
cls: "bg-status-success",
text: "text-status-success",
},
{
key: "output",
label: "Output",
label: t("common:token.output"),
value: tokens.output_tokens,
cls: "bg-orange-500",
text: "text-orange-300",
cls: "bg-syntax-number",
text: "text-syntax-number",
},
];
+63
View File
@@ -0,0 +1,63 @@
/**
* @file SessionRowMenu — the right-click menu of a session anywhere outside a
* workspace's own lists (the Sessions table, the Dashboard's session and agent
* cards): open its analysis page, open it in the workspace that owns it
* (`sessions.lane_id` — an ended session opens its conversation there, never
* a resume), copy its id or folder. Inside a workspace, `lanes/SessionMenu`
* is used instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu";
import { copyText } from "../lib/clipboard";
export type SessionRowRef = { id: string; lane_id?: number | null; cwd?: string | null };
/**
* Where "Open in workspace" goes for a session: its live pane when a dashboard
* run holds it (`&run=`), else its workspace's replay (`&session=`); null when
* no workspace owns it.
*/
export function workspaceHref(
session: SessionRowRef,
runs: ReadonlyArray<{ id: string; laneId: number | null; sessionId: string | null }> = []
): string | null {
const run = runs.find((r) => r.sessionId === session.id && r.laneId != null);
if (run) return `/workspace?lane=${run.laneId}&run=${encodeURIComponent(run.id)}`;
if (session.lane_id != null)
return `/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`;
return null;
}
export function SessionRowMenu({
session,
x,
y,
onClose,
}: MenuPoint & { session: SessionRowRef; onClose: () => void }) {
const { t } = useTranslation("sessions");
const navigate = useNavigate();
const pick = (fn: () => unknown) => () => {
onClose();
fn();
};
const copy = (text: string) => pick(() => void copyText(text));
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
{t("menu.details")}
</MenuItem>
{session.lane_id != null && (
<MenuItem onSelect={pick(() => navigate(workspaceHref(session)!))}>
{t("menu.inWorkspace")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={copy(session.id)}>{t("menu.copyId")}</MenuItem>
{session.cwd && <MenuItem onSelect={copy(session.cwd)}>{t("menu.copyFolder")}</MenuItem>}
</ContextMenu>
);
}
@@ -0,0 +1,30 @@
/**
* @file SessionToolBadge — names the AI coding CLI that produced a session
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
* list and session header look as before for every Claude row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useAiTools } from "../hooks/useAiTools";
/** A chip with the tool's name, or nothing for `claude` / an unset tool. */
export function SessionToolBadge({ tool }: { tool?: string | null }) {
if (!tool || tool === "claude") return null;
return <ToolChip tool={tool} />;
}
// Split out so the catalog hook only mounts for a non-Claude session.
function ToolChip({ tool }: { tool: string }) {
const { t } = useTranslation("sessions");
const { label } = useAiTools();
return (
<span
className="inline-flex items-center text-[10px] font-semibold text-violet-700 dark:text-violet-300 bg-violet-500/10 border border-violet-500/25 px-1.5 py-0.5 rounded-full"
title={t("toolBadgeTitle", { tool: label(tool) })}
>
{label(tool)}
</span>
);
}
+213 -297
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description Defines the Sidebar component that provides navigation links to different sections of the application, displays the connection status, and includes a toggle button for collapsing or expanding the sidebar. The component uses React Router's NavLink for navigation and Lucide icons for visual representation. The collapsed state of the sidebar is stored in localStorage to persist user preferences across sessions.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -51,19 +51,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useDialog } from "../hooks/useDialog";
import {
LayoutDashboard,
FolderOpen,
Activity,
BarChart3,
Workflow,
Play,
Settings,
Wifi,
WifiOff,
PanelLeftClose,
PanelLeftOpen,
Languages,
RefreshCw,
X,
Plug,
@@ -71,13 +68,12 @@ import {
Gauge,
ChevronUp,
ChevronDown,
Sun,
Moon,
FolderTree,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useTheme } from "../hooks/useTheme";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
@@ -87,16 +83,18 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
{ to: "/workspace", icon: Play, key: "nav:workspace" },
{ to: "/settings", icon: Settings, key: "nav:settings" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
// owned by the brand, not vendored here — a machine with no network shows the
// alt text instead.
const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
const SUPPORTED_LANGUAGES = ["en", "vi"] as const;
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
interface PersistedStats {
eventCount: number;
@@ -106,12 +104,18 @@ interface PersistedStats {
recentEvents: { type: string; at: number }[];
}
/** Below Tailwind's `lg` a full 15rem sidebar eats too much of the page. */
const NARROW_QUERY = "(max-width: 1023.98px)";
/** The stored choice, else collapsed on a narrow viewport. */
function loadCollapsed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === "true";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) return stored === "true";
} catch {
return false;
/* storage off: fall through to the viewport */
}
return window.matchMedia?.(NARROW_QUERY).matches ?? false;
}
function loadStats(): PersistedStats {
@@ -155,23 +159,32 @@ function loadStats(): PersistedStats {
}
}
function normalizeLanguage(language: string): SupportedLanguage {
const base = language.toLowerCase().split("-")[0];
if (base === "vi" || base === "en") {
return base;
}
return "en";
}
interface SidebarProps {
wsConnected: boolean;
collapsed: boolean;
onToggle: () => void;
/** The connection details modal, opened from here or from the status bar. */
connectionOpen: boolean;
onConnectionOpenChange: (open: boolean) => void;
/** Told whether a newer release is out, for the status bar's dot. */
onUpdateAvailableChange?: (available: boolean) => void;
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
drawer?: { open: boolean; onClose: () => void };
}
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, setTheme, toggleTheme } = useTheme();
export function Sidebar({
wsConnected,
collapsed,
onToggle,
connectionOpen,
onConnectionOpenChange,
onUpdateAvailableChange,
drawer,
}: SidebarProps) {
const { t } = useTranslation();
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle).
@@ -180,7 +193,6 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const [updateStatus, setUpdateStatus] = useState<UpdateStatusPayload | null>(null);
const [checking, setChecking] = useState(false);
const [checkError, setCheckError] = useState(false);
const [statusModalOpen, setStatusModalOpen] = useState(false);
const [connectedSince, setConnectedSince] = useState<number | null>(
wsConnected ? Date.now() : null
);
@@ -361,57 +373,81 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
? t("nav:checkingForUpdates")
: checkError
? t("nav:checkFailed")
: updateAvailable
? updateStatus?.latest_release
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
: t("nav:updateAvailable")
: updateStatus?.dev_build
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
: updateStatus
? t("nav:upToDate")
: t("nav:checkForUpdates");
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
const switchLanguageTitle = t("nav:switchLanguage", {
language: t(`nav:languageNames.${nextLanguage}`),
});
const nextTheme = theme === "dark" ? "light" : "dark";
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
: updateStatus?.installed_not_running
? t("nav:installedNotRunning", {
version: updateStatus.installed_not_running.version ?? "?",
})
: updateAvailable
? updateStatus?.latest_release
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
: t("nav:updateAvailable")
: updateStatus?.dev_build
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
: // A plugin install has no commit count to fall back on: a release
// that could not be read is "unknown", never "up to date".
updateStatus?.install_kind === "plugin" &&
updateStatus.release_check &&
updateStatus.release_check !== "ok"
? t("nav:releaseUnchecked")
: updateStatus
? t("nav:upToDate")
: t("nav:checkForUpdates");
useEffect(() => {
onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]);
const toggleLang = () => {
i18n.changeLanguage(nextLanguage);
};
const changeLanguage = (language: SupportedLanguage) => {
if (language !== currentLanguage) {
i18n.changeLanguage(language);
}
};
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`;
return (
<aside
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${
collapsed ? "w-[4.25rem]" : "w-60"
ref={asideRef}
aria-label={t("nav:sidebar")}
role={drawerOpen ? "dialog" : undefined}
aria-modal={drawerOpen || undefined}
data-testid="sidebar"
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col overflow-hidden ${
drawer
? // Visibility flips at once on open (a still-hidden panel cannot take
// focus) and only after the slide-out on close.
`z-50 w-60 max-w-[85vw] duration-200 ${
drawer.open
? "visible translate-x-0 shadow-2xl transition-transform"
: "invisible -translate-x-full transition-[transform,visibility]"
}`
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
}`}
>
{/* Brand */}
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
<Activity className="w-4 h-4 text-accent" />
</div>
{!collapsed && (
<div className="min-w-0">
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
</div>
<div className="flex flex-col items-center justify-center">
{collapsed ? (
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
) : (
<>
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
{/* Wordmark under the logo: small caps + wide tracking so it reads
as a caption to the logo rather than competing with it. The
hairlines either side keep it centred on a narrow rail. */}
<div className="mt-3 flex w-full items-center gap-2 px-1">
<span className="h-px flex-1 bg-fg-muted/40" />
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
{t("nav:brand")}
</span>
<span className="h-px flex-1 bg-fg-muted/40" />
</div>
</>
)}
</div>
</div>
{/* Nav - only this section scrolls when its items overflow; the rest of
the sidebar (brand, language, collapse toggle, footer) stays pinned.
the sidebar (brand, and the Settings + collapse footer) stays pinned.
Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex">
@@ -424,21 +460,28 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
to={to}
end={to === "/"}
title={collapsed ? label : undefined}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`
}
className={navClass}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
);
})}
{/* The workspace → sessions list scrolls with the nav. In the rail it
is one icon that opens the sidebar instead. */}
{collapsed ? (
<button
type="button"
onClick={onToggle}
title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<FolderTree className="w-4 h-4" />
</button>
) : (
<WorkspaceTree />
)}
</nav>
{!collapsed && navOverflow.up && (
<button
@@ -464,219 +507,42 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
)}
</div>
{/* Language + theme controls. Theme sits next to language: same icon
row when collapsed, same row as the EN/VI buttons when expanded -
both are 2-state click-to-flip toggles, so they read as one family
of controls rather than two unrelated settings. */}
<div className="px-2 pb-2 flex-shrink-0">
{collapsed ? (
<div className="flex flex-col gap-1">
<button
onClick={toggleLang}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchLanguageTitle}
aria-label={switchLanguageTitle}
>
<Languages className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold leading-none">
{t(`nav:languageShort.${currentLanguage}`)}
</span>
</button>
<button
onClick={toggleTheme}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchThemeTitle}
aria-label={switchThemeTitle}
>
{theme === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Sun className="w-3.5 h-3.5" />
)}
<span className="text-[10px] font-semibold leading-none">
{t(`nav:themeNames.${theme}`).slice(0, 1)}
</span>
</button>
</div>
) : (
<div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:language")}
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
<button
key={language}
onClick={() => changeLanguage(language)}
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`nav:languageShort.${language}`)}
</button>
);
})}
</div>
</div>
<div>
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:theme")}
</p>
<div className="mt-2 grid grid-cols-2 gap-1">
{(["dark", "light"] as const).map((option) => {
const active = option === theme;
return (
<button
key={option}
onClick={() => setTheme(option)}
aria-pressed={active}
aria-label={t(`nav:themeNames.${option}`)}
title={t(`nav:themeNames.${option}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors flex items-center justify-center ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{option === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Sun className="w-3.5 h-3.5" />
)}
</button>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
{/* Collapse toggle */}
<div className="px-2 py-2 flex-shrink-0">
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col" : "items-center"
}`}
>
<NavLink
to="/settings"
title={collapsed ? t("nav:settings") : undefined}
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
<button
type="button"
onClick={onToggle}
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
collapsed
? "flex items-center justify-center text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
: "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
data-testid="sidebar-toggle"
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
}`}
title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
>
{collapsed ? (
<PanelLeftOpen className="w-4 h-4 flex-shrink-0" />
<PanelLeftOpen className="h-4 w-4" />
) : (
<>
<PanelLeftClose className="w-4 h-4 flex-shrink-0" />
<span className="text-[11px] font-semibold uppercase tracking-wide">
{t("nav:collapseShort")}
</span>
</>
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
{/* Footer */}
<div
className={`px-3 pt-3 pb-4 border-t border-border space-y-2.5 flex-shrink-0 ${collapsed ? "px-2" : ""}`}
>
<button
type="button"
onClick={() => setStatusModalOpen(true)}
aria-label={t("nav:connectionDetails")}
title={t("nav:connectionDetails")}
className={`rounded-lg border border-border bg-surface-2 hover:bg-surface-3 transition-colors text-left cursor-pointer ${
collapsed
? "w-8 h-8 mx-auto flex items-center justify-center p-0"
: "block w-full px-2.5 py-2"
}`}
>
<div
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
>
<span
className={`inline-flex items-center gap-2 ${
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
>
{wsConnected ? (
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
)}
{!collapsed && (
<span className="font-medium">
{wsConnected ? t("nav:live") : t("nav:disconnected")}
</span>
)}
</span>
{!collapsed && (
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)}
</div>
</button>
{collapsed ? (
<button
type="button"
onClick={onCheckUpdates}
disabled={checking}
title={checkTitle}
aria-label={checkTitle}
className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${
updateAvailable
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{updateAvailable && !checking && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
)}
</button>
) : (
<button
type="button"
onClick={onCheckUpdates}
disabled={checking}
title={checkTitle}
className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${
updateAvailable
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
<span className="inline-flex items-center gap-2 truncate">
<RefreshCw
className={`w-3.5 h-3.5 flex-shrink-0 ${checking ? "animate-spin" : ""}`}
aria-hidden
/>
<span className="font-medium truncate">{checkTitle}</span>
</span>
{updateAvailable && !checking && (
<span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
)}
</button>
)}
</div>
<ConnectionStatusModal
open={statusModalOpen}
onClose={() => setStatusModalOpen(false)}
open={connectionOpen}
onClose={() => onConnectionOpenChange(false)}
wsConnected={wsConnected}
connectedSince={connectedSince}
eventCountRef={eventCountRef}
@@ -685,6 +551,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
eventTimestampsRef={eventTimestampsRef}
typeCountRef={typeCountRef}
recentEventsRef={recentEventsRef}
update={{
title: checkTitle,
checking,
available: updateAvailable,
failed: checkError,
onCheck: onCheckUpdates,
}}
onResetStats={() => {
eventCountRef.current = 0;
peakPerSecRef.current = 0;
@@ -718,6 +591,15 @@ interface ConnectionStatusModalProps {
eventTimestampsRef: React.MutableRefObject<number[]>;
typeCountRef: React.MutableRefObject<Map<string, number>>;
recentEventsRef: React.MutableRefObject<Array<{ type: string; at: number }>>;
/** The release check: its one-line state ("Up to date", "Dev build · N
* ahead", a release out…) and the button that re-runs it. */
update: {
title: string;
checking: boolean;
available: boolean;
failed: boolean;
onCheck: () => void;
};
onResetStats: () => void;
}
@@ -732,6 +614,7 @@ function ConnectionStatusModal({
eventTimestampsRef,
typeCountRef,
recentEventsRef,
update,
onResetStats,
}: ConnectionStatusModalProps) {
const { t } = useTranslation();
@@ -747,15 +630,8 @@ function ConnectionStatusModal({
}, [open]);
const close = useCallback(() => onClose(), [onClose]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, close]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(open, panelRef, close);
const wsUrl = useMemo(() => {
if (typeof window === "undefined") return "";
@@ -784,14 +660,17 @@ function ConnectionStatusModal({
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="connection-status-title"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="connection-status-title"
className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<div
@@ -833,13 +712,49 @@ function ConnectionStatusModal({
type="button"
onClick={close}
aria-label={t("nav:close")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
className="btn-icon -m-1 flex-shrink-0"
>
<X className="w-4 h-4" />
<X className="w-4 h-4" aria-hidden />
</button>
</div>
<div className="px-5 py-4 space-y-5 overflow-y-auto">
{/* Version + release check */}
<Section title={t("nav:version")} icon={RefreshCw}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-mono text-sm text-fg-primary">{`v${__APP_VERSION__}`}</p>
<p
data-testid="update-status"
className={`mt-0.5 inline-flex items-center gap-1.5 text-xs ${
update.available
? "text-status-success"
: update.failed
? "text-status-warning"
: "text-fg-muted"
}`}
>
{update.available && (
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
)}
{update.title}
</p>
</div>
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
</div>
</Section>
{/* KPI row */}
<div className="grid grid-cols-3 gap-2">
<KpiTile
@@ -1026,6 +941,7 @@ function Sparkline({
connected: boolean;
avgLabel: string;
}) {
const { t } = useTranslation();
const W = 320;
const H = 56;
const max = Math.max(1, ...buckets);
@@ -1037,15 +953,15 @@ function Sparkline({
});
const linePath = points.length > 0 ? `M ${points.join(" L ")}` : "";
const areaPath = points.length > 0 ? `M 0,${H} L ${points.join(" L ")} L ${W},${H} Z` : "";
const stroke = connected ? "#34d399" : "#6b7280";
const stroke = connected ? "rgb(var(--status-success))" : "rgb(var(--fg-muted))";
return (
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-14" aria-hidden>
<defs>
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
<stop offset="0%" style={{ stopColor: stroke, stopOpacity: 0.35 }} />
<stop offset="100%" style={{ stopColor: stroke, stopOpacity: 0 }} />
</linearGradient>
</defs>
{areaPath && <path d={areaPath} fill="url(#spark-fill)" />}
@@ -1053,7 +969,7 @@ function Sparkline({
<path
d={linePath}
fill="none"
stroke={stroke}
style={{ stroke }}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
@@ -1064,7 +980,7 @@ function Sparkline({
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
<span>−60s</span>
<span>{avgLabel}</span>
<span>{"now"}</span>
<span>{t("nav:now")}</span>
</div>
</div>
);
+12 -11
View File
@@ -65,13 +65,16 @@
* ----------------------------------------------------------------------------- */
import type { CSSProperties } from "react";
import { useTranslation } from "react-i18next";
interface SkeletonProps {
className?: string;
style?: CSSProperties;
/** Rounded shape variant. Defaults to "md". */
rounded?: "sm" | "md" | "lg" | "full";
/** Aria label for screen readers. Defaults to "Loading". */
/** Aria label for screen readers. Defaults to the translated "Loading…";
* `""` makes the block decorative (`aria-hidden`), for every block but one
* in a group, so a reader hears "Loading" once, not once per shape. */
label?: string;
}
@@ -86,17 +89,15 @@ const ROUNDED_CLASS = {
* Base shimmer block. Compose with width/height utilities for any shape:
* <Skeleton className="h-6 w-24" />
*/
export function Skeleton({
className = "",
style,
rounded = "md",
label = "Loading",
}: SkeletonProps) {
export function Skeleton({ className = "", style, rounded = "md", label }: SkeletonProps) {
const { t } = useTranslation();
const hidden = label === "";
return (
<span
role="status"
aria-label={label}
aria-busy="true"
role={hidden ? undefined : "status"}
aria-label={hidden ? undefined : (label ?? t("common:loading"))}
aria-hidden={hidden || undefined}
aria-busy={hidden ? undefined : "true"}
className={`inline-block bg-surface-3/70 animate-pulse ${ROUNDED_CLASS[rounded]} ${className}`}
style={style}
/>
@@ -133,7 +134,7 @@ export function TableRowSkeleton({ columns, widths }: { columns: number; widths?
<tr className="border-b border-border/40">
{Array.from({ length: columns }).map((_, i) => (
<td key={i} className="px-5 py-4">
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} />
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} label={i === 0 ? undefined : ""} />
</td>
))}
</tr>
+7
View File
@@ -68,7 +68,10 @@ export function SplashScreen() {
const { t } = useTranslation("splash");
// Show at most once per tab session. Read synchronously so we never flash an
// empty overlay on a repeat mount (StrictMode double-invoke, refresh, etc.).
// Never on a deep link (a shared /sessions/<id>, a notification's
// /workspace?…): the user came for that page, not the welcome.
const [mounted, setMounted] = useState(() => {
if (window.location.pathname !== "/") return false;
try {
return !sessionStorage.getItem(SESSION_KEY);
} catch {
@@ -109,7 +112,11 @@ export function SplashScreen() {
/* sessionStorage may be unavailable (private mode) - show anyway */
}
exitTimer.current = window.setTimeout(beginExit, HOLD_MS);
// Any key dismisses it (not only a click), so a keyboard user is never stuck.
const onKey = () => beginExit();
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
if (exitTimer.current) window.clearTimeout(exitTimer.current);
if (doneTimer.current) window.clearTimeout(doneTimer.current);
};
+17 -3
View File
@@ -54,6 +54,7 @@
*
* ----------------------------------------------------------------------------- */
import { Link } from "react-router-dom";
import type { LucideIcon } from "lucide-react";
import { Tip } from "./Tip";
import { StatValueSkeleton } from "./Skeleton";
@@ -74,6 +75,8 @@ interface StatCardProps {
raw?: string;
/** When true, render skeletons in place of value/trend. */
loading?: boolean;
/** Drill-in: the whole card links here (e.g. the list the number counts). */
to?: string;
}
/**
@@ -88,9 +91,10 @@ export function StatCard({
accentColor = "text-accent",
raw,
loading = false,
to,
}: StatCardProps) {
return (
<div className="card p-5">
const body = (
<>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
{label}
@@ -109,6 +113,16 @@ export function StatCard({
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
)}
</div>
</div>
</>
);
return to ? (
<Link
to={to}
className="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{body}
</Link>
) : (
<div className="card p-5">{body}</div>
);
}
+3 -3
View File
@@ -79,8 +79,8 @@ export const REASON_ICONS: Record<AwaitingReason, LucideIcon> = {
/**
* The "why" chip nested inside a Waiting badge: a small rounded pill with the
* reason's icon and short label. Urgent reasons (permission prompts,
* interruptions) get a hotter amber fill than the calm idle-between-turns
* ones so a scan of a list surfaces the rows that actually block on the human.
* interruptions) get the amber warning fill; the calm idle-between-turns
* ones stay neutral, so a scan of a list surfaces the rows that actually block on the human.
*/
function ReasonChip({ reason }: { reason: AwaitingReason }) {
const { t } = useTranslation();
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
cfg.urgent
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
: "bg-surface-3 border-border text-fg-secondary"
}`}
>
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
+218
View File
@@ -0,0 +1,218 @@
/**
* @file StatusBar — the one-line bar along the bottom of the app shell, on
* every page: the live-connection state (click: the connection details modal,
* which the sidebar owns and which also carries the update check), the version
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import type { CostResult } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const usd = (n: number) => `$${n.toFixed(2)}`;
/** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded),
* costliest first: the hover text of a cost figure. */
function perModel(r: CostResult): { tokens: number; lines: string } {
const byModel = new Map<string, { tokens: number; cost: number }>();
for (const b of r.breakdown) {
const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 };
m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens;
m.cost += b.cost;
byModel.set(b.model, m);
}
const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost);
return {
tokens: rows.reduce((n, [, m]) => n + m.tokens, 0),
lines: rows
.map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`)
.join("\n"),
};
}
function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) {
const { t } = useTranslation("nav");
const { tokens, lines } = perModel(r);
return (
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
</span>
);
}
export function StatusBar({
wsConnected,
left,
onConnectionClick,
updateAvailable = false,
}: {
wsConnected: boolean;
/** Sidebar width, so the bar starts where the main column does. */
left: string;
onConnectionClick: () => void;
/** A newer release is out: a dot beside the version. */
updateAvailable?: boolean;
}) {
const { t } = useTranslation("nav");
const [active, setActive] = useState(0);
const [needYou, setNeedYou] = useState(0);
const running = Math.max(active - needYou, 0);
const [today, setToday] = useState<CostResult | null>(null);
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
const [runs, setRuns] = useState<RunHandle[]>([]);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
const sessionId =
shown.session ?? (shown.run && runs.find((r) => r.id === shown.run)?.sessionId) ?? null;
useEffect(() => {
const take = (e: Event) => setRuns((e as CustomEvent<RunHandle[]>).detail);
window.addEventListener(LIVE_RUNS_EVENT, take);
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
useEffect(() => {
let live = true;
// Another workspace or session: never show the previous one's figure.
setLaneCost(null);
setSessionCost(null);
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
return () => {
live = false;
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
}, [lane, sessionId]);
return (
<footer
data-testid="status-bar"
style={{ left }}
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 overflow-hidden whitespace-nowrap border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
>
{/* What a screen reader is told without looking: the connection dropping
or coming back, and agents waiting on the user. Polite, and nothing
that ticks on its own (costs, running counts) — those stay silent. */}
<span role="status" className="sr-only" data-testid="status-bar-live">
{wsConnected ? t("live") : t("disconnected")}
{needYou > 0 ? ` · ${t("statusBar.needYou", { count: needYou })}` : ""}
</span>
<button
type="button"
onClick={onConnectionClick}
title={t("connectionDetails")}
className={`flex items-center gap-1.5 hover:text-fg-primary ${
wsConnected ? "text-status-success" : ""
}`}
>
{wsConnected ? (
<Wifi className="h-3 w-3" aria-hidden />
) : (
<WifiOff className="h-3 w-3" aria-hidden />
)}
{wsConnected ? t("live") : t("disconnected")}
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
{updateAvailable && (
<span
data-testid="status-bar-update"
title={t("updateAvailable")}
className="h-1.5 w-1.5 rounded-full bg-status-success"
>
<span className="sr-only">{t("updateAvailable")}</span>
</span>
)}
</button>
<span data-testid="status-bar-counts">
{t("statusBar.running", { count: running })}
{" · "}
<span className={needYou ? "text-status-warning" : ""}>
{t("statusBar.needYou", { count: needYou })}
</span>
</span>
{today !== null && (
<CostFigure
testId="status-bar-cost"
label={t("statusBar.today", { cost: usd(today.total_cost) })}
r={today}
/>
)}
{lane !== null && laneCost !== null && (
<CostFigure
testId="status-bar-workspace-cost"
label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
r={laneCost}
/>
)}
{sessionId && sessionCost !== null && (
<CostFigure
testId="status-bar-session-cost"
label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
r={sessionCost}
/>
)}
</footer>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* @file Switch.tsx
* @description The app's ONE on/off switch (`role="switch"`): an accent track
* with a white knob, the same size everywhere. Settings rows, webhook targets
* and the AI Tools list all draw this — do not hand-roll another one; a
* yes/no choice inside a form or list is a `Checkbox` instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export function Switch({
checked,
onChange,
label,
title,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
/** Accessible name — a switch usually sits beside its visible label. */
label?: string;
title?: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
title={title}
disabled={disabled}
onClick={() => onChange(!checked)}
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
}`}
>
<span
aria-hidden
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
checked ? "translate-x-[18px]" : "translate-x-0.5"
}`}
/>
</button>
);
}
+9 -5
View File
@@ -4,6 +4,13 @@
* by {@link StatCard} for raw metric values and anywhere a compact display needs
* a full-precision expansion without cluttering the layout.
*
* ## Look
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
* are visibly one thing. It stays cursor-following (and not anchored like the
* global one) because it expands a NUMBER inside a line of text, where the
* anchor is a few characters wide and the cursor is the real subject.
*
* ## Portal rendering
* Tooltip content is portaled to `document.body` with `position: fixed` so
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
@@ -56,6 +63,7 @@
import { useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { TOOLTIP_SURFACE } from "./Tooltip";
/** Props for {@link Tip}. */
interface TipProps {
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
{children}
{show &&
createPortal(
<div
ref={tipRef}
style={tipStyle}
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
>
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
{raw}
</div>,
document.body
+61
View File
@@ -0,0 +1,61 @@
/**
* @file Toaster.tsx
* @description Renders the app-wide toast queue from `lib/toast.ts`, mounted
* once in `App.tsx`. Two live regions — `status` for success/info, `alert` for
* errors — so a screen reader announces each message at the right urgency.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Check, Info, X } from "lucide-react";
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
const TONE: Record<ToastItem["kind"], string> = {
success: "border-status-success/40 bg-status-success/10 text-status-success",
info: "border-border bg-surface-2 text-fg-primary",
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
};
function Item({ item }: { item: ToastItem }) {
const { t } = useTranslation("common");
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
return (
<div
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
>
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
<button
type="button"
onClick={() => dismissToast(item.id)}
className="opacity-70 hover:opacity-100 p-1 -m-0.5"
aria-label={t("dismiss")}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
/** The single toast renderer. */
export function Toaster() {
const [items, setItems] = useState<ToastItem[]>([]);
useEffect(() => subscribeToasts(setItems), []);
const errors = items.filter((i) => i.kind === "error");
const rest = items.filter((i) => i.kind !== "error");
return (
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div role="status" aria-live="polite" className="flex flex-col gap-2">
{rest.map((i) => (
<Item key={i.id} item={i} />
))}
</div>
<div role="alert" aria-live="assertive" className="flex flex-col gap-2">
{errors.map((i) => (
<Item key={i.id} item={i} />
))}
</div>
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
/**
* @file Tooltip.tsx
* @description The dashboard's tooltip look, and the one renderer that gives it
* to every control in the app.
*
* ## Why a global renderer instead of a wrapper component
* The UI already labels its icon-only controls with the native `title`
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
* touching every one of those call sites to get a designed bubble, and the
* next `title=` someone writes would be back to the browser's grey box.
* {@link GlobalTooltip} instead listens once at the document: on hover or
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
* by removing the attribute — its ancestors' too, or the browser falls back
* to the nearest one's — for as long as the bubble is up, and restores them
* on the way out. Nothing else in the app has to know it exists, and a control
* keeps its native tooltip (and its accessible name) when JS has not run.
* While the title is parked, a control with no other name (icon-only) carries
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
* bubble goes away with its control if that is removed from the page.
*
* ## The look
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
* hard to read and lands over the thing it describes. It flips above/below and
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
* after a short delay so a cursor crossing a toolbar does not strobe, and is
* built from the theme's own tokens so it is legible in light mode too (the
* old bubble was a hardcoded near-black).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
export const TOOLTIP_SURFACE =
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
"whitespace-pre-wrap break-words";
/** How long the pointer must rest on a control before its tooltip appears. */
const SHOW_DELAY_MS = 350;
/** Gap between the control and the bubble, and the minimum viewport margin. */
const GAP = 8;
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
* `[data-tip]` matches the control whose title is parked, so moving between
* its own children still resolves to it instead of re-opening the bubble. */
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
type Anchored = { text: string; rect: DOMRect };
type Placed = { left: number; top: number; arrow: number; above: boolean };
/**
* Takes `el`'s title into `data-tip`. React removing `title` while it is
* parked calls removeAttribute on an attribute that is already gone: no
* MutationRecord, nothing to see — and putting the parked copy back would
* write stale text for good. So, on THIS element and only while parked, that
* call is taken as React's intent: the parked copy is dropped and `onDrop`
* runs. (React DOM calls `node.removeAttribute`, the instance method; `unpark`
* deletes the shim.) No global prototype is touched.
*/
function park(el: HTMLElement, text: string, onDrop?: () => void) {
el.dataset.tip = text;
el.removeAttribute("title");
el.removeAttribute = (name: string) => {
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
delete el.dataset.tip;
onDrop?.();
}
Element.prototype.removeAttribute.call(el, name);
};
}
/** Undoes {@link park}. */
function unpark(el: HTMLElement) {
// Drop the instance shim; the prototype method is back.
delete (el as { removeAttribute?: unknown }).removeAttribute;
const text = el.dataset.tip;
if (text === undefined) return;
// React re-set `title` while it was parked (a poll flipped the hint): that
// value is current, the parked copy is stale. React never re-applies an
// unchanged prop, so writing the old one back would win for good.
if (!el.hasAttribute("title")) el.setAttribute("title", text);
delete el.dataset.tip;
}
/**
* Mount ONCE, near the router root. Every `title` in the app becomes a
* designed tooltip; nothing else has to change.
*/
export function GlobalTooltip() {
const [tip, setTip] = useState<Anchored | null>(null);
const [pos, setPos] = useState<Placed | null>(null);
const bubble = useRef<HTMLDivElement>(null);
// The element whose `title` is currently parked in `data-tip`, so it can be
// put back even if the pointer never leaves it (a route change, a re-render
// that unmounts it, Escape).
const parked = useRef<HTMLElement | null>(null);
// Ancestors of `parked` whose titles are parked with it (see `open`).
const silenced = useRef<HTMLElement[]>([]);
const timer = useRef<number | undefined>(undefined);
// The element whose delay is running, so re-entering it does not restart it.
const pending = useRef<HTMLElement | null>(null);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
pending.current = null;
silenced.current.forEach(unpark);
silenced.current = [];
const el = parked.current;
if (el) {
unpark(el);
if (el.dataset.tipAria !== undefined) {
el.removeAttribute("aria-label");
delete el.dataset.tipAria;
}
parked.current = null;
}
setTip(null);
setPos(null);
}, []);
const open = useCallback(
(el: HTMLElement, delay: number) => {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
pending.current = el;
timer.current = window.setTimeout(() => {
pending.current = null;
// Re-read at fire time: the control may have been re-rendered, removed
// or had its title changed while the delay was running.
if (!el.isConnected) return;
const current = (el.getAttribute("title") || "").trim();
if (!current) return;
park(el, current, restore);
// With its own title gone the browser shows the nearest ANCESTOR's
// native one instead (a pane header's drag hint over its Close
// button's bubble), so every ancestor title is parked too.
for (let a = el.parentElement; a; a = a.parentElement) {
const t = a.getAttribute("title");
if (t === null) continue;
park(a, t);
silenced.current.push(a);
}
// The title was an icon-only control's ONLY name; keep it named.
if (
!el.hasAttribute("aria-label") &&
!el.hasAttribute("aria-labelledby") &&
!el.textContent?.trim()
) {
el.setAttribute("aria-label", current);
el.dataset.tipAria = "";
}
parked.current = el;
setTip({ text: current, rect: el.getBoundingClientRect() });
}, delay);
},
[restore]
);
useEffect(() => {
const target = (e: Event) => {
const node = e.target;
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
};
const onOver = (e: PointerEvent) => {
const el = target(e);
// Already showing or already counting down for this control: a pointer
// crossing its children must neither hide nor restart it.
if (!el || el === parked.current || el === pending.current) return;
open(el, SHOW_DELAY_MS);
};
const onOut = (e: PointerEvent) => {
// Moving between children of the same control must not flicker.
const el = target(e);
const to = e.relatedTarget;
if (el && to instanceof Node && el.contains(to)) return;
if (el || parked.current) restore();
};
// Keyboard users get the same tooltip, immediately — a focus ring that
// reveals nothing is the whole reason icon-only buttons are hard to learn.
// Only KEYBOARD focus, though: a click focuses too, and without the
// `:focus-visible` check it reopened the bubble the pointerdown just
// closed, with no delay. Hover keeps its own path.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (!el || el === parked.current) return;
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
open(el, 0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") restore();
};
document.addEventListener("pointerover", onOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("pointerdown", restore);
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", restore);
document.addEventListener("keydown", onKey);
// A bubble anchored to a rect is wrong the moment its anchor moves — but
// only a scroll of the document or of a box containing the anchor moves
// it. A log tailing in another panel must not close the bubble.
const onScroll = (e: Event) => {
const anchor = parked.current || pending.current;
const t = e.target;
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", restore);
window.addEventListener("blur", restore);
return () => {
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("pointerdown", restore);
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", restore);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", restore);
window.removeEventListener("blur", restore);
restore();
};
}, [open, restore]);
// A control unmounted under a still cursor fires no pointerout: without this
// its bubble stays up over whatever replaced it. The same goes for a title
// re-rendered under it — the bubble would keep saying the old text, so it
// hides and `restore` keeps the new attribute.
useEffect(() => {
if (!tip) return;
const watch = new MutationObserver((records) => {
const el = parked.current;
if (!el) return;
if (
!el.isConnected ||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
)
restore();
});
watch.observe(document.body, { childList: true, subtree: true });
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
return () => watch.disconnect();
}, [tip, restore]);
// Placed after the first paint, when the bubble's real size is known —
// measuring it is the only way to flip and clamp without guessing.
useEffect(() => {
if (!tip || !bubble.current) return;
const { width, height } = bubble.current.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const centre = tip.rect.left + tip.rect.width / 2;
let left = centre - width / 2;
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
// Below by default; above only when below would run off the screen AND
// above actually fits, so a control at the very bottom still gets a bubble.
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
setPos({
left,
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
// The arrow points at the control even after the bubble was clamped.
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
above,
});
}, [tip]);
if (!tip) return null;
return createPortal(
<div
ref={bubble}
role="tooltip"
data-testid="global-tooltip"
style={{
position: "fixed",
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
opacity: pos ? 1 : 0,
transition: "opacity 120ms ease-out",
}}
className={TOOLTIP_SURFACE}
>
{tip.text}
{/* Two borders only: a fully bordered diamond draws its other two
edges straight across the bubble it is attached to. */}
<span
aria-hidden
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
pos?.above ? "border-b border-r" : "border-l border-t"
} border-border`}
/>
</div>,
document.body
);
}
export default GlobalTooltip;
+60 -28
View File
@@ -16,7 +16,9 @@
* — and the poll waits for the old server to actually go DOWN first, because it
* keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it.
* background process can do for it. Escape, the backdrop and "Not now" close it
* for this page load only (never while applying); "Skip this version" is the
* one persistent, per-version dismiss.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
@@ -76,11 +78,13 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
@@ -125,6 +129,10 @@ export function UpdateNotifier() {
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
// persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false);
@@ -185,37 +193,44 @@ export function UpdateNotifier() {
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
// commit the user already has. Falls back to the upstream SHA for the
// commit-count path (no release readable).
const dismissKey = status?.latest_release || status?.remote_sha || null;
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
// A build installed from a terminal while this server kept running has no
// tag or sha when the forge is unreachable — key it by its directory.
const installed = status?.installed_not_running ?? null;
const dismissKey =
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
const show = Boolean(
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
);
const busy = applying || restarting;
const closeForNow = useCallback(() => {
if (busy || !dismissKey) return;
setClosedKey(dismissKey);
}, [busy, dismissKey]);
const dismiss = useCallback(() => {
if (restarting || !dismissKey) return;
if (busy || !dismissKey) return;
try {
localStorage.setItem(DISMISS_KEY, dismissKey);
} catch {
/* ignore */
}
setDismissedSha(dismissKey);
}, [restarting, dismissKey]);
}, [busy, dismissKey]);
// Escape to dismiss - standard modal affordance.
useEffect(() => {
if (!show) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [show, dismiss]);
// Escape closes for now — never while an update is applying.
useDialog(show, panelRef, busy ? undefined : closeForNow);
const copyCmd = async () => {
if (!status?.manual_command) return;
try {
await navigator.clipboard.writeText(status.manual_command);
if (await copyText(status.manual_command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
@@ -298,17 +313,20 @@ export function UpdateNotifier() {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
onClick={(e) => {
if (e.target === e.currentTarget) dismiss();
if (e.target === e.currentTarget) closeForNow();
}}
>
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
longest label is a whole sentence ("restarting the dashboard…"). */}
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
<div
ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
@@ -320,12 +338,16 @@ export function UpdateNotifier() {
id="update-notifier-title"
className="text-sm font-semibold text-fg-primary truncate"
>
{release ? t("releaseTitle", { version: release }) : t("title")}
{installed
? t("installedTitle", { version: installed.version ?? "?" })
: release
? t("releaseTitle", { version: release })
: t("title")}
</h2>
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
{/* Never print a commit count on a shallow clone — the number
is a graft artefact, not a distance. */}
{release && !status.shallow_clone && status.local_version
{(release || installed) && !status.shallow_clone && status.local_version
? t("releaseSubtitle", { current: status.local_version })
: t("commitsBehind", { count: behind, ref: refLabel })}
</p>
@@ -333,8 +355,9 @@ export function UpdateNotifier() {
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("dismiss")}
onClick={closeForNow}
disabled={busy}
aria-label={t("later")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
@@ -450,10 +473,19 @@ export function UpdateNotifier() {
<button
type="button"
onClick={dismiss}
disabled={restarting}
disabled={busy}
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("dismiss")}
{t("skipVersion")}
</button>
<button
type="button"
onClick={closeForNow}
disabled={busy}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("later")}
</button>
{status.manual_command ? (
<button
+61 -48
View File
@@ -7,7 +7,8 @@
* fetched from the server (`/api/webhooks/providers`): each provider declares
* whether it needs a URL and which credential fields to render, so adding a
* provider server-side surfaces here with no UI change. Secrets are never
* returned by the API - URLs are masked and re-entered to change.
* returned by the API - URLs are masked and re-entered to change. A right-click
* on a target opens its Test / Edit / Delete as a menu.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -58,6 +59,7 @@
*
* ----------------------------------------------------------------------------- */
import { Switch } from "./Switch";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -81,8 +83,10 @@ import { api } from "../lib/api";
import { Select } from "./Select";
import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { WEBHOOK_DOCS } from "./webhookGuides";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type {
AlertRule,
WebhookProvider,
@@ -91,20 +95,21 @@ import type {
WebhookTestResult,
} from "../lib/types";
// Brand-ish accent per provider type; anything unmapped falls back to neutral.
// Brand-ish accent per provider type, on the BORDER only: brand hues fail
// contrast as text on several themes, so the label stays text-fg-primary.
const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20",
discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20",
teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20",
google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20",
mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20",
rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20",
telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20",
pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20",
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
slack: "border-[#E01E5A]/60",
discord: "border-[#5865F2]/60",
teams: "border-[#6264A7]/60",
google_chat: "border-[#1A73E8]/60",
mattermost: "border-[#0058CC]/60",
rocketchat: "border-[#F5455C]/60",
telegram: "border-[#26A5E4]/60",
pagerduty: "border-[#06AC38]/60",
opsgenie: "border-[#2684FF]/60",
splunk_oncall: "border-[#F99D1C]/60",
};
const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border";
const NEUTRAL_STYLE = "border-border";
interface HeaderRow {
key: string;
@@ -132,34 +137,6 @@ function defaultsFor(provider: WebhookProvider | undefined): Record<string, stri
return out;
}
function Toggle({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label?: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
checked ? "bg-blue-600" : "bg-surface-4"
}`}
>
<span
className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
style={{ transform: checked ? "translateX(18px)" : "translateX(3px)" }}
/>
</button>
);
}
export function WebhookSettings() {
const { t } = useTranslation("settings");
const [targets, setTargets] = useState<WebhookTarget[]>([]);
@@ -171,6 +148,13 @@ export function WebhookSettings() {
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const ctx = useContextMenu<WebhookTarget>();
// A menu item: close the menu, then act on the target it was opened on.
const pick = (fn: (target: WebhookTarget) => unknown) => () => {
const target = ctx.menu?.item;
ctx.close();
if (target) fn(target);
};
const [testResult, setTestResult] = useState<Record<string, WebhookTestResult>>({});
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [guideOpen, setGuideOpen] = useState(false);
@@ -186,11 +170,13 @@ export function WebhookSettings() {
const res = await api.webhooks.list();
setTargets(res.targets);
} catch (err) {
console.error("Failed to load webhook targets:", err);
toast.error(
t("webhooks.loadFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
load();
@@ -270,6 +256,9 @@ export function WebhookSettings() {
const canSubmit = useMemo(() => {
if (!form || !provider) return false;
if (!form.name.trim()) return false;
// An empty rule list means ALL rules server-side — never save that from
// "only selected rules".
if (!form.scopeAll && form.ruleIds.length === 0) return false;
if (isEdit) return true; // server merge - existing values fill the gaps
if (provider.url_required && !form.url.trim()) return false;
for (const f of provider.fields) {
@@ -344,7 +333,9 @@ export function WebhookSettings() {
await api.webhooks.update(target.id, { enabled: !target.enabled });
load();
} catch (err) {
console.error("Failed to toggle webhook:", err);
toast.error(
t("webhooks.toggleFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -354,7 +345,10 @@ export function WebhookSettings() {
setConfirmDelete(null);
load();
} catch (err) {
console.error("Failed to delete webhook:", err);
setConfirmDelete(null);
toast.error(
t("webhooks.deleteFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -415,11 +409,12 @@ export function WebhookSettings() {
return (
<div
key={target.id}
onContextMenu={(e) => ctx.open(e, target)}
className="bg-surface-2 border border-border rounded-lg px-3.5 py-3 space-y-2"
>
<div className="flex items-center gap-3 flex-wrap">
<span
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border bg-surface-2 text-fg-primary ${
TYPE_STYLES[target.type] || NEUTRAL_STYLE
}`}
>
@@ -452,7 +447,7 @@ export function WebhookSettings() {
{timeAgo(target.last_delivery.created_at)}
</span>
)}
<Toggle
<Switch
checked={target.enabled}
onChange={() => onToggle(target)}
label={t("webhooks.enabled")}
@@ -509,6 +504,21 @@ export function WebhookSettings() {
</div>
);
})}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="webhook-menu">
<MenuItem
disabled={testing === ctx.menu.item.id}
onSelect={pick((target) => void onTest(target.id))}
>
{t("webhooks.test")}
</MenuItem>
<MenuItem onSelect={pick(openEdit)}>{t("webhooks.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick((target) => setConfirmDelete(target.id))}>
{t("webhooks.delete")}
</MenuItem>
</ContextMenu>
)}
</div>
)}
@@ -767,12 +777,15 @@ export function WebhookSettings() {
))}
</div>
)}
{!form.scopeAll && form.ruleIds.length === 0 && (
<p className="mt-1.5 text-[11px] text-status-warning">{t("webhooks.pickRule")}</p>
)}
</div>
)}
<div className="flex items-center gap-3 pt-1">
<label className="inline-flex items-center gap-2 text-[11px] text-fg-secondary cursor-pointer">
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
<Switch checked={form.enabled} onChange={(v) => set({ enabled: v })} />
{t("webhooks.enabledOnSave")}
</label>
</div>
+387
View File
@@ -0,0 +1,387 @@
/**
* @file WorkspaceTree — the sidebar's Workspaces section, shaped like Claude
* Desktop's project list: every workspace (lane) on one line in the stored drag
* order (`laneOrderStorage.ts`), each expanding to ITS sessions
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
* live tab is opened from its session row. Live status comes from the shared
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
* lane's status or session moves and handed over by the Workspace page's own
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import {
insertionIndex,
moveBefore,
moveLane,
readLaneOrder,
sortByStoredOrder,
writeLaneOrder,
} from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { AddLaneModal } from "./lanes/AddLaneModal";
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
/** The workspace (and attached run) the Workspace page shows, from its URL. */
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
export function selectedFrom(pathname: string, search: string) {
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
const p = new URLSearchParams(search);
const v = p.get("lane");
return { lane: v === null ? null : Number(v), run: p.get("run"), session: p.get("session") };
}
/** The Workspace page's polled run list, handed to the sidebar as it lands. */
export const LIVE_RUNS_EVENT = "agenthub:live-runs";
export function WorkspaceTree() {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const location = useLocation();
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes();
const prefs = useProjectTreePrefs();
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [menu, setMenu] = useState<{ lane: Lane; x: number; y: number } | null>(null);
const [renamingId, setRenamingId] = useState<number | null>(null);
const [destructive, setDestructive] = useState<{ lane: Lane; action: "reset" | "remove" } | null>(
null
);
const [addOpen, setAddOpen] = useState(false);
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [orderTick, setOrderTick] = useState(0);
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
// status or session moves, so that is when the live-pane list is re-read;
// the Workspace page also hands over every run list it polls (a tab in
// slots 1–3 moves no lane column).
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
useEffect(() => {
let live = true;
api.run
.list()
.then((r) => live && setLiveRuns(r.items))
.catch(() => undefined);
return () => {
live = false;
};
}, [runKey]);
useEffect(() => {
const take = (e: Event) => setLiveRuns((e as CustomEvent<RunHandle[]>).detail);
window.addEventListener(LIVE_RUNS_EVENT, take);
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
const openAdd = (sourceRepo?: string) => {
setAddPrefill(sourceRepo ? { sourceRepo } : null);
setAddOpen(true);
api.run
.cwds()
.then((r) => setCwdSuggestions(r.items))
.catch(() => undefined);
};
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim();
const setExpanded = (id: number, open: boolean) =>
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps
// The tool filter, offered once workspaces of two tools exist; a stored
// tool no workspace has any more filters nothing.
const aiTools = useAiTools();
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
[lanes, order, toolFilter]
);
const q = query.trim().toLowerCase();
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
// A search also finds a workspace by a session's name: every workspace's
// list stays mounted (hidden) to load and count its matches.
const [hits, setHits] = useState<Record<number, number>>({});
const onMatches = useCallback(
(id: number, n: number) => setHits((h) => (h[id] === n ? h : { ...h, [id]: n })),
[]
);
// A cleared search unmounts the collapsed lists; their old counts must not
// surface workspaces in the next search before its fetch answers.
useEffect(() => {
if (!q) setHits({});
}, [q]);
const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0;
const reorder = (move: (ids: number[]) => number[]) => {
const ids = sorted.map((l) => l.id);
const moved = move(ids);
if (moved === ids) return;
// Under a tool filter the shown ids go back into the slots they held in
// the full order, so the hidden workspaces keep their places.
const shownIds = new Set(ids);
let k = 0;
const full = sortByStoredOrder(lanes, order).map((l) =>
shownIds.has(l.id) ? (moved[k++] ?? l.id) : l.id
);
writeLaneOrder(full);
setOrderTick((n) => n + 1);
};
// A row only starts a drag; the whole section takes the drop, so a gap — a
// workspace's session list, the space above or below a row — lands too.
// `dropAt` is the insertion point in `sorted`, drawn as a line.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
const dropIndexAt = (y: number) => {
const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-lane-index]") ?? [])];
const mids = rows.map((r) => {
const b = r.getBoundingClientRect();
return b.top + b.height / 2;
});
const row = rows[insertionIndex(mids, y)];
return row ? Number(row.dataset.laneIndex) : sorted.length;
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientY);
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
setDropAt(null);
if (!isLaneDrag(e)) return;
e.preventDefault();
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
const before = dropIndexAt(e.clientY);
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
},
};
const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null);
if (title === null || title === (lane.title || "")) return;
try {
await api.lanes.update(lane.id, { title });
await refreshLanes();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
if (action === "rename") setRenamingId(lane.id);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
};
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
if (!destructive) return;
const { lane, action } = destructive;
setDestructive(null);
try {
await api.lanes.action(lane.id, action, {
confirm: true,
expect: body?.expect,
...(body?.force === true ? { force: true } : {}),
});
await refreshLanes();
if (action === "remove" && selected.lane === lane.id) navigate("/workspace");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
// ≥24px targets (WCAG 2.5.8); each carries an aria-label.
const headBtn = "btn-icon";
return (
<section
data-testid="workspace-tree"
// The bottom padding is a drop strip below the last workspace.
className="mt-3 border-t border-border pb-6 pt-3"
{...dragHandlers}
>
<div className="mb-1 flex items-center gap-1 px-2">
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
{t("tree.title")}
</span>
{tools.length > 1 && (
<select
value={toolFilter ?? ""}
onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
aria-label={t("tree.toolFilter")}
title={t("tree.toolFilter")}
className="select max-w-[6.5rem] !rounded !py-0.5 !pl-1 !pr-5 !text-[11px] text-fg-secondary"
>
<option value="">{t("tree.allTools")}</option>
{tools.map((id) => (
<option key={id} value={id}>
{aiTools.label(id)}
</option>
))}
</select>
)}
<button
type="button"
onClick={() => {
setSearching((v) => !v);
setQuery("");
}}
aria-label={t("tree.search")}
aria-pressed={searching}
title={t("tree.search")}
className={headBtn}
>
<Search className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-add"
onClick={() => openAdd()}
aria-label={t("addLane")}
title={t("addLane")}
className={headBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{searching && (
<input
type="search"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setQuery("");
setSearching(false);
}
}}
placeholder={t("tree.search")}
aria-label={t("tree.search")}
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary"
/>
)}
<div ref={listRef} className="space-y-px px-1">
{sorted.map((lane, i) => (
<div key={lane.id} className="relative">
{dropAt === i && <DropLine />}
{visible(lane) && (
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
))}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
</div>
)}
</div>
{error && (
<p role="alert" className="mx-2 mt-2 text-[11px] text-status-danger">
{error}
</p>
)}
{menu && (
<WorkspaceRowMenu
lane={menu.lane}
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)}
/>
)}
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={(body) => void confirmDestructive(body)}
/>
)}
<AddLaneModal
open={addOpen}
cwdSuggestions={cwdSuggestions}
prefill={addPrefill}
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
}}
onAdded={(lane) => {
void refreshLanes();
select(lane.id);
}}
/>
</section>
);
}
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
<div
aria-hidden
data-testid="workspace-drop-line"
className="pointer-events-none absolute inset-x-1 -top-px z-10 h-0.5 rounded-full bg-accent-solid"
/>
);
}
@@ -5,9 +5,10 @@
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, within } from "@testing-library/react";
// render is used inside renderCard helper
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { AgentCard } from "../AgentCard";
import type { Agent } from "../../lib/types";
import { formatModelName, fmtCost } from "../../lib/format";
@@ -239,7 +240,7 @@ describe("AgentCard", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "waiting" })} />);
const card = container.querySelector(".card-hover");
expect(card?.className).toContain("border-l-2");
expect(card?.className).toContain("border-l-yellow-500/60");
expect(card?.className).toContain("border-l-status-warning/60");
});
it("should not apply active border for completed agents", () => {
@@ -266,7 +267,7 @@ describe("AgentCard", () => {
);
expect(screen.getByText("Waiting")).toBeInTheDocument();
const card = container.querySelector(".card-hover");
expect(card?.className).toContain("border-l-yellow-500/60");
expect(card?.className).toContain("border-l-status-warning/60");
});
it("keeps the card badge compact: reason is tooltip-only, no inline chip", () => {
@@ -325,4 +326,42 @@ describe("AgentCard", () => {
);
expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument();
});
it("right-click opens the session menu; its items act without clicking the card", async () => {
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
const onClick = vi.fn();
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route
path="*"
element={
<>
<AgentCard
agent={makeAgent()}
session={{ id: "sess-1", lane_id: 4, cwd: "/r/app" } as never}
onClick={onClick}
/>
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
const user = userEvent.setup();
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
const menu = screen.getByTestId("session-row-menu-sess-1");
await user.click(within(menu).getByText("Copy folder path"));
expect(await navigator.clipboard.readText()).toBe("/r/app");
expect(onClick).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
await user.click(screen.getByText("Open in its workspace"));
expect(screen.getByTestId("where")).toHaveTextContent("/workspace?lane=4&session=sess-1");
expect(onClick).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,79 @@
/**
* @file AlertsNotifications.test.tsx
* @description Alert rules are editable through the create form (PATCH, type
* fixed), and an invalid cooldown blocks saving instead of silently becoming 300.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const update = vi.fn(async () => ({ rule: {} }));
const create = vi.fn(async () => ({ rule: {} }));
vi.mock("../../lib/api", () => ({
api: {
alerts: {
rules: {
list: vi.fn(async () => ({
rules: [
{
id: "r1",
name: "Idle",
rule_type: "inactivity",
config: { minutes: 30 },
enabled: true,
cooldown_seconds: 60,
},
],
})),
update: (...a: unknown[]) => update(...(a as [])),
create: (...a: unknown[]) => create(...(a as [])),
remove: vi.fn(),
},
list: vi.fn(async () => ({ alerts: [], total: 0, unacked: 0 })),
ack: vi.fn(),
ackAll: vi.fn(),
},
webhooks: {
list: vi.fn(async () => ({ targets: [] })),
providers: vi.fn(async () => ({ providers: [] })),
},
},
}));
import { AlertsNotifications } from "../AlertsNotifications";
const renderIt = () =>
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
describe("AlertsNotifications rules", () => {
it("edits a rule in place and validates the cooldown", async () => {
renderIt();
await userEvent.click(await screen.findByRole("button", { name: /edit rule/i }));
const name = screen.getByDisplayValue("Idle");
const cooldown = screen.getByDisplayValue("60");
await userEvent.clear(cooldown);
await userEvent.type(cooldown, "-5");
const save = screen.getByRole("button", { name: /save rule/i });
expect(save).toBeDisabled();
await userEvent.clear(cooldown);
await userEvent.type(cooldown, "120");
await userEvent.clear(name);
await userEvent.type(name, "Idle long");
await userEvent.click(save);
await waitFor(() =>
expect(update).toHaveBeenCalledWith("r1", {
name: "Idle long",
config: { minutes: 30 },
cooldown_seconds: 120,
})
);
expect(create).not.toHaveBeenCalled();
}, 15_000); // many typed fields: slow under a parallel run
});
@@ -0,0 +1,94 @@
/**
* @file ContextMenu.test.tsx
* @description Keyboard model of the shared context menu: focus lands on the
* first item, arrows/Home/End move (wrapping, skipping disabled items), Escape
* and Tab close and hand focus back to the trigger, and a keyboard-opened menu
* (clientX/Y 0) anchors under its element.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ContextMenu, MenuItem, useContextMenu } from "../ContextMenu";
function Harness({ onPick = () => {} }: { onPick?: (s: string) => void }) {
const { menu, open, close } = useContextMenu<string>();
return (
<>
<button onContextMenu={(e) => open(e, "row")}>trigger</button>
{menu && (
<ContextMenu x={menu.x} y={menu.y} onClose={close}>
<MenuItem onSelect={() => (onPick("a"), close())}>Alpha</MenuItem>
<MenuItem disabled onSelect={() => {}}>
Beta
</MenuItem>
<MenuItem onSelect={() => (onPick("c"), close())}>Gamma</MenuItem>
</ContextMenu>
)}
</>
);
}
const item = (name: string) => screen.getByRole("menuitem", { name });
describe("ContextMenu keyboard", () => {
it("focuses the first item and moves with arrows, Home and End", () => {
render(<Harness />);
const trigger = screen.getByText("trigger");
trigger.focus();
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
expect(item("Alpha")).toHaveFocus();
fireEvent.keyDown(item("Alpha"), { key: "ArrowDown" });
expect(item("Gamma")).toHaveFocus(); // Beta is disabled
fireEvent.keyDown(item("Gamma"), { key: "ArrowDown" });
expect(item("Alpha")).toHaveFocus(); // wraps
fireEvent.keyDown(item("Alpha"), { key: "ArrowUp" });
expect(item("Gamma")).toHaveFocus();
fireEvent.keyDown(item("Gamma"), { key: "Home" });
expect(item("Alpha")).toHaveFocus();
fireEvent.keyDown(item("Alpha"), { key: "End" });
expect(item("Gamma")).toHaveFocus();
});
it.each(["Escape", "Tab"])("%s closes and returns focus to the trigger", (key) => {
render(<Harness />);
const trigger = screen.getByText("trigger");
trigger.focus();
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
fireEvent.keyDown(item("Alpha"), { key });
expect(screen.queryByRole("menu")).toBeNull();
expect(trigger).toHaveFocus();
});
it("Escape in the menu does not reach a document listener (a dialog behind)", () => {
const behind = vi.fn();
document.addEventListener("keydown", behind);
render(<Harness />);
fireEvent.contextMenu(screen.getByText("trigger"), { clientX: 40, clientY: 50 });
fireEvent.keyDown(item("Alpha"), { key: "Escape" });
document.removeEventListener("keydown", behind);
expect(behind).not.toHaveBeenCalled();
});
it("returns focus to the trigger after an item runs", () => {
const pick = vi.fn();
render(<Harness onPick={pick} />);
const trigger = screen.getByText("trigger");
trigger.focus();
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
fireEvent.click(item("Gamma"));
expect(pick).toHaveBeenCalledWith("c");
expect(trigger).toHaveFocus();
});
it("anchors a keyboard-opened menu (clientX/Y 0) under the element", () => {
render(<Harness />);
const trigger = screen.getByText("trigger");
trigger.getBoundingClientRect = () =>
({ left: 120, bottom: 80, top: 60, right: 200, width: 80, height: 20 }) as DOMRect;
fireEvent.contextMenu(trigger, { clientX: 0, clientY: 0 });
const menu = screen.getByRole("menu");
expect(menu.style.left).toBe("120px");
expect(menu.style.top).toBe("80px");
});
});
@@ -0,0 +1,28 @@
/**
* @file DateTimePicker.test.tsx
* @description An end-bound picker ("To") picks the very end of a day, so an
* event at 23:59:30 is still inside the range.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DateTimePicker } from "../DateTimePicker";
describe("DateTimePicker", () => {
it("endOfDay picks 23:59:59.999 of the chosen day", () => {
const onChange = vi.fn();
render(<DateTimePicker value="" onChange={onChange} endOfDay aria-label="To" />);
fireEvent.click(screen.getByRole("button", { name: "To" }));
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
expect(onChange.mock.calls[0]![0]).toMatch(/T23:59:59\.999$/);
});
it("a start bound picks midnight", () => {
const onChange = vi.fn();
render(<DateTimePicker value="" onChange={onChange} aria-label="From" />);
fireEvent.click(screen.getByRole("button", { name: "From" }));
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
expect(onChange.mock.calls[0]![0]).toMatch(/T00:00$/);
});
});
@@ -33,7 +33,6 @@ describe("DocumentTitle", () => {
it("titles the other top-level routes", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/workflows")).toMatch(/^Workflows · /);
expect(titleFor("/settings")).toMatch(/^Settings · /);
});
});
@@ -9,7 +9,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { EventFilters, EMPTY_FILTERS, isEmptyFilters } from "../EventFilters";
import {
EventFilters,
EMPTY_FILTERS,
isEmptyFilters,
filtersFromParams,
filtersToParams,
} from "../EventFilters";
import type { EventFiltersValue } from "../EventFilters";
import { api } from "../../lib/api";
@@ -82,4 +88,49 @@ describe("EventFilters", () => {
fireEvent.click(clear);
expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS);
});
it("a debounced search keeps a facet toggled meanwhile (latest value, not the stale one)", async () => {
vi.useFakeTimers();
try {
const onChange = vi.fn();
const { rerender } = render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
fireEvent.change(screen.getByPlaceholderText(/search summary/i), {
target: { value: "curl" },
});
// The parent applies a toggle before the 300 ms elapse.
const toggled = { ...EMPTY_FILTERS, status: ["error"] };
rerender(<EventFilters value={toggled} onChange={onChange} />);
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(onChange).toHaveBeenLastCalledWith({ ...toggled, q: "curl" });
} finally {
vi.useRealTimers();
}
});
it("searches sessions on the server, 20 at a time", async () => {
const list = vi
.spyOn(api.sessions, "list")
.mockResolvedValue({ sessions: [], total: 0, limit: 20, offset: 0 });
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} sessionOptions={[]} />);
fireEvent.click(screen.getByRole("button", { name: /session/i }));
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: undefined, limit: 20 }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "abc" } });
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: "abc", limit: 20 }));
});
it("round-trips filters through URL params, leaving other keys alone", () => {
const value: EventFiltersValue = {
...EMPTY_FILTERS,
event_type: ["Stop", "PreToolUse"],
status: ["error"],
q: "curl",
to: "2026-01-02T23:59",
};
const params = filtersToParams(value, new URLSearchParams("tab=timeline"));
expect(params.get("tab")).toBe("timeline");
expect(filtersFromParams(params)).toEqual(value);
expect(filtersToParams(EMPTY_FILTERS, params).toString()).toBe("tab=timeline");
});
});
@@ -0,0 +1,97 @@
/**
* @file Layout.test.tsx
* @description Responsive shell: with no stored preference the sidebar starts
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
* from a menu button — a modal dialog that Escape and the backdrop close,
* handing focus back to the button.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
vi.mock("../Sagi/Sagi", () => ({ Sagi: () => null }));
vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
import { Layout, DRAWER_QUERY } from "../Layout";
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
function viewport(width: number) {
vi.stubGlobal("matchMedia", (q: string) => {
const max = Number(/max-width:\s*([\d.]+)px/.exec(q)?.[1] ?? Infinity);
return {
matches: width <= max,
media: q,
addEventListener: () => {},
removeEventListener: () => {},
};
});
}
afterEach(() => {
vi.unstubAllGlobals();
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
});
describe("loadCollapsed", () => {
it("starts collapsed below lg when nothing is stored", () => {
viewport(800);
expect(loadCollapsed()).toBe(true);
viewport(1440);
expect(loadCollapsed()).toBe(false);
});
it("a stored choice wins over the viewport", () => {
viewport(800);
localStorage.setItem(SIDEBAR_STORAGE_KEY, "false");
expect(loadCollapsed()).toBe(false);
});
});
describe("Layout drawer", () => {
it("below md: the sidebar is off-canvas until the menu button opens it", async () => {
viewport(600);
expect(window.matchMedia(DRAWER_QUERY).matches).toBe(true);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
const aside = screen.getByTestId("sidebar");
expect(aside.className).toContain("invisible");
expect(aside).not.toHaveAttribute("role", "dialog");
expect(screen.getByRole("main").style.marginLeft).toBe("0rem");
const open = screen.getByRole("button", { name: "Open menu" });
open.focus();
fireEvent.click(open);
expect(aside).toHaveAttribute("role", "dialog");
expect(aside).toHaveAttribute("aria-modal", "true");
expect(aside.className).not.toContain("invisible");
await waitFor(() => expect(aside.contains(document.activeElement)).toBe(true));
fireEvent.keyDown(document, { key: "Escape" });
expect(aside.className).toContain("invisible");
expect(open).toHaveFocus();
fireEvent.click(open);
fireEvent.click(screen.getByTestId("sidebar-backdrop"));
expect(aside.className).toContain("invisible");
});
it("at md and up: no menu button, the sidebar is a landmark beside the content", () => {
viewport(800);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
expect(screen.queryByRole("button", { name: "Open menu" })).toBeNull();
expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeInTheDocument();
// Collapsed rail by default below lg, so the content is not covered.
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
});
});
@@ -0,0 +1,75 @@
/**
* @file MarkdownPreview.test.tsx
* @description The markdown preview: GFM renders (headings, tables, task
* lists), raw HTML stays text, links open in a new tab, frontmatter is a
* code block; and the config FileViewer offers Raw / Preview only for a
* markdown file.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { FileViewer } from "../config/FileViewer";
describe("MarkdownPreview", () => {
it("renders GFM: headings, tables and task lists", () => {
render(
<MarkdownPreview
text={"# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n"}
/>
);
expect(screen.getByRole("heading", { level: 1, name: "Title" })).toBeInTheDocument();
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
it("never runs raw HTML and opens links in a new tab", () => {
const { container } = render(
<MarkdownPreview
text={'<script>alert(1)</script><img src=x onerror="alert(1)">\n\n[x](https://a.example)'}
/>
);
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("img")).toBeNull();
const link = screen.getByRole("link", { name: "x" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noreferrer noopener");
});
it("shows frontmatter as a block, not as a heading", () => {
const { container } = render(
<MarkdownPreview text={"---\nname: demo\ndescription: d\n---\n\nBody text\n"} />
);
expect(container.querySelector(".md-frontmatter")).toHaveTextContent("name: demo");
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByText("Body text")).toBeInTheDocument();
});
it("knows a markdown path", () => {
expect(isMarkdown("/a/SKILL.md")).toBe(true);
expect(isMarkdown("README.markdown")).toBe(true);
expect(isMarkdown("config.toml")).toBe(false);
expect(isMarkdown(null)).toBe(false);
});
});
describe("FileViewer — markdown preview", () => {
const data = { text: "# Hello\n", truncated: false } as never;
it("switches a markdown file between raw and preview", async () => {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
expect(screen.queryByTestId("markdown-preview")).toBeNull();
});
it("offers no preview for another file type", () => {
render(<FileViewer state={{ path: "/x/config.toml", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByRole("button", { name: "Preview" })).toBeNull();
});
});
@@ -0,0 +1,69 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { PathPickButton } from "../PathPickButton";
vi.mock("../../lib/api", () => ({
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
}));
vi.mock("../../lib/toast", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
const fail = (code: string) => Object.assign(new Error(code), { code });
beforeEach(() => {
vi.mocked(api.lanes.pickPath).mockReset();
vi.mocked(api.lanes.browse)
.mockReset()
.mockResolvedValue({ path: "/tmp", parent: "/", entries: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.error).mockReset();
});
describe("PathPickButton", () => {
it("fills the field with the chosen path and leaves it alone on Cancel", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ path: "/home/u/proj" });
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ cancelled: true });
render(<PathPickButton start=" /home/u " onPick={onPick} />);
const button = screen.getByTitle("Browse for a folder");
await userEvent.click(button);
await waitFor(() => expect(onPick).toHaveBeenCalledWith("/home/u/proj"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("dir", "/home/u");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(onPick).toHaveBeenCalledTimes(1);
});
it("opens the in-app folder browser on any failure, not only a missing picker", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EBUSY"));
render(<PathPickButton onPick={vi.fn()} />);
await userEvent.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByRole("dialog", { name: "Browse for a folder" })).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
expect(screen.queryByRole("dialog")).toBeNull();
});
});
@@ -0,0 +1,82 @@
/**
* @file RouteErrorBoundary.test.tsx
* @description The route error boundary: a failed lazy chunk (stale assets after
* a self-update) reloads the page once; any other error — or a second chunk
* failure — shows the themed Reload fallback instead of a blank app.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { RouteErrorBoundary, isChunkLoadError, reloadOnce } from "../RouteErrorBoundary";
function Boom({ error }: { error: Error }): never {
throw error;
}
let reload: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.clear();
reload = vi.fn();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
describe("RouteErrorBoundary", () => {
it("recognises dynamic-import failures", () => {
expect(
isChunkLoadError(new TypeError("Failed to fetch dynamically imported module: /a.js"))
).toBe(true);
expect(isChunkLoadError(Object.assign(new Error("x"), { name: "ChunkLoadError" }))).toBe(true);
expect(isChunkLoadError(new Error("Importing a module script failed."))).toBe(true);
expect(isChunkLoadError(new Error("boom"))).toBe(false);
});
it("reloads once on a chunk error, never twice in a row", () => {
expect(reloadOnce(reload)).toBe(true);
expect(reloadOnce(reload)).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);
});
it("shows a Reload fallback for an ordinary render error", () => {
render(
<RouteErrorBoundary reload={reload}>
<Boom error={new Error("boom")} />
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toHaveTextContent(/something went wrong/i);
expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument();
expect(reload).not.toHaveBeenCalled();
});
it("clears the error when the route changes, so the next page renders", () => {
const { rerender } = render(
<RouteErrorBoundary reload={reload} resetKey="/workspace">
<Boom error={new Error("boom")} />
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
rerender(
<RouteErrorBoundary reload={reload} resetKey="/workspace">
<p>settings page</p>
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
rerender(
<RouteErrorBoundary reload={reload} resetKey="/settings/general">
<p>settings page</p>
</RouteErrorBoundary>
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText("settings page")).toBeInTheDocument();
});
it("reloads on a chunk error the first time", () => {
render(
<RouteErrorBoundary reload={reload}>
<Boom error={new TypeError("Failed to fetch dynamically imported module: /x.js")} />
</RouteErrorBoundary>
);
expect(reload).toHaveBeenCalledTimes(1);
});
});

Some files were not shown because too many files have changed in this diff Show More