feat(dashboard): merge Analytics into Dashboard, remove Kanban Board
Closes issue #5 (Dashboard | Update menu & page): - Analytics content (heatmap, sparkline, token/cost/workflow/productivity charts) moves into a new "Analytics" tab on the Dashboard page, alongside the existing Monitor/Health tabs. The standalone Analytics page, its /analytics route, and its sidebar menu entry are removed. - The Kanban Board page, its /kanban route, and its sidebar menu entry are removed entirely (page content deleted, not just hidden). - Updated ARCHITECTURE.md, README.md, client/README.md, docs/API.md, and the affected client tests/snapshots to match.
This commit is contained in:
+14
-25
@@ -428,20 +428,18 @@ graph TD
|
|||||||
APP["App.tsx<br/>Router + WebSocket"]
|
APP["App.tsx<br/>Router + WebSocket"]
|
||||||
LAYOUT["Layout.tsx<br/>Sidebar + Outlet"]
|
LAYOUT["Layout.tsx<br/>Sidebar + Outlet"]
|
||||||
SIDEBAR["Sidebar.tsx<br/>Nav (scroll-bounded with overflow<br/>chevrons) + Connection Status"]
|
SIDEBAR["Sidebar.tsx<br/>Nav (scroll-bounded with overflow<br/>chevrons) + Connection Status"]
|
||||||
DASH["Dashboard.tsx"]
|
DASH["Dashboard.tsx<br/>(Monitor / Analytics / Health tabs)"]
|
||||||
KANBAN["KanbanBoard.tsx"]
|
|
||||||
SESS["Sessions.tsx"]
|
SESS["Sessions.tsx"]
|
||||||
DETAIL["SessionDetail.tsx"]
|
DETAIL["SessionDetail.tsx"]
|
||||||
ACTIVITY["ActivityFeed.tsx"]
|
ACTIVITY["ActivityFeed.tsx"]
|
||||||
SETTINGS_P["Settings.tsx"]
|
SETTINGS_P["Settings.tsx"]
|
||||||
|
|
||||||
ANALYTICS_P["Analytics.tsx"]
|
|
||||||
WORKFLOWS_P["Workflows.tsx"]
|
WORKFLOWS_P["Workflows.tsx"]
|
||||||
NOTFOUND["NotFound.tsx"]
|
NOTFOUND["NotFound.tsx"]
|
||||||
|
|
||||||
APP --> LAYOUT
|
APP --> LAYOUT
|
||||||
LAYOUT --> SIDEBAR
|
LAYOUT --> SIDEBAR
|
||||||
LAYOUT --> DASH & KANBAN & SESS & DETAIL & ACTIVITY & ANALYTICS_P & WORKFLOWS_P & SETTINGS_P & NOTFOUND
|
LAYOUT --> DASH & SESS & DETAIL & ACTIVITY & WORKFLOWS_P & SETTINGS_P & NOTFOUND
|
||||||
|
|
||||||
DASH --> SC1["StatCard x6<br/>(sessions/agents/subagents/<br/>events today/total events/cost)<br/>3-column grid"]
|
DASH --> SC1["StatCard x6<br/>(sessions/agents/subagents/<br/>events today/total events/cost)<br/>3-column grid"]
|
||||||
DASH --> AC1["AgentCard[]<br/>with collapsible subagent hierarchy"]
|
DASH --> AC1["AgentCard[]<br/>with collapsible subagent hierarchy"]
|
||||||
@@ -547,25 +545,23 @@ graph TD
|
|||||||
EB --> TYPES
|
EB --> TYPES
|
||||||
|
|
||||||
subgraph Pages
|
subgraph Pages
|
||||||
D[Dashboard]
|
D["Dashboard<br/>(Monitor / Analytics / Health)"]
|
||||||
K[KanbanBoard]
|
|
||||||
S[Sessions]
|
S[Sessions]
|
||||||
SD[SessionDetail]
|
SD[SessionDetail]
|
||||||
AF[ActivityFeed]
|
AF[ActivityFeed]
|
||||||
AN[Analytics]
|
|
||||||
WF[Workflows]
|
WF[Workflows]
|
||||||
SET[Settings]
|
SET[Settings]
|
||||||
NF[NotFound]
|
NF[NotFound]
|
||||||
end
|
end
|
||||||
|
|
||||||
APP --> D & K & S & SD & AF & AN & WF
|
APP --> D & S & SD & AF & WF
|
||||||
D & K & S & SD & AF & AN & WF --> API
|
D & S & SD & AF & WF --> API
|
||||||
D & K & S & SD & AF & AN & WF --> EB
|
D & S & SD & AF & WF --> EB
|
||||||
D & K & S & SD & AF & AN & WF --> FMT
|
D & S & SD & AF & WF --> FMT
|
||||||
SET --> API
|
SET --> API
|
||||||
SET --> EB
|
SET --> EB
|
||||||
SET --> FMT
|
SET --> FMT
|
||||||
D & K & S & AF & AN & WF --> DS
|
D & S & AF & WF --> DS
|
||||||
DS --> API
|
DS --> API
|
||||||
API --> TYPES
|
API --> TYPES
|
||||||
|
|
||||||
@@ -580,7 +576,6 @@ graph TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
D --> STC & AGC & STB
|
D --> STC & AGC & STB
|
||||||
K --> AGC
|
|
||||||
S --> STB & ES
|
S --> STB & ES
|
||||||
SD --> AGC & STB
|
SD --> AGC & STB
|
||||||
AF --> STB & ES
|
AF --> STB & ES
|
||||||
@@ -598,12 +593,10 @@ graph TD
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph LR
|
graph LR
|
||||||
ROOT["/ (index)"] --> DASH[Dashboard]
|
ROOT["/ (index)"] --> DASH["Dashboard<br/>(Monitor / Analytics / Health)"]
|
||||||
KANBAN_R["/kanban"] --> KANBAN[KanbanBoard]
|
|
||||||
SESS_R["/sessions"] --> SESS[Sessions]
|
SESS_R["/sessions"] --> SESS[Sessions]
|
||||||
DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail]
|
DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail]
|
||||||
ACT_R["/activity"] --> ACT[ActivityFeed]
|
ACT_R["/activity"] --> ACT[ActivityFeed]
|
||||||
AN_R["/analytics"] --> AN[Analytics]
|
|
||||||
WF_R["/workflows"] --> WF[Workflows]
|
WF_R["/workflows"] --> WF[Workflows]
|
||||||
CC_R["/cc-config"] --> CC[CcConfig]
|
CC_R["/cc-config"] --> CC[CcConfig]
|
||||||
LANES_R["/lanes"] -->|redirect| RUN_R["/run"]
|
LANES_R["/lanes"] -->|redirect| RUN_R["/run"]
|
||||||
@@ -616,12 +609,10 @@ graph LR
|
|||||||
|
|
||||||
| Route | Page | Data Sources |
|
| Route | Page | Data Sources |
|
||||||
| --------------- | ------------- | ------------------------------------------------------ |
|
| --------------- | ------------- | ------------------------------------------------------ |
|
||||||
| `/` | Dashboard | Two tabs (Monitor / Health). Monitor: `GET /api/stats`, `GET /api/agents`, `GET /api/events`, `GET /api/agents?session_id={sid}` (subagent hierarchy), dynamic item counts via `ResizeObserver`. Health: `GET /api/settings/info` + `GET /api/workflows` (5 s auto-refresh) — composite health score, storage donut, cache/error/success gauges, tool invocation bars, subagent effectiveness, model token distribution, compaction stats |
|
| `/` | Dashboard | Three tabs (Monitor / Analytics / Health). Monitor: `GET /api/stats`, `GET /api/agents`, `GET /api/events`, `GET /api/agents?session_id={sid}` (subagent hierarchy), dynamic item counts via `ResizeObserver`. Analytics (merged from the former standalone `/analytics` page): `GET /api/analytics` + `GET /api/pricing/cost` — event heatmap, cost/token/workflow/productivity sub-tabs. Health: `GET /api/settings/info` + `GET /api/workflows` (5 s auto-refresh) — composite health score, storage donut, cache/error/success gauges, tool invocation bars, subagent effectiveness, model token distribution, compaction stats |
|
||||||
| `/kanban` | KanbanBoard | View toggle persisted in `localStorage`. Agents view: `GET /api/agents?status={each}` per-status (default 10000 cap). Sessions view: `GET /api/sessions?status={each}&limit=10000` per-status. Each column then paginates client-side at `COLUMN_PAGE_SIZE=10`; the WS subscription scopes to the active view. |
|
|
||||||
| `/sessions` | Sessions | `GET /api/sessions?status=&q=&limit=PAGE_SIZE&offset=page*PAGE_SIZE` — true server-side pagination. The search box passes `q` to the server (300 ms debounced). Response carries `total` for the paginator UI. Cost computation runs server-side over the visible page only. Polls `/api/run` (and listens for `run_status`) to badge any row whose session is currently being driven from `/run` with a clickable green **▶ Run** pill |
|
| `/sessions` | Sessions | `GET /api/sessions?status=&q=&limit=PAGE_SIZE&offset=page*PAGE_SIZE` — true server-side pagination. The search box passes `q` to the server (300 ms debounced). Response carries `total` for the paginator UI. Cost computation runs server-side over the visible page only. Polls `/api/run` (and listens for `run_status`) to badge any row whose session is currently being driven from `/run` with a clickable green **▶ Run** pill |
|
||||||
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Kanban/Dashboard) stay tooltip-only |
|
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only |
|
||||||
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
|
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
|
||||||
| `/analytics` | Analytics | `GET /api/analytics` |
|
|
||||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||||
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
||||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
|
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
|
||||||
@@ -1852,19 +1843,17 @@ graph TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Page State"
|
subgraph "Page State"
|
||||||
US1["useState<br/>Dashboard"]
|
US1["useState<br/>Dashboard<br/>(Monitor/Analytics/Health)"]
|
||||||
US2["useState<br/>KanbanBoard"]
|
|
||||||
US3["useState<br/>Sessions"]
|
US3["useState<br/>Sessions"]
|
||||||
US4["useState<br/>SessionDetail"]
|
US4["useState<br/>SessionDetail"]
|
||||||
US5["useState<br/>ActivityFeed"]
|
US5["useState<br/>ActivityFeed"]
|
||||||
US6["useState<br/>Analytics"]
|
|
||||||
US8["useState<br/>Workflows"]
|
US8["useState<br/>Workflows"]
|
||||||
US7["useState<br/>Settings"]
|
US7["useState<br/>Settings"]
|
||||||
end
|
end
|
||||||
|
|
||||||
REST --> US1 & US2 & US3 & US4 & US5 & US6 & US8 & US7
|
REST --> US1 & US3 & US4 & US5 & US8 & US7
|
||||||
WSM --> EB
|
WSM --> EB
|
||||||
EB --> US1 & US2 & US3 & US4 & US5 & US6 & US8 & US7
|
EB --> US1 & US3 & US4 & US5 & US8 & US7
|
||||||
EB --> NOTIF_H
|
EB --> NOTIF_H
|
||||||
EB --> SAGI_H
|
EB --> SAGI_H
|
||||||
LS --> NOTIF_H
|
LS --> NOTIF_H
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Internal build — all rights reserved.
|
|||||||
rendered with xterm.js) attached to a lane's directory — the exact TUI you'd
|
rendered with xterm.js) attached to a lane's directory — the exact TUI you'd
|
||||||
see locally, fully interactive, resumable, and attachable from a real
|
see locally, fully interactive, resumable, and attachable from a real
|
||||||
terminal too via `ccam lanes shell`.
|
terminal too via `ccam lanes shell`.
|
||||||
- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
|
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus an MCP server and a CLI.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
+7
-17
@@ -67,12 +67,10 @@ graph TB
|
|||||||
Layout[Layout Component]
|
Layout[Layout Component]
|
||||||
|
|
||||||
subgraph "Pages"
|
subgraph "Pages"
|
||||||
Home[Dashboard]
|
Home[Dashboard<br/>Monitor / Analytics / Health tabs]
|
||||||
Kanban[KanbanBoard]
|
|
||||||
Sessions[Sessions]
|
Sessions[Sessions]
|
||||||
Detail[SessionDetail]
|
Detail[SessionDetail]
|
||||||
Feed[ActivityFeed]
|
Feed[ActivityFeed]
|
||||||
Analytics[Analytics]
|
|
||||||
Workflows[Workflows]
|
Workflows[Workflows]
|
||||||
Settings[Settings]
|
Settings[Settings]
|
||||||
end
|
end
|
||||||
@@ -100,7 +98,7 @@ graph TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
Router --> Layout
|
Router --> Layout
|
||||||
Layout --> Home & Kanban & Sessions & Detail & Feed & Analytics & Workflows & Settings
|
Layout --> Home & Sessions & Detail & Feed & Workflows & Settings
|
||||||
Home & Detail --> AgentCard & StatCard & StatusBadge
|
Home & Detail --> AgentCard & StatCard & StatusBadge
|
||||||
Feed --> EventDetail
|
Feed --> EventDetail
|
||||||
API --> REST
|
API --> REST
|
||||||
@@ -183,12 +181,10 @@ client/
|
|||||||
│ │ └── workflows/ # D3.js workflow visualization components (12 files)
|
│ │ └── workflows/ # D3.js workflow visualization components (12 files)
|
||||||
│ │
|
│ │
|
||||||
│ ├── pages/ # Route pages
|
│ ├── pages/ # Route pages
|
||||||
│ │ ├── Dashboard.tsx
|
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
|
||||||
│ │ ├── KanbanBoard.tsx
|
|
||||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
│ │ ├── 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)
|
│ │ ├── 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
|
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||||
│ │ ├── Analytics.tsx
|
|
||||||
│ │ ├── Workflows.tsx
|
│ │ ├── Workflows.tsx
|
||||||
│ │ ├── Settings.tsx
|
│ │ ├── Settings.tsx
|
||||||
│ │ └── NotFound.tsx
|
│ │ └── NotFound.tsx
|
||||||
@@ -230,17 +226,15 @@ graph TB
|
|||||||
App[App.tsx<br/>Router + WS + Notifications]
|
App[App.tsx<br/>Router + WS + Notifications]
|
||||||
Layout[Layout.tsx<br/>Sidebar + Outlet]
|
Layout[Layout.tsx<br/>Sidebar + Outlet]
|
||||||
|
|
||||||
Dashboard[Dashboard<br/>Monitor tab: stats + agents + events<br/>Health tab: SystemHealthTab]
|
Dashboard[Dashboard<br/>Monitor tab: stats + agents + events<br/>Analytics tab: tokens + heatmap + trends<br/>Health tab: SystemHealthTab]
|
||||||
Kanban[KanbanBoard<br/>4-column agent board]
|
|
||||||
Sessions[Sessions<br/>filterable table]
|
Sessions[Sessions<br/>filterable table]
|
||||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||||
Feed[ActivityFeed<br/>streaming event log]
|
Feed[ActivityFeed<br/>streaming event log]
|
||||||
Analytics[Analytics<br/>tokens + heatmap + trends]
|
|
||||||
Workflows[Workflows<br/>D3.js visualizations]
|
Workflows[Workflows<br/>D3.js visualizations]
|
||||||
Settings[Settings<br/>pricing + notifications + hooks]
|
Settings[Settings<br/>pricing + notifications + hooks]
|
||||||
|
|
||||||
App --> Layout
|
App --> Layout
|
||||||
Layout --> Dashboard & Kanban & Sessions & Detail & Feed & Analytics & Workflows & Settings
|
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
||||||
|
|
||||||
Dashboard --> StatCard[StatCard × 6]
|
Dashboard --> StatCard[StatCard × 6]
|
||||||
Dashboard --> AgentCard[AgentCard × N]
|
Dashboard --> AgentCard[AgentCard × N]
|
||||||
@@ -450,18 +444,16 @@ useEffect(() => {
|
|||||||
graph TB
|
graph TB
|
||||||
Root["/"]
|
Root["/"]
|
||||||
Dashboard["/ (Dashboard)"]
|
Dashboard["/ (Dashboard)"]
|
||||||
Kanban["/kanban"]
|
|
||||||
Sessions["/sessions"]
|
Sessions["/sessions"]
|
||||||
Detail["/sessions/:id"]
|
Detail["/sessions/:id"]
|
||||||
Feed["/activity"]
|
Feed["/activity"]
|
||||||
Analytics["/analytics"]
|
|
||||||
Workflows["/workflows"]
|
Workflows["/workflows"]
|
||||||
CcConfig["/cc-config"]
|
CcConfig["/cc-config"]
|
||||||
Run["/run"]
|
Run["/run"]
|
||||||
Settings["/settings"]
|
Settings["/settings"]
|
||||||
NF["/* (NotFound)"]
|
NF["/* (NotFound)"]
|
||||||
|
|
||||||
Root --> Dashboard & Kanban & Sessions & Detail & Feed & Analytics & Workflows & CcConfig & Run & Settings & NF
|
Root --> Dashboard & Sessions & Detail & Feed & Workflows & CcConfig & Run & Settings & NF
|
||||||
|
|
||||||
style Dashboard fill:#3B82F6
|
style Dashboard fill:#3B82F6
|
||||||
style Detail fill:#3B82F6
|
style Detail fill:#3B82F6
|
||||||
@@ -480,11 +472,9 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="kanban" element={<KanbanBoard />} />
|
|
||||||
<Route path="sessions" element={<Sessions />} />
|
<Route path="sessions" element={<Sessions />} />
|
||||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||||
<Route path="activity" element={<ActivityFeed />} />
|
<Route path="activity" element={<ActivityFeed />} />
|
||||||
<Route path="analytics" element={<Analytics />} />
|
|
||||||
<Route path="workflows" element={<Workflows />} />
|
<Route path="workflows" element={<Workflows />} />
|
||||||
<Route path="cc-config" element={<CcConfig />} />
|
<Route path="cc-config" element={<CcConfig />} />
|
||||||
<Route path="run" element={<Run />} />
|
<Route path="run" element={<Run />} />
|
||||||
@@ -883,7 +873,7 @@ test('renders session title and cost', () => {
|
|||||||
### Snapshot Testing
|
### Snapshot Testing
|
||||||
|
|
||||||
`pages/__tests__/screens.snapshot.test.tsx` renders **every routed screen**
|
`pages/__tests__/screens.snapshot.test.tsx` renders **every routed screen**
|
||||||
(Dashboard, Kanban, Sessions, Session detail, Activity feed, Analytics,
|
(Dashboard, Sessions, Session detail, Activity feed,
|
||||||
Workflows, Claude Config, Run, Settings, Not found) and asserts each against a
|
Workflows, Claude Config, Run, Settings, Not found) and asserts each against a
|
||||||
committed snapshot in `pages/__tests__/__snapshots__/`. These are structural
|
committed snapshot in `pages/__tests__/__snapshots__/`. These are structural
|
||||||
regression guards — they catch unintended changes to layout, markup, or
|
regression guards — they catch unintended changes to layout, markup, or
|
||||||
|
|||||||
@@ -39,11 +39,9 @@
|
|||||||
* - `./components/Layout`
|
* - `./components/Layout`
|
||||||
* - `./components/SplashScreen`
|
* - `./components/SplashScreen`
|
||||||
* - `./pages/Dashboard`
|
* - `./pages/Dashboard`
|
||||||
* - `./pages/KanbanBoard`
|
|
||||||
* - `./pages/Sessions`
|
* - `./pages/Sessions`
|
||||||
* - `./pages/SessionDetail`
|
* - `./pages/SessionDetail`
|
||||||
* - `./pages/ActivityFeed`
|
* - `./pages/ActivityFeed`
|
||||||
* - `./pages/Analytics`
|
|
||||||
* - `./pages/Workflows`
|
* - `./pages/Workflows`
|
||||||
* - `./pages/Settings`
|
* - `./pages/Settings`
|
||||||
* - `./pages/CcConfig`
|
* - `./pages/CcConfig`
|
||||||
@@ -66,11 +64,9 @@ import { Layout } from "./components/Layout";
|
|||||||
import { DocumentTitle } from "./components/DocumentTitle";
|
import { DocumentTitle } from "./components/DocumentTitle";
|
||||||
import { SplashScreen } from "./components/SplashScreen";
|
import { SplashScreen } from "./components/SplashScreen";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { KanbanBoard } from "./pages/KanbanBoard";
|
|
||||||
import { Sessions } from "./pages/Sessions";
|
import { Sessions } from "./pages/Sessions";
|
||||||
import { SessionDetail } from "./pages/SessionDetail";
|
import { SessionDetail } from "./pages/SessionDetail";
|
||||||
import { ActivityFeed } from "./pages/ActivityFeed";
|
import { ActivityFeed } from "./pages/ActivityFeed";
|
||||||
import { Analytics } from "./pages/Analytics";
|
|
||||||
import { Workflows } from "./pages/Workflows";
|
import { Workflows } from "./pages/Workflows";
|
||||||
import { Settings } from "./pages/Settings";
|
import { Settings } from "./pages/Settings";
|
||||||
import { CcConfig } from "./pages/CcConfig";
|
import { CcConfig } from "./pages/CcConfig";
|
||||||
@@ -101,11 +97,9 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout wsConnected={connected} />}>
|
<Route element={<Layout wsConnected={connected} />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="kanban" element={<KanbanBoard />} />
|
|
||||||
<Route path="sessions" element={<Sessions />} />
|
<Route path="sessions" element={<Sessions />} />
|
||||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||||
<Route path="activity" element={<ActivityFeed />} />
|
<Route path="activity" element={<ActivityFeed />} />
|
||||||
<Route path="analytics" element={<Analytics />} />
|
|
||||||
<Route path="workflows" element={<Workflows />} />
|
<Route path="workflows" element={<Workflows />} />
|
||||||
<Route path="lanes" element={<Navigate to="/run" replace />} />
|
<Route path="lanes" element={<Navigate to="/run" replace />} />
|
||||||
<Route path="cc-config" element={<CcConfig />} />
|
<Route path="cc-config" element={<CcConfig />} />
|
||||||
|
|||||||
@@ -24,12 +24,8 @@ export function DocumentTitle() {
|
|||||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||||
} else if (path.startsWith("/sessions")) {
|
} else if (path.startsWith("/sessions")) {
|
||||||
title = t("sessions");
|
title = t("sessions");
|
||||||
} else if (path.startsWith("/kanban")) {
|
|
||||||
title = t("agentBoard");
|
|
||||||
} else if (path.startsWith("/activity")) {
|
} else if (path.startsWith("/activity")) {
|
||||||
title = t("activityFeed");
|
title = t("activityFeed");
|
||||||
} else if (path.startsWith("/analytics")) {
|
|
||||||
title = t("analytics");
|
|
||||||
} else if (path.startsWith("/workflows")) {
|
} else if (path.startsWith("/workflows")) {
|
||||||
title = t("workflows");
|
title = t("workflows");
|
||||||
} else if (path.startsWith("/cc-config")) {
|
} else if (path.startsWith("/cc-config")) {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { NavLink } from "react-router-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Columns3,
|
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -89,10 +88,8 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
|||||||
|
|
||||||
const NAV_KEYS = [
|
const NAV_KEYS = [
|
||||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||||
{ to: "/kanban", icon: Columns3, key: "nav:agentBoard" },
|
|
||||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||||
{ to: "/activity", icon: Activity, key: "nav:activityFeed" },
|
{ to: "/activity", icon: Activity, key: "nav:activityFeed" },
|
||||||
{ to: "/analytics", icon: BarChart3, key: "nav:analytics" },
|
|
||||||
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
|
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
|
||||||
{ to: "/cc-config", icon: Boxes, key: "nav:ccConfig" },
|
{ to: "/cc-config", icon: Boxes, key: "nav:ccConfig" },
|
||||||
{ to: "/run", icon: Play, key: "nav:workspace" },
|
{ to: "/run", icon: Play, key: "nav:workspace" },
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ describe("Sidebar", () => {
|
|||||||
it("should render all navigation links", () => {
|
it("should render all navigation links", () => {
|
||||||
renderSidebar(true);
|
renderSidebar(true);
|
||||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Kanban Board")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Activity Feed")).toBeInTheDocument();
|
expect(screen.getByText("Activity Feed")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -60,7 +59,6 @@ describe("Sidebar", () => {
|
|||||||
const links = screen.getAllByRole("link");
|
const links = screen.getAllByRole("link");
|
||||||
const hrefs = links.map((link) => link.getAttribute("href"));
|
const hrefs = links.map((link) => link.getAttribute("href"));
|
||||||
expect(hrefs).toContain("/");
|
expect(hrefs).toContain("/");
|
||||||
expect(hrefs).toContain("/kanban");
|
|
||||||
expect(hrefs).toContain("/sessions");
|
expect(hrefs).toContain("/sessions");
|
||||||
expect(hrefs).toContain("/activity");
|
expect(hrefs).toContain("/activity");
|
||||||
});
|
});
|
||||||
@@ -82,7 +80,6 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Tổng quan")).toBeInTheDocument();
|
expect(screen.getByText("Tổng quan")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Bảng Kanban")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1392
-8
File diff suppressed because it is too large
Load Diff
@@ -1,525 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file KanbanBoard.tsx
|
|
||||||
* @description Kanban-style board with two views: agents grouped by their
|
|
||||||
* AgentStatus (working/waiting/completed/error) or sessions grouped
|
|
||||||
* by their SessionStatus (active/completed/error/abandoned). The view toggle
|
|
||||||
* is persisted in localStorage so the user's choice survives reloads. Each
|
|
||||||
* column paginates client-side at COLUMN_PAGE_SIZE.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
/* =============================================================================
|
|
||||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
||||||
* =============================================================================
|
|
||||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
|
||||||
*
|
|
||||||
* ## Design constraints
|
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
||||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
||||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
||||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
||||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
||||||
*
|
|
||||||
* ## Remote data & SSH
|
|
||||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
|
||||||
* - `../lib/api`
|
|
||||||
* - `../lib/eventBus`
|
|
||||||
* - `../components/AgentCard`
|
|
||||||
* - `../components/SessionCard`
|
|
||||||
* - `../components/EmptyState`
|
|
||||||
* - `../components/Skeleton`
|
|
||||||
* - `../lib/types`
|
|
||||||
*
|
|
||||||
* ## Public surface
|
|
||||||
* - `KanbanBoard` — exported API; see TSDoc on the symbol for behavior.
|
|
||||||
*
|
|
||||||
* ## Testing pointers
|
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
||||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
||||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
|
||||||
*
|
|
||||||
* ## Related docs
|
|
||||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
||||||
* - `docs/API.md` — REST reference.
|
|
||||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
|
||||||
* ============================================================================= */
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
||||||
* -----------------------------------------------------------------------------
|
|
||||||
* **KanbanBoard**
|
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
|
||||||
*
|
|
||||||
* ----------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { RefreshCw, Columns3, ChevronDown, HelpCircle } from "lucide-react";
|
|
||||||
import { api } from "../lib/api";
|
|
||||||
import { eventBus } from "../lib/eventBus";
|
|
||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
|
||||||
import { AgentCard } from "../components/AgentCard";
|
|
||||||
import { SessionCard } from "../components/SessionCard";
|
|
||||||
import { EmptyState } from "../components/EmptyState";
|
|
||||||
import { CardSkeleton } from "../components/Skeleton";
|
|
||||||
import {
|
|
||||||
STATUS_CONFIG,
|
|
||||||
SESSION_STATUS_CONFIG,
|
|
||||||
isAgentAwaitingInput,
|
|
||||||
isSessionAwaitingInput,
|
|
||||||
} from "../lib/types";
|
|
||||||
import type {
|
|
||||||
Agent,
|
|
||||||
AgentStatus,
|
|
||||||
EffectiveAgentStatus,
|
|
||||||
EffectiveSessionStatus,
|
|
||||||
Session,
|
|
||||||
WSMessage,
|
|
||||||
} from "../lib/types";
|
|
||||||
|
|
||||||
type BoardView = "agents" | "sessions";
|
|
||||||
|
|
||||||
// Persisted statuses we fetch from the API.
|
|
||||||
const AGENT_FETCH_STATUSES: AgentStatus[] = ["working", "waiting", "completed", "error"];
|
|
||||||
|
|
||||||
// Columns rendered on the Agents board.
|
|
||||||
const AGENT_COLUMNS: EffectiveAgentStatus[] = ["working", "waiting", "completed", "error"];
|
|
||||||
const SESSION_COLUMNS: EffectiveSessionStatus[] = [
|
|
||||||
"active",
|
|
||||||
"waiting",
|
|
||||||
"completed",
|
|
||||||
"error",
|
|
||||||
"abandoned",
|
|
||||||
];
|
|
||||||
const COLUMN_PAGE_SIZE = 10;
|
|
||||||
const VIEW_STORAGE_KEY = "kanban-board-view";
|
|
||||||
|
|
||||||
function loadView(): BoardView {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(VIEW_STORAGE_KEY);
|
|
||||||
if (stored === "agents" || stored === "sessions") return stored;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return "agents";
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistView(view: BoardView): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(VIEW_STORAGE_KEY, view);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KanbanBoard() {
|
|
||||||
const { t } = useTranslation("kanban");
|
|
||||||
const [view, setViewState] = useState<BoardView>(loadView);
|
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [expanded, setExpanded] = useState<Record<string, number>>({});
|
|
||||||
|
|
||||||
const setView = useCallback((next: BoardView) => {
|
|
||||||
setViewState(next);
|
|
||||||
persistView(next);
|
|
||||||
setExpanded({}); // reset per-column pagination when switching views
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAgents = useCallback(async () => {
|
|
||||||
// Fetch every persisted agent status. Bucketing happens below in
|
|
||||||
// `groupedAgents`.
|
|
||||||
//
|
|
||||||
// Also fetch sessions so AgentCard can surface model / cwd / cost on
|
|
||||||
// main-agent cards (they have no task and a generic name on their
|
|
||||||
// own - the session metadata is what makes the card useful).
|
|
||||||
const [agentResults, sessionsRes] = await Promise.all([
|
|
||||||
Promise.all(AGENT_FETCH_STATUSES.map((status) => api.agents.list({ status }))),
|
|
||||||
api.sessions.list({ limit: 10000 }),
|
|
||||||
]);
|
|
||||||
setAgents(agentResults.flatMap((r) => r.agents));
|
|
||||||
setSessions(sessionsRes.sessions);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
|
||||||
// Each column needs the full set for its status - column-level
|
|
||||||
// pagination ("show more") is handled client-side at COLUMN_PAGE_SIZE.
|
|
||||||
// Wire-limit raised to the server's safety cap (10000); cost
|
|
||||||
// computation on the server scales with returned rows, so each
|
|
||||||
// column's request stays bounded by how many sessions actually have
|
|
||||||
// that status. The "waiting" column is derived client-side from the
|
|
||||||
// active set (see grouping below).
|
|
||||||
const persistedStatuses = SESSION_COLUMNS.filter((s) => s !== "waiting");
|
|
||||||
const results = await Promise.all(
|
|
||||||
persistedStatuses.map((status) => api.sessions.list({ status, limit: 10000 }))
|
|
||||||
);
|
|
||||||
setSessions(results.flatMap((r) => r.sessions));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
if (view === "agents") await loadAgents();
|
|
||||||
else await loadSessions();
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [view, loadAgents, loadSessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
return eventBus.subscribe((msg: WSMessage) => {
|
|
||||||
if (isRemoteDataRefreshMessage(msg)) {
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(load, 300);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (view === "agents") {
|
|
||||||
if (
|
|
||||||
msg.type === "agent_created" ||
|
|
||||||
msg.type === "agent_updated" ||
|
|
||||||
msg.type === "session_updated" ||
|
|
||||||
msg.type === "session_created"
|
|
||||||
) {
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(loadAgents, 300);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (msg.type === "session_created" || msg.type === "session_updated") {
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(loadSessions, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [view, loadAgents, loadSessions]);
|
|
||||||
|
|
||||||
// Lookup map for AgentCard's session prop - memoized to avoid rebuilding on every render
|
|
||||||
const sessionsById = useMemo(() => {
|
|
||||||
const map = new Map<string, Session>();
|
|
||||||
for (const s of sessions) map.set(s.id, s);
|
|
||||||
return map;
|
|
||||||
}, [sessions]);
|
|
||||||
|
|
||||||
// Bucket by effective status: agents with status "waiting" OR those with
|
|
||||||
// awaiting_input_since set go into the "waiting" column. Other columns
|
|
||||||
// exclude agents that belong in "waiting".
|
|
||||||
const isEffectivelyWaiting = (a: Agent) => a.status === "waiting" || isAgentAwaitingInput(a);
|
|
||||||
|
|
||||||
const groupedAgents = AGENT_COLUMNS.reduce(
|
|
||||||
(acc, status) => {
|
|
||||||
acc[status] =
|
|
||||||
status === "waiting"
|
|
||||||
? agents.filter(isEffectivelyWaiting)
|
|
||||||
: agents.filter((a) => a.status === status && !isEffectivelyWaiting(a));
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<EffectiveAgentStatus, Agent[]>
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedSessions = SESSION_COLUMNS.reduce(
|
|
||||||
(acc, status) => {
|
|
||||||
acc[status] =
|
|
||||||
status === "waiting"
|
|
||||||
? sessions.filter(isSessionAwaitingInput)
|
|
||||||
: sessions.filter((s) => s.status === status && !isSessionAwaitingInput(s));
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<EffectiveSessionStatus, Session[]>
|
|
||||||
);
|
|
||||||
|
|
||||||
const total = view === "agents" ? agents.length : sessions.length;
|
|
||||||
const subtitle =
|
|
||||||
view === "agents"
|
|
||||||
? t("agentCount", { count: agents.length })
|
|
||||||
: t("sessionCount", { count: sessions.length });
|
|
||||||
|
|
||||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
|
||||||
|
|
||||||
const Header = (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Columns3 className="w-4.5 h-4.5 text-accent" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-lg font-semibold text-fg-primary truncate">{t("title")}</h1>
|
|
||||||
{wsConnected ? (
|
|
||||||
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
|
||||||
{t("common:live")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
|
|
||||||
{t("common:offline")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-fg-muted truncate">{subtitle}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<ViewToggle view={view} onChange={setView} />
|
|
||||||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
|
||||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!loading && total === 0) {
|
|
||||||
return (
|
|
||||||
<div className="animate-fade-in flex flex-col min-h-[60vh]">
|
|
||||||
{Header}
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<EmptyState
|
|
||||||
icon={Columns3}
|
|
||||||
title={view === "agents" ? t("noAgents") : t("noSessions")}
|
|
||||||
description={view === "agents" ? t("noAgentsDesc") : t("noSessionsDesc")}
|
|
||||||
action={
|
|
||||||
<button onClick={load} className="btn-primary">
|
|
||||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
{Header}
|
|
||||||
|
|
||||||
<div className="flex gap-4 min-h-[600px] overflow-x-auto pb-4 -mx-8 px-8">
|
|
||||||
{view === "agents"
|
|
||||||
? AGENT_COLUMNS.map((status) => {
|
|
||||||
const config = STATUS_CONFIG[status];
|
|
||||||
const items = groupedAgents[status];
|
|
||||||
const limit = expanded[status] || COLUMN_PAGE_SIZE;
|
|
||||||
return (
|
|
||||||
<Column
|
|
||||||
key={status}
|
|
||||||
labelKey={config.labelKey}
|
|
||||||
color={config.color}
|
|
||||||
dotClass={config.dot}
|
|
||||||
pulse={status === "working" || status === "waiting"}
|
|
||||||
count={items?.length ?? 0}
|
|
||||||
emptyLabel={t("noAgentsInColumn")}
|
|
||||||
tooltip={t(`tooltip.agent.${status}`)}
|
|
||||||
remaining={Math.max(0, (items?.length ?? 0) - limit)}
|
|
||||||
onShowMore={() =>
|
|
||||||
setExpanded((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[status]: limit + COLUMN_PAGE_SIZE,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loading && (items?.length ?? 0) === 0
|
|
||||||
? Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<CardSkeleton key={`sk-${status}-${i}`} />
|
|
||||||
))
|
|
||||||
: items
|
|
||||||
?.slice(0, limit)
|
|
||||||
.map((agent) => (
|
|
||||||
<AgentCard
|
|
||||||
key={agent.id}
|
|
||||||
agent={agent}
|
|
||||||
session={sessionsById.get(agent.session_id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: SESSION_COLUMNS.map((status) => {
|
|
||||||
const config = SESSION_STATUS_CONFIG[status];
|
|
||||||
const items = groupedSessions[status];
|
|
||||||
const limit = expanded[status] || COLUMN_PAGE_SIZE;
|
|
||||||
return (
|
|
||||||
<Column
|
|
||||||
key={status}
|
|
||||||
labelKey={config.labelKey}
|
|
||||||
color={config.color}
|
|
||||||
dotClass={config.dot}
|
|
||||||
pulse={status === "active" || status === "waiting"}
|
|
||||||
count={items?.length ?? 0}
|
|
||||||
emptyLabel={t("noSessionsInColumn")}
|
|
||||||
tooltip={t(`tooltip.session.${status}`)}
|
|
||||||
remaining={Math.max(0, (items?.length ?? 0) - limit)}
|
|
||||||
onShowMore={() =>
|
|
||||||
setExpanded((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[status]: limit + COLUMN_PAGE_SIZE,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loading && (items?.length ?? 0) === 0
|
|
||||||
? Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<CardSkeleton key={`sk-${status}-${i}`} />
|
|
||||||
))
|
|
||||||
: items
|
|
||||||
?.slice(0, limit)
|
|
||||||
.map((session) => <SessionCard key={session.id} session={session} />)}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ViewToggleProps {
|
|
||||||
view: BoardView;
|
|
||||||
onChange: (next: BoardView) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ViewToggle({ view, onChange }: ViewToggleProps) {
|
|
||||||
const { t } = useTranslation("kanban");
|
|
||||||
const baseClass =
|
|
||||||
"px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg";
|
|
||||||
const activeClass = "bg-accent/15 text-accent";
|
|
||||||
const inactiveClass = "text-fg-secondary hover:text-fg-primary hover:bg-surface-3";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
aria-label={t("viewToggle.agents") + " / " + t("viewToggle.sessions")}
|
|
||||||
className="inline-flex border border-border rounded-lg overflow-hidden bg-surface-2"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={view === "agents"}
|
|
||||||
onClick={() => onChange("agents")}
|
|
||||||
className={`${baseClass} ${view === "agents" ? activeClass : inactiveClass}`}
|
|
||||||
>
|
|
||||||
{t("viewToggle.agents")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={view === "sessions"}
|
|
||||||
onClick={() => onChange("sessions")}
|
|
||||||
className={`${baseClass} border-l border-border ${
|
|
||||||
view === "sessions" ? activeClass : inactiveClass
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("viewToggle.sessions")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ColumnProps {
|
|
||||||
labelKey: string;
|
|
||||||
color: string;
|
|
||||||
dotClass: string;
|
|
||||||
pulse: boolean;
|
|
||||||
count: number;
|
|
||||||
emptyLabel: string;
|
|
||||||
/** Multi-line description rendered in a tooltip when the user hovers
|
|
||||||
* the column's help icon. Pass an empty string to suppress the icon. */
|
|
||||||
tooltip?: string;
|
|
||||||
remaining: number;
|
|
||||||
onShowMore: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Column({
|
|
||||||
labelKey,
|
|
||||||
color,
|
|
||||||
dotClass,
|
|
||||||
pulse,
|
|
||||||
count,
|
|
||||||
emptyLabel,
|
|
||||||
tooltip,
|
|
||||||
remaining,
|
|
||||||
onShowMore,
|
|
||||||
children,
|
|
||||||
}: ColumnProps) {
|
|
||||||
const { t } = useTranslation("kanban");
|
|
||||||
const childrenArray = Array.isArray(children) ? children : children ? [children] : [];
|
|
||||||
const hasChildren = childrenArray.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-surface-1 rounded-xl border border-border p-3 flex flex-col flex-shrink-0 w-72">
|
|
||||||
<div className="flex items-center gap-2 mb-4 px-1">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${dotClass} ${pulse ? "animate-pulse-dot" : ""}`} />
|
|
||||||
<span className={`text-xs font-semibold uppercase tracking-wider ${color}`}>
|
|
||||||
{t(labelKey)}
|
|
||||||
</span>
|
|
||||||
{tooltip && <ColumnHelp text={tooltip} />}
|
|
||||||
<span className="ml-auto text-[11px] text-fg-muted bg-surface-3 px-2 py-0.5 rounded-full">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-2.5 overflow-y-auto">
|
|
||||||
{hasChildren ? (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
{remaining > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={onShowMore}
|
|
||||||
className="w-full py-2 text-[11px] text-fg-muted hover:text-fg-secondary flex items-center justify-center gap-1 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronDown className="w-3 h-3" />
|
|
||||||
{t("common:showMore", { count: remaining })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-24 text-xs text-fg-muted">
|
|
||||||
{emptyLabel}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Help icon + tooltip for a Kanban column header. Hover or focus shows a
|
|
||||||
* multi-line description explaining what the column lists and what the
|
|
||||||
* status means in lifecycle terms. Keyboard-focusable for accessibility.
|
|
||||||
*/
|
|
||||||
function ColumnHelp({ text }: { text: string }) {
|
|
||||||
const [show, setShow] = useState(false);
|
|
||||||
// Anchor positioning to the column header so the tooltip stays in-page on
|
|
||||||
// the leftmost columns (where a centered tooltip would clip on narrow
|
|
||||||
// viewports). We always anchor left-aligned to the trigger.
|
|
||||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
ref={triggerRef}
|
|
||||||
className="relative inline-flex items-center cursor-help"
|
|
||||||
tabIndex={0}
|
|
||||||
role="img"
|
|
||||||
aria-label={text}
|
|
||||||
onMouseEnter={() => setShow(true)}
|
|
||||||
onMouseLeave={() => setShow(false)}
|
|
||||||
onFocus={() => setShow(true)}
|
|
||||||
onBlur={() => setShow(false)}
|
|
||||||
>
|
|
||||||
<HelpCircle className="w-3 h-3 text-fg-muted hover:text-fg-secondary transition-colors" />
|
|
||||||
{show && (
|
|
||||||
<span
|
|
||||||
role="tooltip"
|
|
||||||
className="absolute left-0 top-full mt-1.5 w-64 px-3 py-2 text-[11px] leading-relaxed text-fg-secondary bg-surface-3 border border-border rounded-md shadow-xl z-50 pointer-events-none whitespace-pre-line"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -429,11 +429,9 @@ vi.mock("../../lib/push", () => ({
|
|||||||
|
|
||||||
// Page components (imported after the mocks above; vi.mock is hoisted).
|
// Page components (imported after the mocks above; vi.mock is hoisted).
|
||||||
import { Dashboard } from "../Dashboard";
|
import { Dashboard } from "../Dashboard";
|
||||||
import { KanbanBoard } from "../KanbanBoard";
|
|
||||||
import { Sessions } from "../Sessions";
|
import { Sessions } from "../Sessions";
|
||||||
import { SessionDetail } from "../SessionDetail";
|
import { SessionDetail } from "../SessionDetail";
|
||||||
import { ActivityFeed } from "../ActivityFeed";
|
import { ActivityFeed } from "../ActivityFeed";
|
||||||
import { Analytics } from "../Analytics";
|
|
||||||
import { Workflows } from "../Workflows";
|
import { Workflows } from "../Workflows";
|
||||||
import { CcConfig } from "../CcConfig";
|
import { CcConfig } from "../CcConfig";
|
||||||
import { Workspace } from "../Workspace";
|
import { Workspace } from "../Workspace";
|
||||||
@@ -511,9 +509,6 @@ describe("screen snapshots", () => {
|
|||||||
it("Dashboard", async () => {
|
it("Dashboard", async () => {
|
||||||
await snapshot(<Dashboard />, "/");
|
await snapshot(<Dashboard />, "/");
|
||||||
});
|
});
|
||||||
it("Kanban board", async () => {
|
|
||||||
await snapshot(<KanbanBoard />, "/kanban");
|
|
||||||
});
|
|
||||||
it("Sessions", async () => {
|
it("Sessions", async () => {
|
||||||
await snapshot(<Sessions />, "/sessions");
|
await snapshot(<Sessions />, "/sessions");
|
||||||
});
|
});
|
||||||
@@ -528,9 +523,6 @@ describe("screen snapshots", () => {
|
|||||||
it("Activity feed", async () => {
|
it("Activity feed", async () => {
|
||||||
await snapshot(<ActivityFeed />, "/activity");
|
await snapshot(<ActivityFeed />, "/activity");
|
||||||
});
|
});
|
||||||
it("Analytics", async () => {
|
|
||||||
await snapshot(<Analytics />, "/analytics");
|
|
||||||
});
|
|
||||||
it("Workflows", async () => {
|
it("Workflows", async () => {
|
||||||
await snapshot(<Workflows />, "/workflows");
|
await snapshot(<Workflows />, "/workflows");
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1496,7 +1496,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
|||||||
|
|
||||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||||
|
|
||||||
Every history row carries `lane_id`: the lane whose run it belongs to. `GET /api/run/history?laneId=<n>` returns only that lane's runs. Spawned `claude` processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically.
|
Every history row carries `lane_id`: the lane whose run it belongs to. `GET /api/run/history?laneId=<n>` returns only that lane's runs. Spawned `claude` processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user