Compare commits
159 Commits
57dc91585d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f053051a5d | |||
| 1fa52d1bfc | |||
| ab6d6410d5 | |||
| 37adf983e3 | |||
| c25008ab19 | |||
| 174c650624 | |||
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 | |||
| bab19e2f36 | |||
| 6f22aed47c | |||
| d542fbbf4b | |||
| 764dc6a7b5 | |||
| 06817b7901 | |||
| 14f116bf00 | |||
| 6dda604362 | |||
| 22ce61bcfe | |||
| 8a61a2b359 | |||
| b1d43bf098 | |||
| 11b779479d | |||
| 18a1ecb6f9 | |||
| fa416b5e6b | |||
| 0f15800b23 | |||
| 43f29ee904 | |||
| 18a42873b2 | |||
| b0bfc66d65 | |||
| 78fb82b257 | |||
| 7e2bb6225f | |||
| 774ee48f19 | |||
| bcd1259ed2 | |||
| 251a18dc39 | |||
| d581705eb0 | |||
| cb8800fa31 | |||
| 9c3331c843 | |||
| 31af7aefbf | |||
| 6709f9a192 | |||
| b951f64321 | |||
| 2f39f4ec98 | |||
| f1e7d4245a | |||
| 82bf803c2e | |||
| 24f13911fe | |||
| 9b8d9bbe39 | |||
| 872c698132 | |||
| 1bc237198c | |||
| 56744b360d | |||
| 1dd18fe98c | |||
| d96d552428 | |||
| 00f6338d4c | |||
| dfea1a99d6 | |||
| f4dc6a0730 | |||
| 7357070fb9 | |||
| f0ae876472 | |||
| f6946d72f4 | |||
| 205f40c29c | |||
| a65ee1512e | |||
| 022b2384ac | |||
| 8a82895c65 | |||
| 5a793e70cc | |||
| 201eae68bb | |||
| 8fcef5a10b | |||
| 67edda77eb | |||
| 87b5e1c3db | |||
| 61443f4814 | |||
| bd829ba1c3 | |||
| 78f6e1be8e | |||
| 9f13769fb4 | |||
| 54299f119e | |||
| e31d261fd7 | |||
| 7fa39687fe | |||
| 77f3805083 | |||
| dd0cb4ad7c | |||
| 5dd4793b88 | |||
| 64c00dea85 | |||
| ab6005afdc | |||
| 14dbfa73e5 | |||
| 57dd4ffcb7 | |||
| 6d8c5399ea | |||
| bc7c5e5431 | |||
| e32c684bf0 | |||
| e7ef7bcef9 | |||
| 99465d2095 | |||
| 5f114d7c6f | |||
| d0f42254ee | |||
| 5e620bd8ab | |||
| e2d516f199 | |||
| f157977783 | |||
| 6d24f9d7ad | |||
| c781fcdb45 | |||
| 0006fabb4c | |||
| 32dc4e432a | |||
| 8fc7a32490 | |||
| 36d66cda07 | |||
| 4b3a1e3d25 | |||
| ef686454b9 | |||
| 60fb7cbaeb | |||
| 8471b3a757 | |||
| 2731b7c8ce | |||
| d25a20089a | |||
| 4da7ef0453 | |||
| f6fa1ea42d | |||
| d2327408fb | |||
| 02b90292c0 | |||
| 727823d75e | |||
| 3fb70a3fc1 | |||
| 4134b3d307 | |||
| 692ba65b0f | |||
| 39c6e1c920 | |||
| 7606653537 | |||
| ed884c6961 | |||
| 5a127df709 | |||
| 40b1d0e0fb | |||
| d12e9410e3 | |||
| a4246528fe | |||
| 77d79a59d7 | |||
| ea0cd3b455 | |||
| 80d49e2c24 | |||
| 9377849ae0 | |||
| e600df236e | |||
| 31f984c750 | |||
| c37933adfe | |||
| b08504858c | |||
| 5d5ea32db6 | |||
| 6cb5fb356c | |||
| fccfa5ad17 | |||
| a545230746 | |||
| 5de66d0b13 | |||
| e6942db7fe | |||
| e9193ae4be | |||
| fcc7a8f2f3 | |||
| 917f0794d5 | |||
| 3a83e849cf | |||
| 5b2f98ab4d | |||
| 5bc4d03740 | |||
| aa01d77ce6 | |||
| 181e0f77ff | |||
| 465dca35e5 | |||
| 6c7554dbdc | |||
| c068f0cac6 | |||
| 1f3a5ff51d | |||
| 4a8725f136 | |||
| 7488a375e3 | |||
| 2312c53788 | |||
| e783a2a946 | |||
| 4d9a385c5e | |||
| 9d145865dd | |||
| d71086f677 | |||
| 113ed01504 | |||
| c024534475 | |||
| c3546f568a | |||
| fa489016b4 | |||
| 9f9879cc3d | |||
| c4b11799dc | |||
| 0ecc2eec24 | |||
| 2c313084a8 | |||
| 72f74f9104 | |||
| b673363351 | |||
| 4905d63b97 | |||
| 9b1fa67385 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-agent-monitor-plugins",
|
"name": "claude-code-agent-monitor-plugins",
|
||||||
"description": "Official plugin marketplace for Claude Code Agent Monitor — 10 plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
|
"description": "Official plugin marketplace for Claude Code Agent Monitor — the `ccam` plugin installs the dashboard itself (hooks, server, CLI, MCP), plus 10 focused plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "smartgift",
|
"name": "smartgift",
|
||||||
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "ccam",
|
||||||
|
"source": "./",
|
||||||
|
"description": "The dashboard itself. Installs the Claude Code event hooks, boots the local server, puts the `ccam` CLI on PATH and connects the MCP tools — no checkout and no `npm run setup`. Install this one first; the ten focused plugins below all read from the API it provides.",
|
||||||
|
"tags": ["dashboard", "monitoring", "hooks", "mcp", "cli"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ccam-analytics",
|
"name": "ccam-analytics",
|
||||||
"path": "plugins/ccam-analytics",
|
"path": "plugins/ccam-analytics",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"name": "ccam",
|
||||||
|
"description": "Claude Code Agent Monitor — the full local-first dashboard as a plugin. Installs the event hooks, boots the dashboard server, puts the `ccam` CLI on PATH, and connects the MCP tools. No checkout, no npm run setup.",
|
||||||
|
"author": {
|
||||||
|
"name": "Nguyễn Ngọc Trí Vĩ",
|
||||||
|
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||||
|
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "mcp", "claude-code"],
|
||||||
|
"commands": [
|
||||||
|
"./plugins/ccam/commands/ccam-doctor.md",
|
||||||
|
"./plugins/ccam/commands/ccam-update.md",
|
||||||
|
"./plugins/ccam/commands/ccam-open.md"
|
||||||
|
],
|
||||||
|
"mcpServers": "./plugins/ccam/.mcp.json",
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Stop",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SubagentStop",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Notification",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js\"",
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": "Checking the CCAM dashboard..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionStart",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionEnd",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" UserPromptSubmit",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
name: ship-feature-lane
|
||||||
|
description: "Autonomous end-to-end feature pipeline for ONE CCAM lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane <requirement>`. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → pre-push CI gates + dev preflight → e2e on the feature branch → code review → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `ccam stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a CCAM lane. NOTE: ticket-filing and post-merge dev-site QC are currently OFF (F's integrations aren't built yet) — this pipeline stops at Stage 14 once dev CI/dev-QC support lands."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ship Feature Lane (CCAM lane pipeline)
|
||||||
|
|
||||||
|
You are running the autonomous feature pipeline for **one CCAM lane**. The human's only interactive touchpoints are **Stage 0 (frontloaded Q&A)** and **merging the PR on GitHub**; everything else runs to completion or to a `blocked` escalation, reporting progress through `ccam stage` (which the dashboard renders).
|
||||||
|
|
||||||
|
## Setup — do this first, every run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LANE_DIR="$(pwd)" # the lane clone IS your cwd — CCAM resolves the lane from this, never a hardcoded path
|
||||||
|
```
|
||||||
|
- CCAM resolves your lane from `cwd` automatically (longest path-boundary prefix match) — there is no marker file to check and no separate assign step. If `ccam stage` or `ccam feature activate` ever fails with "no lane found", you are not inside a lane's working directory; stop and tell the human.
|
||||||
|
- **Pipeline template is this skill's contract, not whatever the lane started on.** Before your first `ccam stage` call, run `ccam lanes pipeline ship-feature` (no id needed — resolves from `cwd` like `ccam stage`; idempotent, a no-op if already set) so every stage below resolves against the 16-node `ship-feature` map. The human is never expected to pick this in Add Lane or anywhere else — you force it to match the skill actually running.
|
||||||
|
- All stage updates go through `ccam stage <stage> [--status <s>] [--evidence "..."]` — **call it at the start of every stage** (this is also the heartbeat, visible on the dashboard).
|
||||||
|
- **Integration toggles: check, don't assume.** `ccam lanes integration tracker`, `ccam lanes integration dev_qc`, `ccam lanes integration ci_wait` each exit 0 (on) or 1 (off), reading the profile's `integrations.env`. No agent exists yet to actually FILE a ticket or run dev-QC even when a toggle reads on (`ticketer`/`dev-qc` are a later task) — so regardless of the check's result, Stage 9 (ticket) stays skipped, Stage 13's dev-QC and dev-CI-wait halves stay skipped, and Stage 10's CI watch keeps using the plain `gh pr checks` path (`ccam ci` doesn't exist yet). Check the toggle where noted below anyway, so the evidence you record is honest about whether the PROFILE wants the integration on, distinct from whether CCAM can act on it yet.
|
||||||
|
- **Heartbeat during long stages.** Implementing (Stage 1), CI waits (Stage 10), and the watch/post-merge polls (Stages 12–13) can run many minutes between stage transitions — bump the heartbeat with `ccam stage <same-stage>` after each commit and on each poll iteration, so the dashboard doesn't false-flag a working lane as stalled.
|
||||||
|
- Profile hooks (`bootstrap`/`boot`/`migrate`/`seed`/`ci-gate`/`e2e`/`health`/`regen`) run through `ccam lanes hook <name> [args…]` and `ccam lanes up`/`down`. Use them; don't reinvent their logic.
|
||||||
|
- **NEVER merge or rebase branches manually.** The ONLY merge that ever happens in this flow is `origin/development` INTO the feature branch, and only through `ccam lanes sync-base` (fetches fresh, pre-checks migration collisions, auto-regenerates generated files — see the note on this command in Stage 2; it is a LATER task, referenced here by its intended contract). There is no other direction: never merge a feature branch into anything locally, never commit on `development`, and never touch `main`.
|
||||||
|
- **NEVER push `origin/development` or `origin/main` (HARD).** `development` moves ONLY when a human merges a PR on GitHub. The only branch you ever push is your own `feat/<slug>` — and only after the senior gate's GO (Stage 8). If you ever find yourself typing `git push` with `development` or `main` on the line: STOP, `ccam stage <current> --status blocked`.
|
||||||
|
|
||||||
|
## Context recovery — after conversation compaction
|
||||||
|
|
||||||
|
Long pipelines outlive the context window. When context is compacted (summarized), re-derive these before continuing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LANE_DIR="$(pwd)" # lane = cwd, resolved fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check your current position:
|
||||||
|
- **Lane state**: `ccam feature show <slug>` (or `ccam lanes` for the lane's own row) — shows current stage, status, feature title, branch, gate decision, PR URL, notes.
|
||||||
|
- **Git branch**: `git rev-parse --abbrev-ref HEAD` — which branch you're on.
|
||||||
|
- **Feature slug**: from the branch name (`feat/X` → `X`), or from `ccam feature list` (the lane's currently-active feature is marked `▶`).
|
||||||
|
|
||||||
|
Resume from the stage shown. If state says `stage=X status=running`, you were mid-stage X when context compacted — re-run that stage from the top (all `ccam` commands are idempotent).
|
||||||
|
- **MCP preflight (fail fast):** if this is the lane's first run, or a required Playwright MCP's `browser_*` tools aren't available in this session, run `ccam lanes mcp sync` — it relocates the source repo's already-configured MCP servers into this lane's `.mcp.json`. If the source repo has none configured (`ENOMCPCONFIG`), tell the human to configure `.mcp.json` manually for the source repo first. Either way, restart the session after syncing so the new config loads — `playwright` and a local-QC MCP are always required for Stage 3/6. Catching this at Stage 0 costs a minute; catching it at Stage 13 strands a merged feature unverified.
|
||||||
|
- **Agents preflight:** if this is the lane's first run through this pipeline, run `ccam lanes agents install` once — it writes the `qc-local` and `senior-gate-reviewer` agent definitions into the lane's own `.claude/agents/` (idempotent; re-running just overwrites with the current templates). Stage 6/7 launch these by `subagent_type`; skipping this leaves those stages unable to find the agent.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- **Publish ONLY after the senior-gate-reviewer returns `VERDICT: GO`.** "Publish" = push the feature branch + open/update the PR (Stage 8). Nothing reviewer-visible exists before GO, and nothing else authorizes it.
|
||||||
|
- **The fix-loop:** any failure in stages 2–7 (gates/preflight, e2e, review, QC, senior gate), any red PR CI that's genuinely yours (Stage 10), and any worth-fixing review comment (Stage 12) → fix on the **feature branch**, and re-run **from Stage 2 through Stage 8** (gates+preflight → e2e → review → QC plan → QC → senior gate → publish/update PR), then the Stage 10 CI watch. Never skip a gate — the full process applies; no shortcuts because "it's just review feedback".
|
||||||
|
- **EXCEPTION — test-only re-entry (browser-QC fast-path).** If the re-entry's change is ENTIRELY test files — `git diff --name-only` since the last browser-QC'd commit matches only test paths — the app's runtime behavior/UI is unchanged from the last QC'd pass. Run this EXACT stage set, nothing else:
|
||||||
|
- **Always run:** Stage 2 (gates + preflight), Stage 7 (senior gate), Stage 8 (publish/update PR), and the Stage 10 tail (CI watch).
|
||||||
|
- **Run only if e2e spec files are among the changed tests:** Stage 3 (boot + e2e on the feature branch). No e2e specs changed → skip it.
|
||||||
|
- **Always SKIP** (runtime UI unchanged): Stage 5 (QC plan), Stage 6 (qc-local). Record `--evidence "QC skipped: test-only change"`.
|
||||||
|
- If the diff contains ANY non-test file → this fast-path does NOT apply; take the full path above. The first pass (not a re-entry) always runs full QC.
|
||||||
|
- **EXCEPTION — localized re-entry (scoped-e2e fast-path).** On a re-entry whose diff since the last fully-validated commit is SMALL and LOCALIZED — only files inside the feature's own surface, NO migrations, NO contract/generated files, NO shared fixtures/utilities, NO dependency changes — you may shrink Stage 3's e2e to a SCOPED run of the specs covering the touched surface: `ccam lanes hook e2e -- <spec files>` (scoped runs still heartbeat + lock + time-bound like the full suite), and browser QC runs SCOPED to the affected QC-Plan scenarios (tell the qc agent exactly which scenario numbers). Know the trade-off: there is no dev-merged full suite anymore — post-merge dev CI + dev-QC (Stage 13, currently off) are normally the integration net; without them, a localized re-entry after this lands is a real gap until F ships. If in doubt whether the change is localized, it isn't — run the full path.
|
||||||
|
- **Run long helpers so they can't be killed mid-flight or hang your turn.** `ccam lanes hook ci-gate`, `ccam lanes up`, `ccam lanes hook e2e` legitimately run 3–20+ minutes (builds, tests, Playwright, lock waits). NEVER invoke them with the default Bash timeout (2 min kills them mid-flight and strands the lane half-done): use `run_in_background: true` and poll the output file until done, or set `timeout: 600000` for the shorter gates. If a helper does die mid-run, don't panic: every hook is idempotent — re-run the step (e.g. re-run `ccam lanes up --no-build` to revive a stack).
|
||||||
|
- **Waiting + polling NEVER use a foreground `sleep`** (the Bash tool blocks it) or `ScheduleWakeup` (that's a `/loop`-only primitive — this pipeline is not a `/loop` session, so it won't sustain your watch). To pace a poll loop or wait out a timer, **background the wait**: run `sleep <secs>` with `run_in_background: true` — you're re-invoked when it exits, and re-invoked the moment a backgrounded Agent or helper finishes, so you never busy-poll for background work. **If you ever can't sustain a wait/loop in this session, set `ccam stage <current> --note "<honest note>"` and STOP — never narrate a watch or loop you are not actually running.**
|
||||||
|
- **e2e: actively poll — never wait on the completion re-invoke alone.** A hung suite never fires it, stranding the lane at `stage=e2e-feature`. When running `ccam lanes hook e2e` (Stage 3): start it `run_in_background: true` AND background a `sleep 90` beside it. Each wake — finished → parse PASS/FAIL; still running → read the e2e log tail (`ccam lanes logs <id> e2e`), bump the heartbeat, and re-background `sleep 90`, UNLESS it's erroring or has run past ~22 min, in which case kill the e2e task and treat it as FAIL → re-enter Stage 2.
|
||||||
|
- **Turn-liveness: never let the pipeline die silently.** A "stalled" lane usually died one of two ways: (a) a turn ended with NOTHING pending — no backgrounded wait, no running helper, no background agent — so nothing ever re-invoked the session; or (b) a transient API error (rate-limit, 529/overload, connection refused) killed the turn mid-stage. Rules: while the pipeline is anywhere between Stage 1 and Stage 14 (done), every turn you end MUST leave at least one re-invoker pending (a `run_in_background` helper/sleep or a background agent) — check before ending the turn. And on ANY resume after an error or a human nudge ("continue"), do not ask questions: re-derive position from lane state (Context recovery above) and continue the stage. If you truly cannot leave a re-invoker, set `--note "watch needs re-trigger: <what to do>"` so the dashboard shows it honestly.
|
||||||
|
- **No retry cap — the phase clock is the signal.** A failing gate/QC/CI/e2e just re-enters the loop (fix on the feature branch, re-run from Stage 2); there is NO automatic block after N attempts. The dashboard shows how long the lane has sat in its current stage, so the human can spot a stuck or endlessly-looping lane and step in. Reserve `--status blocked` for GENUINE blockers you cannot resolve (an ambiguous merge conflict, a hard/unrecoverable error).
|
||||||
|
- **Commit only on the feature branch.** Never commit on `development`/`main`. Stage only intended files (never `git add .` blindly — this repo collects stray build/QA artifacts).
|
||||||
|
- Keep the lane's state truthful: on any stop, set an accurate `ccam stage <stage> --status <status> --note "<why>"`.
|
||||||
|
- **Quality bar (applies to every code change, including fix-loop re-entries and follow-up PRs).** Tests are sharp and meaningful — each pins a real behavior/edge case (happy + negative + boundary), none trivial, redundant, or coverage-padding. Comments are minimal — only the non-obvious *why*, matching the surrounding density; never narrate the *what*. Investigate before fixing (root cause, not symptom — use **systematic-debugging**). Prefer reusing/extending existing code over duplicating it.
|
||||||
|
- **One driver per MCP browser server.** Each MCP server owns ONE browser; two agents driving the SAME server interleave clicks in one tab. The local-QC MCP → the qc-local agent (Stage 6) only; the general `playwright` MCP → the main session for ad-hoc checks only (never while qc-local runs). Parallel agents on DIFFERENT servers are safe by design; a second concurrent driver on the SAME server is never OK.
|
||||||
|
- **Cross-lane etiquette (locks + siblings).** Lanes share one machine and one dev site. Waiting on a cross-lane serializer (`ccam lock acquire <name>`, e.g. around a shared build/e2e step) is NORMAL — it heartbeats while it waits, so you won't look stalled. NEVER free a lock by killing another lane's session or processes, deleting the lock's directory by hand, or shrinking `LOCK_MAX_HOLD`; a dead holder's lock auto-expires on its own. If a lock wait times out: re-try with a longer `--timeout`, or set `--status blocked` with a note and report. Touch ONLY your own lane's clone, state, and locks you hold.
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
### 0 — Intake & frontloaded Q&A *(the only interactive part)*
|
||||||
|
Do NOT jump to code. Understand the requirement first.
|
||||||
|
|
||||||
|
- **Restate + quick scan.** Restate the requirement. Do a fast targeted scan of the relevant code (use the `Explore` agent for breadth; the **brainstorming** skill if the requirement is fuzzy) so your questions are grounded in what actually exists.
|
||||||
|
- **Frontloaded Q&A.** Ask the human **every** clarifying question in ONE batch: acceptance criteria, scope / non-goals, UI/UX specifics, data shapes, edge cases, which existing flows it touches. **Sibling-surface check (mandatory):** if your scan shows the app has N parallel surfaces of the pattern the requirement touches (e.g. several foldered areas, several list pages sharing a component) and the requirement names fewer than N, explicitly ask "this exists in [all N places] — apply to all, or only [the named ones]?" A missed sibling here costs a full second pipeline pass when a reviewer catches it on the PR. Then activate a clean feature slot for this run: `ccam feature activate <slug> --title "<short title>"` — Task B's `activate` archives whatever feature was previously active on this lane automatically, so this run's dashboard state starts clean without a separate "init" step. Then mark intake: `ccam stage intake --status running`. Announce "Questions answered — going autonomous now." After this, don't ask the human anything unless you hit a `blocked` escalation.
|
||||||
|
|
||||||
|
### 0b — Investigate & plan *(autonomous)*
|
||||||
|
- `ccam stage plan` — now design a real plan and have it independently challenged before you implement.
|
||||||
|
- **Investigate (autonomous, thorough).** Read the actual code paths, models, existing tests, and conventions the feature touches — `Explore`/`general-purpose` subagents for breadth, then read the key files yourself for depth. Pin down: integration points, data/migration needs, API/contract impact, reuse opportunities, and risks. Use **systematic-debugging** if the feature is a fix (root-cause first, no symptom patches).
|
||||||
|
- **Plan.** Produce a concrete implementation plan (the **writing-plans** skill): approach, files to change, the test strategy (which behaviors/edge cases each test will pin), migration/contract impact, and how each acceptance criterion is met.
|
||||||
|
- **Debate the plan (adversarial review).** Spawn a SEPARATE sub-agent (Agent tool — `Plan` or `general-purpose`) to critique the plan + investigation: missed requirements, wrong assumptions, a simpler approach, unhandled edge cases, acceptance-criteria gaps. Apply the worthwhile critiques (use **receiving-code-review** judgment — verify each point, don't blindly accept or reject). Iterate once or twice until the plan holds up.
|
||||||
|
- Write the Q&A answers **and the agreed plan** to a lane spec file `docs/superpowers/specs/lane-<slug>.md` (gitignored, or add it to `.gitignore` if this is the first one) — the acceptance contract the senior gate checks against.
|
||||||
|
|
||||||
|
### 1 — Implement (TDD, to the plan)
|
||||||
|
- Choose a **single-segment slug** for the feature — lowercase, hyphens, NO slashes. Cut the feature branch from **development** (the PR base): `git fetch origin && git checkout -b feat/<slug> origin/development`.
|
||||||
|
- If Stage 0 activated a placeholder slug different from the final chosen one, reconcile: `ccam feature activate <slug>` — it echoes back the canonicalized slug it actually stored; use THAT for the branch and every later reference.
|
||||||
|
- Implement the agreed plan with the **test-driven-development** skill: failing test → minimal code → green → commit. Frequent small commits.
|
||||||
|
- **Tests must be sharp and meaningful.** Each test pins a real behavior or edge case from the plan / acceptance criteria — cover the happy path, the negative/error path, and boundaries. NO trivial or redundant tests: don't assert constants or framework internals, don't re-test the same path twice, don't pad for coverage. A few precise tests that would actually catch a regression beat many shallow ones.
|
||||||
|
- **Comment only when it earns its place.** Match the surrounding code's comment density. Comment the non-obvious *why* (intent, invariants, gotchas, links to context) — never narrate the *what* the code already says. Delete redundant/boilerplate/restating comments rather than adding them.
|
||||||
|
- If your stack generates an API contract/client and the API changed, regenerate it (`ccam lanes hook regen`) so the contract-check gate passes (stacks without a contract gate skip this).
|
||||||
|
- `ccam stage implementing`
|
||||||
|
|
||||||
|
### 2 — Pre-push CI gates + dev preflight (on the feature branch)
|
||||||
|
- `ccam stage gates --status running` — declare it FIRST. This is also the fix-loop's re-entry point, and the declaration is what moves the lane BACK down the pipeline: detection alone can't (`recordDetection` is forward-only and never overrides a higher declared stage), so a re-entry that skips this line leaves the dashboard showing the stage you already left.
|
||||||
|
- `ccam lanes hook ci-gate` — runs the profile's CI gate (lint / test / contract checks) against an isolated per-lane test DB. On failure: read the output, fix on the feature branch, commit, re-run. Loop until green.
|
||||||
|
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: declare `ccam stage migration-collision --status running` (an alias of `gates` — the map stays put, the lane's stage names WHY it is sitting there), rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
|
||||||
|
|
||||||
|
### 3 — E2E on the feature branch
|
||||||
|
The e2e hook doesn't run migrations itself — it tests the already-running stack. To exercise the feature's code and any new schema, boot the lane stack with the feature branch first:
|
||||||
|
|
||||||
|
- `ccam stage booting --status running` — an alias of `e2e-feature`; the boot below can take minutes and this says which minutes they are.
|
||||||
|
- `ccam lanes up --qc` — boots with the profile's QC env (mock/stub flags so QC is deterministic, from `QC_BOOT_ENV`), applies the feature branch's own migrations, and reboots the stack. Idempotent; safe to re-run. (The branch was cut from `origin/development`, so this stack IS development + your feature as of the branch point.)
|
||||||
|
- `ccam stage e2e-feature --status running`, then `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
|
||||||
|
- On failure: fix on the feature branch, commit, re-run from Stage 2.
|
||||||
|
- **Iterating on a failing spec:** declare `ccam stage e2e-scoped` (alias of `e2e-feature`) so the dashboard shows this is a narrowed run, not the gate, and use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
|
||||||
|
- On success: `ccam stage e2e-feature-passed --status running`
|
||||||
|
|
||||||
|
### 4 — Code review *(no open PR yet — use local diff)*
|
||||||
|
- Run the **`code-review` skill at effort `high`** on the feature diff vs `origin/development` — this is the deterministic code-review gate, not an ad-hoc read. The PR isn't open yet, so point it at the local diff: `git diff origin/development...feat/<slug>` (and `git log origin/development..feat/<slug>` for commits). ONLY if the `code-review` skill is unavailable, fall back to a manual review of that diff (correctness, security, tests, migration/contract safety). The Stage-6 `qc-local` report covers the user-flow review for the senior gate.
|
||||||
|
- Apply the fixes worth making on the feature branch; if you change code, re-run **from Stage 2**.
|
||||||
|
- `ccam stage review`
|
||||||
|
|
||||||
|
### 5 — QC plan *(bound the test scope before any browser QC)*
|
||||||
|
- Author a **QC Plan** the browser-QC agents (Stage 6 now, and a future Stage 13 once dev-QC exists) will execute against — so QC covers everything that matters and nothing that doesn't (no missed scenarios, no wandering into unrelated areas). Derive it from the acceptance points (lane spec) + the real change surface (`git diff origin/development...feat/<slug>` and `--stat`). Three parts:
|
||||||
|
- **In-scope scenarios** (numbered): each acceptance point with positive AND negative cases; adjacent flows sharing routes/components/data with the change; required **state coverage** (reload on each stateful screen touched, one logout→re-login, back/forth nav); and the required **UI/UX layout checks** for every form/screen the feature touches (narrow AND short viewport, expandables open so content exceeds the viewport, fixed chrome not clipped, every control labelled, section headers more prominent than field labels).
|
||||||
|
- **Out-of-scope** (explicit): areas NOT to test because the change cannot affect them — this is what stops QC from over-testing.
|
||||||
|
- **Smoke set**: login + main nav + ≥3 unaffected major areas.
|
||||||
|
- Append it to the lane spec under a `## QC Plan` heading (`docs/superpowers/specs/lane-<slug>.md`) — the same file the senior gate reads. You are the **single writer** of this section; the QC agent only *proposes* additions in its report and you fold them in (Stage 6). This keeps the plan race-free yet living.
|
||||||
|
- `ccam stage qc-plan --status running`
|
||||||
|
|
||||||
|
### 6 — Browser QC via the qc-local agent
|
||||||
|
- **Test-only fast-path:** on a fix-loop re-entry whose change is ENTIRELY test files (see the fix-loop rule), SKIP this stage — the app's runtime UI is unchanged — and record `--evidence "QC skipped: test-only change"`. Otherwise run it:
|
||||||
|
- `ccam stage qc --status running`, then launch the **qc-local** agent (Agent tool, `subagent_type: qc-local` — FOREGROUND; it gates the pipeline; the Setup section's agents preflight already installed it). Give it: the lane's working directory, the feature slug, the feature title, the acceptance points (lane spec), and the **QC Plan** (lane spec, Stage 5) as the authoritative scope to execute against. It owns the whole local browser QC and proof capture (`ccam lanes proof-link` first, then screenshots land under the proof gallery automatically). It runs against the lane's feature-branch stack from Stage 3. Do NOT drive the browser yourself at this stage.
|
||||||
|
- Parse its last line: `LOCAL-QC: PASS` → continue. `LOCAL-QC: FAIL — <reasons>` → fix on the feature branch → re-run from Stage 2. Keep its report — it is the feature user-flow review for the senior gate.
|
||||||
|
- **Fold back discoveries:** if its report lists scenarios it found that weren't in the plan (its "Scenarios discovered during QC" section), add them to the `## QC Plan` in-scope list in the lane spec.
|
||||||
|
|
||||||
|
### 7 — Senior GO/NO-GO gate *(authorizes the publish)*
|
||||||
|
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer` — installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions, the Stage-6 `qc-local` report (the user-flow review), and confirmation that gates/e2e/review/QC passed. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
|
||||||
|
- Parse its final line:
|
||||||
|
- `VERDICT: GO` → proceed to Stage 8.
|
||||||
|
- `VERDICT: NO-GO — <fixes>` → `ccam stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
|
||||||
|
- `ccam stage gate --evidence "GO"` (or `NO-GO — <reason>`)
|
||||||
|
|
||||||
|
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
|
||||||
|
- `ccam stage publishing --status running`
|
||||||
|
- `ccam stage push-revalidate` (alias of `pr-open`) — then re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
|
||||||
|
- `git push -u origin feat/<slug>` — first push of the feature branch to remote. All gates have passed before this point; the PR is finalized before reviewers see it.
|
||||||
|
- Open or update the PR **based on and targeting `development`**: `gh pr create --base development --fill` (or `gh pr edit` / the push itself if a prior run already created it). Capture the URL.
|
||||||
|
- `ccam stage pr-open --evidence "<pr-url>"` — the dashboard shows the PR link from here (via `--evidence` in `ccam feature show`/`ccam lanes`).
|
||||||
|
|
||||||
|
### 9 — Ticket *(currently SKIPPED — tracker integration is off)*
|
||||||
|
- Tracker integration is hardcoded off (see Setup). Do nothing here; do not attempt to file a ticket. When F ships `ccam lanes integration tracker`, this stage gets a real implementation. (Check `ccam lanes integration tracker` in the heartbeat: even when on, no ticketer agent exists yet to act on it.)
|
||||||
|
|
||||||
|
### 10 — CI watch on the PR *(non-blocking)*
|
||||||
|
- Check the PR's CI with `gh pr checks` / `gh`. Green → continue to the report + watch — never idle waiting for green.
|
||||||
|
- **Red CI → triage before the fix-loop** (shared CI flakes under multi-lane load; treating every red as your defect wastes cycles):
|
||||||
|
1. Read WHICH job/tests failed with `gh` (there is no `ccam ci` yet — F builds that; use `gh run view`/`gh pr checks --watch` directly).
|
||||||
|
2. **Your tests / your code implicated** → real failure: fix on the feature branch, re-enter from Stage 2.
|
||||||
|
3. **Infra/flake signature** (a job with no test failures, OOM/contention on the shared runner, a hung job with no output, or a test that is green locally on the identical tree) → re-run the workflow via `gh run rerun <run-id> --failed` — ONCE. Still red after the rerun → treat it as real (or escalate with the evidence). Never rerun more than twice, and never push an empty commit to re-trigger CI.
|
||||||
|
4. A **hung** workflow (running way past its normal duration with no output) → `gh run cancel <run-id>` then rerun once.
|
||||||
|
|
||||||
|
### 11 — Report
|
||||||
|
- Post a concise report: PR URL, CI status, what shipped, and that the PR now **awaits a human merge** (this pipeline never merges).
|
||||||
|
- `ccam stage reported --status running`
|
||||||
|
- Do **NOT** clear or reset the lane's feature state. Cleanup is the human's call, from the dashboard — they may still be manually testing.
|
||||||
|
|
||||||
|
### 12 — Watch the PR *(until a human merges or closes it)*
|
||||||
|
- `ccam stage watching-pr --note "watching PR for comments + base conflicts + the merge"`
|
||||||
|
- Loop every ~5 minutes, paced by a **backgrounded** wait so the turn isn't pinned (see the waiting-primitive rule above): run `sleep 300` with `run_in_background: true` — you're re-invoked when it elapses. Each iteration:
|
||||||
|
- Check PR state: `gh pr view <pr_url> --json state,mergeable -q '.state + " " + (.mergeable|tostring)'`, and bump the heartbeat (`ccam stage watching-pr`).
|
||||||
|
- `MERGED` → a human merged it: go to **Stage 13** (post-merge verification).
|
||||||
|
- `CLOSED` (unmerged) → the human rejected/abandoned it: `ccam stage done --status passed --note "PR closed unmerged by human"` → STOP.
|
||||||
|
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, declare `ccam stage sync-conflict --status running` (an alias of `gates`, which is also where the re-entry below lands — one declaration, honest about both) and resolve it as real work:
|
||||||
|
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
|
||||||
|
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
|
||||||
|
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
|
||||||
|
- For each new PR comment (list with `gh pr view <pr_url> --json comments`, tracking which you've already handled by comment id in your own notes), triage AND **always reply on its thread** (every comment gets a response — no silent handling, so reviewers see it was considered):
|
||||||
|
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — `ccam stage pr-comment-fix --status running` (alias of `watching-pr`, so the lane reads as *acting on a comment* rather than idly polling), then apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
|
||||||
|
- **Question / discussion**: answer it via `gh pr comment <pr_url> --body-file <path>` (same file-not-inline rule) — no code change.
|
||||||
|
- **Not worth fixing** (out of scope, working as intended, deferred): **reply with the reasoning** so the reviewer knows why it wasn't actioned (don't just skip it).
|
||||||
|
- **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`.
|
||||||
|
- Nothing new → background another `sleep 300` (`run_in_background: true`) and end the turn; you'll be re-invoked for the next poll. A PR can sit for days — that's fine.
|
||||||
|
|
||||||
|
### 13 — Post-merge verification *(currently SKIPPED — CI-wait and dev-QC integrations are off)*
|
||||||
|
- `ccam stage merged --status running --note "PR merged — post-merge verification unavailable (F not built)"`
|
||||||
|
- Both dev-CI-wait and dev-QC are hardcoded off (see Setup). Go straight to Stage 14. When F ships these integrations, this stage gets its real implementation (mirroring Shipyard's: dev CI watch, then a `dev-qc` agent QCing the deployed site, looping to a Stage 15 follow-up-fix pattern on any issue found).
|
||||||
|
|
||||||
|
### 14 — Done
|
||||||
|
- Post the final report: PR merged, that post-merge verification is unavailable pending F.
|
||||||
|
- `ccam stage done --status passed --note "merged; post-merge verification unavailable (F not built)"` → STOP (leave the lane for the human to clear from the dashboard whenever).
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
Whenever you STOP early (an ambiguous merge conflict, an unexpected/unrecoverable failure, a missing agent this skill depends on), set `ccam stage <current> --status blocked --note "<what the human must decide>"` — the dashboard surfaces it. Then summarize for the human and wait.
|
||||||
@@ -1,28 +1,106 @@
|
|||||||
---
|
---
|
||||||
name: ship-feature
|
name: ship-feature
|
||||||
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates.
|
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `ccam stage` so the dashboard's pipeline map follows along.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ship Feature
|
# Ship Feature
|
||||||
|
|
||||||
Use this workflow for medium or large implementation tasks.
|
Use this workflow for medium or large implementation tasks. It does not restate
|
||||||
|
how to plan, test, or review — the Superpowers skills own that. What lives here
|
||||||
|
is the phase order, this repository's own rules, and the stage declaration at
|
||||||
|
each boundary.
|
||||||
|
|
||||||
## Steps
|
For a feature inside a CCAM **lane**, use `ship-feature-lane` instead: it adds
|
||||||
- Explore impacted modules first.
|
the branch/e2e/QC/senior-gate/PR half this skill deliberately leaves out.
|
||||||
- Write a short implementation plan before editing.
|
|
||||||
- Implement smallest coherent diff that satisfies requirements.
|
|
||||||
- Run relevant verification commands.
|
|
||||||
- Update docs when commands, paths, architecture, or behavior changed.
|
|
||||||
|
|
||||||
## Required quality checks
|
## Declaring the stage
|
||||||
- Keep API and websocket contracts stable unless intentionally changed.
|
|
||||||
- Keep destructive operations behind explicit guardrails.
|
|
||||||
- Avoid broad refactors in feature tickets unless requested.
|
|
||||||
|
|
||||||
## Finish checklist
|
Each phase below starts with `ccam stage <node>`, which is what puts the phase
|
||||||
- Tests/build/typecheck completed or explicitly reported as not run.
|
on the lane's pipeline map. The nodes are the `default` template's:
|
||||||
- Changed file set is scoped and intentional.
|
`intake → plan → implement → tests → review → gate → ship → done`.
|
||||||
- User-facing docs updated if behavior changed.
|
|
||||||
|
Set the template yourself, don't rely on how the lane was created: before your
|
||||||
|
first `ccam stage` call, run `ccam lanes pipeline default` (no id needed —
|
||||||
|
resolves from `cwd`; a no-op if the lane is already on it). This is what makes
|
||||||
|
the choice invisible to the human — whichever of `ship-feature` or
|
||||||
|
`ship-feature-lane` actually runs is what decides the template, not a picker
|
||||||
|
they have to get right in advance.
|
||||||
|
|
||||||
|
`ccam stage` needs a lane owning the current directory. If it reports no lane,
|
||||||
|
this repo was never adopted (`ccam lanes add --cwd $(pwd)` fixes it) — carry on
|
||||||
|
with the workflow and skip the stage calls; they are reporting, not control
|
||||||
|
flow. Never skip a phase because its stage call failed.
|
||||||
|
|
||||||
|
Declaring beats detection. The dashboard also *infers* these stages from the
|
||||||
|
Superpowers skill invocations below, but an inference never renders `done` and
|
||||||
|
never overrides a declaration — and detection cannot move a lane BACKWARD past
|
||||||
|
a declared stage, so a rework loop is invisible unless you re-declare the phase
|
||||||
|
you dropped back to.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### intake — understand before touching anything
|
||||||
|
- `ccam stage intake --status running`
|
||||||
|
- Restate the requirement and its success criteria.
|
||||||
|
- Explore the impacted modules: `Explore` agent for breadth, then read the key
|
||||||
|
files yourself. Identify which layers are hit (server / client / mcp / docs /
|
||||||
|
scripts) — `repo-onboarding` if the area is unfamiliar.
|
||||||
|
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**.
|
||||||
|
|
||||||
|
### plan — a written plan, challenged before it is code
|
||||||
|
- `ccam stage plan`
|
||||||
|
- **superpowers:writing-plans**: approach, files to change, the test strategy
|
||||||
|
(which behavior each test pins), and how each success criterion is met.
|
||||||
|
- If the task is a bug rather than a feature: **superpowers:systematic-debugging**
|
||||||
|
first. Root cause, not symptom — and grep every caller of the function you
|
||||||
|
are about to change, not just the path the report names.
|
||||||
|
|
||||||
|
### implement — smallest coherent diff
|
||||||
|
- `ccam stage implementing`
|
||||||
|
- **superpowers:test-driven-development**: failing test → minimal code → green.
|
||||||
|
- Smallest diff that satisfies the requirement. Every changed line traces to it.
|
||||||
|
- **file-headers** applies to every source file you create or edit.
|
||||||
|
|
||||||
|
### tests — this repo's verification, not a claim
|
||||||
|
- `ccam stage tests --status running`
|
||||||
|
- Backend changed → `npm run test:server`. Frontend → `npm run test:client`.
|
||||||
|
MCP → `npm run mcp:typecheck` + `npm run mcp:build`.
|
||||||
|
- A UI snapshot diff is reviewed, never blindly regenerated
|
||||||
|
(`cd client && npx vitest run -u` only after you have read the diff).
|
||||||
|
- Record the outcome: `ccam stage tests --evidence "<what passed>"`, or
|
||||||
|
`--result fail` with what failed. A step you could not run is reported as not
|
||||||
|
run, never as passed.
|
||||||
|
|
||||||
|
### review — a real review pass, not a re-read
|
||||||
|
- `ccam stage review`
|
||||||
|
- Run the **code-review** skill on the working diff, or
|
||||||
|
**superpowers:requesting-code-review** when handing it to an agent.
|
||||||
|
- Apply what is worth applying with **superpowers:receiving-code-review**
|
||||||
|
judgment: verify each point, neither blind agreement nor blind rejection.
|
||||||
|
- Changed code in response? Re-run the `tests` phase.
|
||||||
|
|
||||||
|
### gate — evidence before the completion claim
|
||||||
|
- **superpowers:verification-before-completion**. Commands actually run, output
|
||||||
|
actually read. This is the phase that stops "should work" from shipping.
|
||||||
|
- `ccam stage gate --evidence "<what was verified>"`
|
||||||
|
|
||||||
|
### ship — docs, then the user's call
|
||||||
|
- `ccam stage ship`
|
||||||
|
- **update-project-docs** — mandatory for any change to behavior, config,
|
||||||
|
interfaces, events, schema, CLI commands, or features. Not optional, not
|
||||||
|
deferred, not "if asked".
|
||||||
|
- Commit / push / PR **only when the user asks in this turn**. Finishing an
|
||||||
|
implementation is not authorization to commit.
|
||||||
|
- `ccam stage done --status passed --evidence "<what shipped>"`
|
||||||
|
|
||||||
|
## This repository's own rules
|
||||||
|
|
||||||
|
Not restated here. `CLAUDE.md` is loaded in every session and already binds
|
||||||
|
them — backward-compatible API/WebSocket contracts, fail-safe hooks,
|
||||||
|
migration-safe schema changes, the destructive-lane and git-argv guards, and
|
||||||
|
the lane boundaries (the console never writes a stage; the runtime never writes
|
||||||
|
`stage`/`status`/`notes`). A second copy here would only drift out of sync with
|
||||||
|
the first. Read `CLAUDE.md` and `.claude/rules/` for the area you are touching.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
- Checklist template: `references/feature-checklist.md`
|
- Checklist template: `references/feature-checklist.md`
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ i18n architecture: **Supported languages** list, `supportedLngs`, the 15 namespa
|
|||||||
## Tier 3 — situational
|
## Tier 3 — situational
|
||||||
|
|
||||||
- `.env.example` — every env var belongs here with a sane default + comment.
|
- `.env.example` — every env var belongs here with a sane default + comment.
|
||||||
- `INSTALL.md`, `SETUP.md`, `DEPLOYMENT.md`, `docs/DEPLOYMENT.md` — install/run/deploy commands.
|
- `INSTALL.md`, `SETUP.md` — install/run commands.
|
||||||
- `CLAUDE.md`, `AGENTS.md` — agent working guides; update when commands, file locations, or workflows change.
|
- `CLAUDE.md`, `AGENTS.md` — agent working guides; update when commands, file locations, or workflows change.
|
||||||
- `docs/README.md` — docs index; add a link when a new `docs/*.md` is created.
|
- `docs/README.md` — docs index; add a link when a new `docs/*.md` is created.
|
||||||
- `desktop/README.md`, `vscode-extension/README.md`, `statusline/README.md` — surface-specific; update only when that surface changes.
|
- `vscode-extension/README.md`, `statusline/README.md` — surface-specific; update only when that surface changes.
|
||||||
|
|
||||||
## Consistency invariants
|
## Consistency invariants
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
# Port to listen on (default: 4820)
|
# Port to listen on (default: 4820)
|
||||||
# DASHBOARD_PORT=4820
|
# DASHBOARD_PORT=4820
|
||||||
|
|
||||||
|
# Directory the built client is served from in production (default: client/dist).
|
||||||
|
# The `ccam` plugin install points this at ~/.claude/agent-dashboard/runtime/client-dist
|
||||||
|
# because the plugin cache directory is read-only and replaced on every update.
|
||||||
|
# DASHBOARD_CLIENT_DIST=
|
||||||
|
|
||||||
# Interface to bind. SECURITY: defaults to 127.0.0.1 (loopback) so the dashboard
|
# Interface to bind. SECURITY: defaults to 127.0.0.1 (loopback) so the dashboard
|
||||||
# is NOT reachable from the network out of the box (GHSA-gr74-4xfh-6jw9). The
|
# is NOT reachable from the network out of the box (GHSA-gr74-4xfh-6jw9). The
|
||||||
# server reads transcripts, exports all data, and can spawn `claude`, so binding
|
# server reads transcripts, exports all data, and can spawn `claude`, so binding
|
||||||
|
|||||||
+8
-1
@@ -6,7 +6,10 @@ jspm_packages/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
client/dist/
|
client/dist/
|
||||||
mcp/build/
|
# mcp/build/ is deliberately COMMITTED: Claude Code starts a plugin's MCP
|
||||||
|
# servers the moment the session opens, so the artifact has to exist before any
|
||||||
|
# bootstrap could build it. Freshness is enforced by scripts/check-mcp-build.js.
|
||||||
|
!mcp/build/
|
||||||
desktop/out/
|
desktop/out/
|
||||||
desktop/release/
|
desktop/release/
|
||||||
desktop/assets/icon.iconset/
|
desktop/assets/icon.iconset/
|
||||||
@@ -18,6 +21,10 @@ desktop/assets/icon.iconset/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
|
# Local lane profile - only present when this repo itself is adopted as a
|
||||||
|
# lane (`ccam lanes add --cwd`); machine-specific runtime config, not source.
|
||||||
|
/.ccam/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
+10
-1
@@ -36,7 +36,16 @@ else
|
|||||||
echo "🎨 No staged files to format."
|
echo "🎨 No staged files to format."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 2. Run tests — commit is blocked unless all pass ────────────────────────
|
# ── 2. Committed MCP build must match mcp/src ───────────────────────────────
|
||||||
|
# mcp/build/ is committed because a plugin's MCP server starts before any
|
||||||
|
# bootstrap could build it. Only enforced when mcp/src is part of this commit,
|
||||||
|
# so unrelated commits are not blocked by a stale artifact.
|
||||||
|
if git diff --cached --name-only --diff-filter=ACMR | grep -q '^mcp/src/'; then
|
||||||
|
echo "🔌 Checking the committed MCP build against mcp/src..."
|
||||||
|
node scripts/check-mcp-build.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Run tests — commit is blocked unless all pass ────────────────────────
|
||||||
# Each suite is retried once on failure. The full run executes dozens of test
|
# Each suite is retried once on failure. The full run executes dozens of test
|
||||||
# files concurrently, each starting its own server (plus the CLI suite's
|
# files concurrently, each starting its own server (plus the CLI suite's
|
||||||
# spawned child processes with hard kill timeouts), so a loaded machine can
|
# spawned child processes with hard kill timeouts), so a loaded machine can
|
||||||
|
|||||||
+53
-397
@@ -35,35 +35,13 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|

|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,7 +65,6 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
|||||||
- [Update Notifier Subsystem](#update-notifier-subsystem)
|
- [Update Notifier Subsystem](#update-notifier-subsystem)
|
||||||
- [Tabby Companion Subsystem](#tabby-companion-subsystem)
|
- [Tabby Companion Subsystem](#tabby-companion-subsystem)
|
||||||
- [VS Code Extension Architecture](#vs-code-extension-architecture)
|
- [VS Code Extension Architecture](#vs-code-extension-architecture)
|
||||||
- [Desktop App Architecture (macOS & Windows / Electron)](#desktop-app-architecture-macos--windows--electron)
|
|
||||||
- [Security Considerations](#security-considerations)
|
- [Security Considerations](#security-considerations)
|
||||||
- [Performance Characteristics](#performance-characteristics)
|
- [Performance Characteristics](#performance-characteristics)
|
||||||
- [Deployment Modes](#deployment-modes)
|
- [Deployment Modes](#deployment-modes)
|
||||||
@@ -348,8 +325,7 @@ graph TD
|
|||||||
| `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total |
|
| `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total |
|
||||||
| `routes/events.js` | Read-only event listing with session_id filter and pagination |
|
| `routes/events.js` | Read-only event listing with session_id filter and pagination |
|
||||||
| `routes/stats.js` | Single aggregate query returning total/active counts + status distributions |
|
| `routes/stats.js` | Single aggregate query returning total/active counts + status distributions |
|
||||||
| `routes/metrics.js` | Prometheus / OpenMetrics text-exposition endpoint (`GET /api/metrics`) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same `db.js` prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under `/api`, so it sits behind the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard — a non-loopback scraper needs `DASHBOARD_ALLOWED_HOSTS` (+ token if set). A turnkey Prometheus + Grafana stack with four auto-provisioned dashboards lives in `monitoring/` (`npm run monitoring:up` or `npm run docker:full:up`) |
|
| `routes/metrics.js` | Prometheus / OpenMetrics text-exposition endpoint (`GET /api/metrics`) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same `db.js` prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under `/api`, so it sits behind the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard — a non-loopback scraper needs `DASHBOARD_ALLOWED_HOSTS` (+ token if set) |
|
||||||
| `monitoring/` | Optional npm-managed or Docker Compose Prometheus + Grafana stack that scrapes `GET /api/metrics`. Ships four Grafana dashboards (`ccam-overview`, `ccam-sessions-agents`, `ccam-tokens-events`, `ccam-platform`), recording rules (`prometheus/ccam-rules.yml`), a Prometheus 3.x-compatible static HTML console (`prometheus/consoles/index.html`), and lifecycle scripts (`monitoring:install`, `monitoring:up`, `monitoring:verify`). See [`monitoring/README.md`](./monitoring/README.md) |
|
|
||||||
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
|
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
|
||||||
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
|
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
|
||||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||||
@@ -374,10 +350,22 @@ graph TD
|
|||||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||||
| `lib/stream-json-parser.js` | Newline-delimited JSON line buffer for parsing `claude --output-format stream-json` output. Reassembles arbitrarily chunked stdout into discrete envelopes. Robust: malformed lines are reported via an `onError` callback but never throw |
|
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||||
| `lib/run-spawner.js` | Spawns and supervises `claude` subprocesses for the Run page. Two modes: **headless** (`-p "<prompt>"` in argv, stdin closed, exits after one turn) and **conversation** (`--input-format stream-json`, prompt + follow-ups piped over stdin, multi-turn). Conversation mode also supports `resumeSessionId` → `--resume <id>`; an empty `prompt` is permitted in this case (the spawner skips the initial stdin write so `claude` idles on the resumed transcript until the user POSTs a follow-up via `/run/:id/message`). The argv builder also passes through an optional `effort` (`low`/`medium`/`high`) → `--effort`. Output is always `--output-format stream-json --verbose --include-partial-messages` so the parser yields character-level deltas (`stream_event` envelopes) the UI can render token-by-token; each envelope is broadcast as `run_stream` over the existing WebSocket. Status transitions broadcast as `run_status`. A failed spawn records an actual-exit timestamp too: no child started, so lane teardown can safely proceed instead of waiting for a nonexistent `exit` event. SIGTERM escalation checks that timestamp rather than Node's delivery-acknowledgement `child.killed`, so a child that ignores SIGTERM still receives SIGKILL after five seconds. Concurrency is effectively uncapped (default ceiling 10000 — matches the terminal TUI which has no cap; the cap is sanity-only to prevent fork-bomb footguns from a buggy client; override with `RUN_MAX_CONCURRENT`, NaN-safe). Per-handle bounded envelope log (cap 500) lets late-attaching clients replay history via `?envelopes=1`. The Run page additionally reconciles this in-memory log against the session's on-disk JSONL transcript on every attach (incl. clicking Resume / View on a row) — when the transcript has more user/assistant messages than the spawner saw (e.g., a resumed run whose prior history never traversed stdout), it supersedes; otherwise the spawner's log wins (it has stream_event deltas the transcript doesn't carry until each turn finalizes). This is what makes leaving a resumed run and coming back show the same chat the user saw initially. Completed handles reaped after 5 min; full transcripts persist via the normal hook ingestion pipeline because every spawned `claude` fires hooks like any other CLI session |
|
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `ccam-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and 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. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
|
||||||
| `routes/run.js` | HTTP surface for the Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). cwd sanitization: must be absolute and exist as a directory. `GET /` lists handles + concurrency state. `GET /binary` probes whether `claude` is on `PATH`. `GET /cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /files?cwd=&q=` powers the Run page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /` spawns (accepts `effort` in body). `POST /:id/message` sends a follow-up turn. `GET /:id` returns the handle; `?envelopes=1` includes the in-memory envelope log for re-attach. `DELETE /:id` SIGTERMs (escalates to SIGKILL after 5 s) |
|
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `ccam lanes shell` CLI, etc.), all synced live |
|
||||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug` and `base_branch` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. |
|
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||||
|
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.ccam/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. |
|
||||||
|
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||||
|
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and CCAM, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||||
|
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.ccam/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
|
||||||
|
| `lib/lane-runtime.js` | Lane stack lifecycle. `upLane` runs `boot` then `health` (never `bootstrap` — that belongs to provision/reset) and leaves processes running on a failed health check, because their logs are the evidence. Before `boot` it also repairs `.env`, ensures the database exists, migrates every boot, and seeds only the boot that created the database (A2). `downLane` kills each recorded pid tree bottom-up (a parent killed first reparents children to init) and only sweeps port listeners when a pid file existed — an unconditional sweep would kill a server the user started on a lane's stale port. `runtimeFacts` **computes** liveness on every read from pid files and probes rather than caching it (plus the database name/Redis index, never a connection string), which is both correct when a process dies unobserved and why adopting a stack after a dashboard restart needs no code at all. `provisionLane`/`resetLaneData`/`removeLaneData` (A2) drive the data-isolation lifecycle at worktree-provision, reset, and remove time — see `docs/LANES.md#data-isolation-database-redis-and-env-a2`. Writes `slot`/`ports` and nothing else on the lane row: `status=running` means an agent is working, not that a server is listening, and conflating them would corrupt lane liveness |
|
||||||
|
| `lib/lane-detect.js` | (A3) Node.js project detection and `.ccam/profile/` scaffolding. `detectNode` reads `package.json` layout (root, or `backend/`+`frontend/`) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`) — read-only, never executes anything. Any detected name written into GENERATED SHELL TEXT (an npm script name, a compose service name) must pass a strict identifier check first; a `profile.env` value is already safe regardless, since that file is parsed, never sourced. `scaffoldProfile` writes the profile, always leaving `migrate`/`seed` as an `exit 0` TODO stub when a database is found rather than guessing a migration tool. `checkProfile` is the read-only hard gate: unresolved `TODO:`, a missing/non-executable hook, or a port already in use all fail it; a missing `~/.ccam/secrets.env` only warns |
|
||||||
|
| `lib/secrets.js` | (A2) Reads `~/.ccam/secrets.env` — machine-level database/Redis credentials, deliberately outside any repository. Parsed with `lane-profile.js`'s literal `KEY=VALUE` reader, never sourced. Falls back to local defaults (with a one-time warning) when the file is absent; refuses to load a file readable by group or world rather than trusting it. Never returned by any route |
|
||||||
|
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
|
||||||
|
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — CCAM stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and CCAM can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||||
|
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||||
|
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `ccam lanes proof-link` |
|
||||||
|
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `ccam lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||||
|
|
||||||
### API Documentation
|
### API Documentation
|
||||||
|
|
||||||
@@ -636,7 +624,7 @@ graph LR
|
|||||||
| `/analytics` | Analytics | `GET /api/analytics` |
|
| `/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. Spawns `claude` subprocesses with chat-style streaming UI, tied to lanes: the UI opens on a `cwd`, calls `POST /api/lanes/ensure` when no lane owns it yet, then starts runs through `POST /api/lanes/:id/start` (which accepts `mode: "conversation" \| "headless"` and `effort: "low" \| "medium" \| "high"`). **A finished run releases its lane** (clears `run_id`, returns status to `idle`). Displays a horizontal lane strip at the top, the selected lane's pipeline map, and run configuration/console/history below. `GET /api/run/{binary,cwds,files}` for pre-flight + `@`-file autocomplete; `POST /api/run/:id/message` for follow-up turns; `DELETE /api/run/:id` to stop (lane-tied runs go through `POST /api/lanes/:id/stop` instead). `GET /api/run/history?laneId=<n>` lists only that lane's runs. WS messages: `run_stream` (includes `stream_event` deltas), `run_status`, `run_input_ack`, `lane_update`. Streaming pipeline: each WS envelope is dispatched through `flushSync` so React 18 doesn't batch bursts into a single render; a `useTypewriterEnvelopes` hook drips text/thinking deltas via `requestAnimationFrame` so even short replies type in; the merge code preserves `_streaming` and the delta-accumulated content array when claude's canonical `assistant` envelope arrives mid-stream so thinking blocks aren't dropped. Tier 1 TUI parity: collapsible-to-pill limitations banner, slash + `@`-file autocomplete (dropdowns open upward, slash matching uses tiered scoring), live token / context-window meter, status header. **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. 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 |
|
||||||
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
||||||
| `/*` | NotFound | None (static 404 page) |
|
| `/*` | NotFound | None (static 404 page) |
|
||||||
|
|
||||||
@@ -1107,6 +1095,39 @@ flowchart TD
|
|||||||
|
|
||||||
**Preserves existing hooks** -- only adds or updates entries containing `hook-handler.js`.
|
**Preserves existing hooks** -- only adds or updates entries containing `hook-handler.js`.
|
||||||
|
|
||||||
|
### Plugin installs declare the same hooks instead
|
||||||
|
|
||||||
|
When the dashboard is installed as the `ccam` Claude Code plugin, the eight hook
|
||||||
|
entries are declared inline in `.claude-plugin/plugin.json` (each running
|
||||||
|
`${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`), and `install-hooks.js` is not
|
||||||
|
used at all. `claude plugin install` itself materializes these into
|
||||||
|
`~/.claude/settings.json` with `${CLAUDE_PLUGIN_ROOT}` already resolved to the
|
||||||
|
plugin's cache path — confirmed by installing the plugin for real and
|
||||||
|
inspecting the file, not just reading the docs. Both a checkout install and a
|
||||||
|
plugin install writing hooks at once would POST every event twice — events
|
||||||
|
carry no id, so ingest cannot deduplicate them, and every token and cost figure
|
||||||
|
would double. Two guards keep that from happening silently:
|
||||||
|
|
||||||
|
- `scripts/plugin-bootstrap.js` strips checkout-style `hook-handler.js` entries
|
||||||
|
out of `~/.claude/settings.json` on session start (backing the file up as
|
||||||
|
`settings.json.ccam-bak` first) and logs what it removed. Because the
|
||||||
|
plugin's own entries ALSO contain `hook-handler.js` (just resolved to a cache
|
||||||
|
path instead of a raw filesystem one), a plain substring match cannot tell
|
||||||
|
them apart — `isCheckoutHookEntry()` only treats an entry as removable when
|
||||||
|
its command does NOT resolve under `~/.claude/plugins/cache/`, so the
|
||||||
|
plugin's own legitimate hooks are never touched.
|
||||||
|
- `install-hooks.js` warns when the plugin runtime state exists, and
|
||||||
|
`/ccam-doctor` reports any surviving checkout-style duplicates as a `FAIL`
|
||||||
|
using the same `isCheckoutHookEntry()` predicate.
|
||||||
|
|
||||||
|
`scripts/plugin-bootstrap.js` also owns the rest of the plugin's runtime: the
|
||||||
|
Node >= 22.5 gate (`node:sqlite`), an atomic `mkdir` lock, the dependency
|
||||||
|
install into `~/.claude/agent-dashboard/runtime/`, the `~/.local/bin/ccam`
|
||||||
|
launcher, and the detached server spawn with `NODE_PATH` and
|
||||||
|
`DASHBOARD_CLIENT_DIST` pointed at that runtime directory. It never writes into
|
||||||
|
the plugin cache, which Claude Code garbage-collects and replaces on every
|
||||||
|
update. Full behavior: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Import Pipeline
|
## Import Pipeline
|
||||||
@@ -1967,7 +1988,7 @@ Cache versioning is controlled by the `CACHE_NAME` constant (`dashboard-v2`). On
|
|||||||
|
|
||||||
`client/src/main.tsx` snapshots `navigator.serviceWorker.controller` before registration and listens for `controllerchange`: when a new SW activates on an already-controlled page, it reloads exactly once so the page picks up the new asset URLs without a hard refresh. The first install (no previous controller) does **not** reload.
|
`client/src/main.tsx` snapshots `navigator.serviceWorker.controller` before registration and listens for `controllerchange`: when a new SW activates on an already-controlled page, it reloads exactly once so the page picks up the new asset URLs without a hard refresh. The first install (no previous controller) does **not** reload.
|
||||||
|
|
||||||
These behaviors are reinforced by explicit `Cache-Control` headers from the production Express static middleware in `server/index.js`: `immutable, max-age=31536000` for `/assets/*`; `no-cache, must-revalidate` for `index.html`, `sw.js`, and `manifest.json`; a short revalidation window for other static files. The SPA fallback `sendFile` sends the same `no-cache` header. The native desktop shell (macOS and Windows) loads the dashboard from this same in-process server (`NODE_ENV=production`), so it inherits the policy automatically.
|
These behaviors are reinforced by explicit `Cache-Control` headers from the production Express static middleware in `server/index.js`: `immutable, max-age=31536000` for `/assets/*`; `no-cache, must-revalidate` for `index.html`, `sw.js`, and `manifest.json`; a short revalidation window for other static files. The SPA fallback `sendFile` sends the same `no-cache` header.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2264,299 +2285,6 @@ For the extension source code, refer to the [vscode-extension/](./vscode-extensi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Desktop App Architecture (macOS & Windows / Electron)
|
|
||||||
|
|
||||||
The `desktop/` workspace ships the dashboard as a **native desktop app** for **macOS** (`Claude Code Monitor.app`, distributed as a `.dmg`) **and Windows** (`Claude Code Monitor.exe`, distributed as an NSIS installer plus a no-install portable build). It is an Electron shell that **embeds the existing Express server in-process** and renders the already-built React client in a `BrowserWindow`. The desktop app does not reimplement the dashboard -- it `require()`s `server/index.js` directly, in the same Node runtime as the Electron main process, and points a Chromium window at it.
|
|
||||||
|
|
||||||
For the user-facing guide (download, install, Gatekeeper / SmartScreen, tray menu, auto-start), see [DESKTOP.md](./DESKTOP.md). For the full contributor/architecture reference -- including build performance, code signing, notarization, and CI details -- see [desktop/README.md](./desktop/README.md).
|
|
||||||
|
|
||||||
### Workspace Position
|
|
||||||
|
|
||||||
`desktop/` is a **sibling workspace**, not an npm-workspaces conversion. It has its own `package.json`, its own `node_modules`, and its own TypeScript toolchain. It pins **Electron 35** (bundled Node 22.16). It consumes the rest of the repo as plain files and touches no other workspace's runtime behavior.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
subgraph repo["Claude-Code-Agent-Monitor (repo root)"]
|
|
||||||
server["server/<br/>Express API · SQLite · WebSocket"]
|
|
||||||
client["client/<br/>React + Vite SPA"]
|
|
||||||
scripts["scripts/<br/>hook installer/handler, import, seed"]
|
|
||||||
mcp["mcp/<br/>local MCP server"]
|
|
||||||
vscode["vscode-extension/"]
|
|
||||||
desktop["desktop/<br/>Electron shell (sibling workspace)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
desktop -->|"require() in-process"| server
|
|
||||||
desktop -->|"loads built SPA from"| client
|
|
||||||
desktop -->|"auto-installs hooks via"| scripts
|
|
||||||
server -->|"serves static"| client
|
|
||||||
|
|
||||||
style desktop fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
style server fill:#238636,stroke:#196c2e,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
The **only** change outside `desktop/` is a behavior-preserving refactor of `server/index.js` (see [Background Services & Hook Bootstrap](#background-services--hook-bootstrap-1) below). `client/`, `scripts/`, `mcp/`, and `vscode-extension/` are untouched.
|
|
||||||
|
|
||||||
### Process Model
|
|
||||||
|
|
||||||
Electron runs a **main process** (Node.js) and one or more **renderer processes** (Chromium). In this app:
|
|
||||||
|
|
||||||
- The **main process** hosts the embedded Express server _and_ manages the window, tray, and menus. There is **no child process and no IPC** for the server -- it runs inside the main process's own event loop.
|
|
||||||
- The **renderer** is plain Chromium loading `http://127.0.0.1:<port>` -- exactly the same origin a normal browser would use. `preload.ts` is intentionally empty (`contextIsolation: true`, `nodeIntegration: false`, `webSecurity: true`), so the renderer has **zero privileged surface**.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph main["Electron Main Process (Node 22 / Electron 35)"]
|
|
||||||
boot["main.ts<br/>lifecycle"]
|
|
||||||
host["server-host.ts<br/>embedded server"]
|
|
||||||
express["server/index.js<br/>Express + WS + SQLite"]
|
|
||||||
tray["tray.ts"]
|
|
||||||
menu["menu.ts"]
|
|
||||||
host --> express
|
|
||||||
boot --> host
|
|
||||||
boot --> tray
|
|
||||||
boot --> menu
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph renderer["Renderer Process (Chromium)"]
|
|
||||||
win["BrowserWindow<br/>React dashboard"]
|
|
||||||
preload["preload.ts<br/>(empty -- no bridge)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
express -->|"http + ws on 127.0.0.1:port"| win
|
|
||||||
win -.->|loads| preload
|
|
||||||
|
|
||||||
hooks["Claude Code hooks<br/>(separate node processes)"] -->|"POST /api/hooks/event"| express
|
|
||||||
|
|
||||||
style main fill:#0d1117,stroke:#30363d,color:#e6edf3
|
|
||||||
style renderer fill:#161b22,stroke:#30363d,color:#e6edf3
|
|
||||||
```
|
|
||||||
|
|
||||||
### In-Process Server Hosting
|
|
||||||
|
|
||||||
`server-host.ts` is the **only file** that imports `server/index.js`. The dashboard server already exports `{ createApp, startServer, startBackgroundServices }` and serves the built React client (`client/dist`) as static assets in production -- so the host imports that module directly, with no child process, no IPC, and no port marshalling.
|
|
||||||
|
|
||||||
| Component | Responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| **`main.ts`** | Main-process entry. Single-instance lock, app menu + tray wiring, dashboard window, `Restart Server`, lifecycle (`window-all-closed`, `before-quit`). |
|
|
||||||
| **`server-host.ts`** | In-process Express boot: port discovery, adoption, `better-sqlite3` ABI patch, `startBackgroundServices()` + hook bootstrap, clean DB close. Returns a `ServerHandle`. |
|
|
||||||
| **`window.ts`** | `BrowserWindow` with persisted geometry (`userData/window-state.json`). External links open in the system browser. Sets the window/taskbar `icon` to the colored app logo (`assets/icon.ico` on Windows, `icon.png` elsewhere) so an unpackaged `desktop:dev` run no longer shows the generic Electron icon. |
|
|
||||||
| **`menu.ts` / `tray.ts`** | Native application menu and menu-bar / notification-area (tray) icon. `tray.ts` selects a platform tray image — a macOS template glyph that the OS tints for the menu bar, or the colored `assets/icon.ico` for the Windows notification area (a black template would vanish on the dark taskbar). Tray uses a single-click dropdown (left or right) with a **live status snapshot** queried straight from SQLite at click time — server port, active sessions, working agents, events today — followed by *Open Dashboard*, *Open in Browser*, *Restart Server*, *Show Logs*, *Open at Login* (toggle), and *Quit*. The menu is rebuilt on each open so every value stays current. Snapshot rows are enabled and click-to-open-dashboard rather than disabled (which the OS dims). The application menu's *File ▸ Open Dashboard* (⌘1) is **macOS-only** — there the global menu bar persists after the window hides; on Windows/Linux the window-attached menu can't reopen a hidden window, so reopening is the tray's job. `focusOrCreateWindow` calls `show()` unconditionally so the tray reliably raises a backgrounded/minimized window (a bare `focus()` on Windows often only flashes the taskbar). |
|
|
||||||
| **`login-item.ts`** | Auto-start-at-login toggle through Electron's first-party `app.setLoginItemSettings` API on every platform. On macOS it drives the modern `SMAppService` Login Items (not a `LaunchAgent` plist); on Windows it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry. Login launches are tagged with a `--ccam-hidden` arg so the app can stay tray-only at startup — Windows has no `wasOpenedAtLogin` signal, so the arg is the cross-platform detection mechanism. |
|
|
||||||
| **`shell-path.ts`** | (macOS) Recovers the user's login-shell `PATH` at startup and merges it onto `process.env.PATH`, so the embedded server (and the `claude` CLI it spawns) is not limited to launchd's minimal `PATH`. On Windows the process already inherits the full user `PATH`, so no recovery is needed. |
|
|
||||||
| **`logger.ts`** | File logger to `~/Library/Logs/Claude Code Monitor/desktop.log` (macOS) or `%APPDATA%\Claude Code Monitor\logs\desktop.log` (Windows) -- the main process has no console when launched from Finder / Explorer. |
|
|
||||||
| **`constants.ts`** | Shared identifiers, including the `APP_ID` (`com.vn.smartgift.ccam.desktop`) that `main.ts` sets as the Windows AppUserModelId. |
|
|
||||||
|
|
||||||
`server-host.ts` resolves the directory containing the bundled `server/` and `client/dist/` via `resolveAppRoot()`: `process.resourcesPath/app` when packaged, or the repo root (one directory up from `desktop/`) in development.
|
|
||||||
|
|
||||||
The `ServerHandle` returned to `main.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ServerHandle {
|
|
||||||
url: string; // e.g. "http://127.0.0.1:4820"
|
|
||||||
port: number;
|
|
||||||
ownedByUs: boolean; // false when an existing server was adopted
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port Discovery & Adoption
|
|
||||||
|
|
||||||
On startup `server-host.ts` picks a port, then either adopts an already-healthy server or boots its own. **Adoption** -- `probePort()` connects to `:4820`, then checks that the listener answers `GET /api/health` with `{ status: "ok" }`. If a healthy dashboard server is already running there (e.g. the user ran `npm start` in a terminal), the desktop app **adopts** it rather than double-binding -- no SQLite contention. An adopted server is not owned by the app, so quitting the app leaves it running.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
start["startEmbeddedServer()"] --> forced{"CCAM_DESKTOP_BIND_PORT set?"}
|
|
||||||
forced -->|yes| bind["bind exactly that port<br/>(no adoption, no fallback)"]
|
|
||||||
forced -->|no| adopt{"healthy server<br/>already on :4820?"}
|
|
||||||
adopt -->|yes| reuse["adopt it<br/>ownedByUs = false"]
|
|
||||||
adopt -->|no| pick["pickFreePort()"]
|
|
||||||
|
|
||||||
pick --> p1{":4820 free?"}
|
|
||||||
p1 -->|yes| use4820["use 4820"]
|
|
||||||
p1 -->|no| p2{"any of<br/>:4821–:4829 free?"}
|
|
||||||
p2 -->|yes| usefb["use that"]
|
|
||||||
p2 -->|no| p3{"any of<br/>:49152–:49500 free?"}
|
|
||||||
p3 -->|yes| userand["use that"]
|
|
||||||
p3 -->|no| fail["throw — no free port"]
|
|
||||||
|
|
||||||
bind --> bootsrv["createApp() + startServer()"]
|
|
||||||
use4820 --> bootsrv
|
|
||||||
usefb --> bootsrv
|
|
||||||
userand --> bootsrv
|
|
||||||
bootsrv --> healthy["waitForHealthy()<br/>poll /api/health ≤ 30s"]
|
|
||||||
healthy --> bg["bootstrapOwnedServer()"]
|
|
||||||
bg --> handle["ServerHandle ownedByUs = true"]
|
|
||||||
reuse --> handleR["ServerHandle ownedByUs = false"]
|
|
||||||
|
|
||||||
style reuse fill:#9e6a03,stroke:#7d5300,color:#fff
|
|
||||||
style fail fill:#da3633,stroke:#b62324,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
Port preference order is **4820 → 4821–4829 → a random port in 49152–49500**. Two environment overrides exist primarily for testing: `CCAM_DESKTOP_BIND_PORT` binds an exact port (disabling adoption and fallback, used by the smoke test), and `CCAM_DESKTOP_NO_ADOPT=1` always starts a fresh server. Before `require()`ing the server module, the host sets `NODE_ENV=production`, `DASHBOARD_PORT=<port>`, and `DASHBOARD_DATA_DIR=<userData>/data` (see [Writable Data Directory](#writable-data-directory) below) so the server reads them from `process.env`.
|
|
||||||
|
|
||||||
### Writable Data Directory
|
|
||||||
|
|
||||||
A packaged install directory is **read-only** in practice: on macOS a `.app` bundle installed under `/Applications`, code-signed, or run through **app translocation** cannot write to `Resources/app/`, and on Windows the NSIS install dir under `%ProgramFiles%` (or the read-only mount a portable build runs from) is no place for mutable state. The dashboard's SQLite database and the VAPID keypair (`server/lib/push.js`) are writable state, so they must not live inside the bundle / install dir. Before booting the embedded server, `server-host.ts` creates `app.getPath('userData')/data` and points the server at it via the `DASHBOARD_DATA_DIR` environment variable:
|
|
||||||
|
|
||||||
- `server/db.js` honors `DASHBOARD_DATA_DIR` for the SQLite file.
|
|
||||||
- `server/lib/push.js` honors it for the persisted VAPID keys.
|
|
||||||
|
|
||||||
The resulting per-user location is `~/Library/Application Support/Claude Code Monitor/data/` on macOS and `%APPDATA%\Claude Code Monitor\data\` on Windows. Because this lives outside the bundle / install dir, imported history and persisted events **survive an app reinstall or update** (the Windows NSIS uninstaller keeps this data by default). Without this, writing a database into the read-only install location failed on a packaged build and broke History Import and event persistence.
|
|
||||||
|
|
||||||
The standalone `node server/index.js` path is **unaffected**: `DASHBOARD_DATA_DIR` is unset there, and `server-host.ts` only sets it when it is not already defined -- so `server/db.js` falls back to its usual repo-relative default.
|
|
||||||
|
|
||||||
### Shell `PATH` Recovery (macOS)
|
|
||||||
|
|
||||||
This step is **macOS-only**. A macOS app launched from Finder, the Dock, or Login Items auto-start is spawned by `launchd`, which hands it a **minimal `PATH`** (roughly `/usr/bin:/bin:/usr/sbin:/sbin`) and does **not** source the user's shell profile. The dashboard's "Run Claude" feature (`server/routes/run.js`, `server/lib/run-spawner.js`) spawns the `claude` CLI, which is almost always installed somewhere only the shell `PATH` knows about (`/opt/homebrew/bin`, `~/.local/bin`, `~/.claude/local`, a Node version-manager's bin dir). Under launchd's `PATH`, `claude` cannot be resolved or spawned.
|
|
||||||
|
|
||||||
`shell-path.ts` repairs this **before the server boots**: at startup it runs the user's login+interactive shell once (`$SHELL -ilc`, so `.zprofile`/`.zshrc` are sourced), captures the resulting `PATH` between sentinel markers, and merges it -- plus a fallback list of common CLI install directories -- onto `process.env.PATH`. The merge is order-preserving and deduplicated, so it is idempotent. Because the embedded server runs in the same process, it and every `claude` it spawns inherit the corrected `PATH`. (A `claude` shell _alias_ or _function_ still cannot be spawned -- only a real executable on the `PATH` can.)
|
|
||||||
|
|
||||||
On **Windows** there is no equivalent step: a process launched from Explorer, the Start menu, or the `HKCU\…\Run` startup entry already inherits the full user `PATH`, so the embedded server can resolve `claude` directly.
|
|
||||||
|
|
||||||
### `better-sqlite3` Native-Module Handling
|
|
||||||
|
|
||||||
`better-sqlite3` is the only **native** module in the dependency tree, and a native module must be compiled against the exact Node ABI it runs on. The repo-root copy is built for the **system Node** (so `npm run test:server` works for contributors); Electron ships its **own Node ABI**.
|
|
||||||
|
|
||||||
The desktop workspace solves this without disturbing the root install: the desktop workspace has its own `better-sqlite3`, rebuilt for Electron's Node ABI by `electron-builder install-app-deps` (run in its `postinstall`). `server-host.ts` then installs a one-time, **process-local** patch to `Module._resolveFilename` that redirects `require("better-sqlite3")` -- from anywhere in the embedded server -- to that ABI-correct copy.
|
|
||||||
|
|
||||||
Desktop setup is **guarded** so a missing or unbuilt binary never escapes as a raw node-gyp trace or a runtime crash: `desktop/scripts/preflight.js` (shared by `install.js` and `prebuild.js`) verifies the Electron-ABI binary exists and, when it doesn't, prints actionable, copy-pasteable setup help -- the per-OS C++ toolchain prerequisites (or a no-toolchain alternative that fetches Electron's prebuilt binary directly) -- before exiting non-zero. `desktop:install` runs this on install; the `prebuild` gate enforces it before every `desktop:*` build, turning a would-be runtime failure into a build-time error.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
subgraph desk["desktop/node_modules"]
|
|
||||||
d1["better-sqlite3<br/>rebuilt for Electron's ABI<br/>(electron-builder install-app-deps)"]
|
|
||||||
end
|
|
||||||
subgraph root["node_modules (repo root)"]
|
|
||||||
r1["better-sqlite3<br/>built for system Node<br/>(used by npm run test:server)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
patch["ensureNativeModulesPatched()<br/>overrides Module._resolveFilename"]
|
|
||||||
srv["server/db.js<br/>require('better-sqlite3')"]
|
|
||||||
|
|
||||||
srv -->|"request intercepted"| patch
|
|
||||||
patch -->|"redirected to"| d1
|
|
||||||
patch -.->|"everything else<br/>passes through"| root
|
|
||||||
|
|
||||||
style d1 fill:#238636,stroke:#196c2e,color:#fff
|
|
||||||
style patch fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
- The patch is installed exactly once, **before** `server/index.js` is `require()`d, and rewrites _only_ `require("better-sqlite3")` -- every other module resolves normally.
|
|
||||||
- `electron-builder.yml` therefore **excludes** the root `better-sqlite3` from the bundle (it would trip `@electron/universal`'s identical-file detector) and `asarUnpack`s the desktop copy (native `.node` files cannot live inside an `asar` archive).
|
|
||||||
- The `compat-sqlite` (`node:sqlite`) fallback remains a safety net -- one reason the desktop app pins **Electron 35**, whose bundled Node 22.16 has `node:sqlite`.
|
|
||||||
|
|
||||||
### Background Services & Hook Bootstrap
|
|
||||||
|
|
||||||
`node server/index.js` runs its production bootstrap from an `if (require.main === module)` block. Because the desktop app **`require()`s** that module, the block never fires -- so the bootstrap was extracted into an exported `startBackgroundServices()` that both paths call. This is a **behavior-preserving refactor** of `server/index.js`: the standalone server path is functionally unchanged.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph standalone["node server/index.js"]
|
|
||||||
s1["require.main === module"] --> s2["startBackgroundServices()"]
|
|
||||||
end
|
|
||||||
subgraph desktopapp["desktop app"]
|
|
||||||
d1["server-host.ts<br/>bootstrapOwnedServer()"] --> d2["startBackgroundServices()"]
|
|
||||||
d1 --> d3["installHooks()"]
|
|
||||||
end
|
|
||||||
|
|
||||||
d2 --> svc
|
|
||||||
s2 --> svc
|
|
||||||
subgraph svc["Background services"]
|
|
||||||
u["update scheduler"]
|
|
||||||
w["cc-watcher (Claude config watcher)"]
|
|
||||||
r["orphaned-run reconciliation"]
|
|
||||||
end
|
|
||||||
|
|
||||||
style d1 fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
`bootstrapOwnedServer()` runs **once** -- guarded by a module-level flag so a `Restart Server` does not double-register schedulers or watchers -- and:
|
|
||||||
|
|
||||||
1. Calls `startBackgroundServices()` -- the update scheduler, the `cc-watcher` config watcher, and one-time orphaned-run reconciliation.
|
|
||||||
2. Calls `installHooks()` -- writes the Claude Code hook configuration to `~/.claude/settings.json`, so an install-only user (DMG on macOS, `.exe` on Windows) gets events flowing without ever running `npm run install-hooks` from a checkout.
|
|
||||||
|
|
||||||
It runs only when the server is **owned** by the app -- an adopted server has already done its own bootstrap.
|
|
||||||
|
|
||||||
### App Lifecycle
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
participant OS as macOS / Windows
|
|
||||||
participant Main as main.ts
|
|
||||||
participant Host as server-host.ts
|
|
||||||
participant Srv as server/index.js
|
|
||||||
participant UI as BrowserWindow
|
|
||||||
|
|
||||||
OS->>Main: launch app
|
|
||||||
Main->>Main: setAppUserModelId (win32) · requestSingleInstanceLock()
|
|
||||||
alt lock not acquired
|
|
||||||
Main->>OS: exit(0) — focus existing instance
|
|
||||||
end
|
|
||||||
Main->>Host: ensureUserPath() — recover login-shell PATH (macOS only)
|
|
||||||
Main->>Host: startEmbeddedServer()
|
|
||||||
Host->>Host: probe :4820 — adopt if a healthy server answers
|
|
||||||
alt no server to adopt
|
|
||||||
Host->>Host: pickFreePort() · set DASHBOARD_DATA_DIR · patch better-sqlite3 ABI
|
|
||||||
Host->>Srv: require() · createApp() · startServer(port)
|
|
||||||
Host->>Srv: waitForHealthy() — poll /api/health ≤ 30s
|
|
||||||
Host->>Srv: bootstrapOwnedServer() — schedulers, cc-watcher, install hooks
|
|
||||||
end
|
|
||||||
Host-->>Main: ServerHandle { url, port, ownedByUs, stop }
|
|
||||||
Main->>Main: installApplicationMenu() · createTray()
|
|
||||||
alt launched at login (--ccam-hidden / wasOpenedAtLogin)
|
|
||||||
Main->>OS: stay tray-only, hide dock (macOS)
|
|
||||||
else normal launch
|
|
||||||
Main->>UI: createDashboardWindow(url)
|
|
||||||
UI->>Srv: GET http://127.0.0.1:port
|
|
||||||
end
|
|
||||||
Note over Main: window "close" → hide (server keeps running)
|
|
||||||
Note over Main: ⌘Q / Ctrl+Q → confirm (second press bypasses)
|
|
||||||
Note over Main: before-quit → stop owned server + closeEmbeddedDatabase()
|
|
||||||
```
|
|
||||||
|
|
||||||
| Event | Behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| **Second launch** | `requestSingleInstanceLock()` (enabled on **every platform**) fails -- the new process exits and the existing window is focused. |
|
|
||||||
| **Window close** | Intercepted -- the window **hides** (`hide()`); the server and tray keep running. |
|
|
||||||
| **`window-all-closed`** | App stays alive in tray-only mode (the handler is intentionally a no-op). |
|
|
||||||
| **Launched at login** | The dashboard window is **not** shown -- only the tray icon. Detected via macOS `wasOpenedAtLogin` (dock hidden, `openAsHidden`) or, on Windows, the `--ccam-hidden` arg written into the `HKCU\…\Run` startup command. |
|
|
||||||
| **Quit shortcut** | ⌘Q (macOS) / Ctrl+Q (Windows) shows a confirmation dialog; a second press bypasses it. |
|
|
||||||
| **`before-quit`** | If the server is owned: stop the HTTP server, then `closeEmbeddedDatabase()` for a clean WAL checkpoint, then `app.exit(0)`. The DB handle is closed here -- never on `Restart Server`, where the cached `server/db.js` singleton must stay usable. |
|
|
||||||
|
|
||||||
### Packaged App Layout
|
|
||||||
|
|
||||||
`electron-builder` produces `Claude Code Monitor.app` on macOS and `Claude Code Monitor.exe` (NSIS installer + portable) on Windows. On both platforms the Electron main-process code is compiled (`tsc` → `out/`) and packed into `app.asar`; the rest of the repo is shipped as **`extraResources`** -- plain files under the bundle's `Resources/app/` (macOS) or the install dir's `resources\app\` (Windows). The internal layout is the same shape on both:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
appbundle["Claude Code Monitor.app (macOS)<br/>Claude Code Monitor install dir (Windows)"]
|
|
||||||
appbundle --> contents["Contents/ (macOS)<br/>install root (Windows)"]
|
|
||||||
contents --> macos["MacOS/ — Electron binary (macOS)<br/>Claude Code Monitor.exe (Windows)"]
|
|
||||||
contents --> res["Resources/ (macOS)<br/>resources\ (Windows)"]
|
|
||||||
res --> asar["app.asar<br/>(compiled out/**, package.json)"]
|
|
||||||
res --> unpacked["app.asar.unpacked/<br/>node_modules/better-sqlite3 (.node)"]
|
|
||||||
res --> appdir["app/"]
|
|
||||||
appdir --> a1["server/ — Express server (no tests)"]
|
|
||||||
appdir --> a2["client/dist/ — built React SPA"]
|
|
||||||
appdir --> a3["scripts/ — hook-handler, install-hooks"]
|
|
||||||
appdir --> a4["node_modules/ — server runtime deps"]
|
|
||||||
appdir --> a5["package.json"]
|
|
||||||
|
|
||||||
style asar fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
style appdir fill:#238636,stroke:#196c2e,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
At runtime `server-host.ts` resolves this root as `process.resourcesPath/app` when packaged, on both platforms. Everything under the packaged `app/` is **read-only** on a packaged, signed, or app-translocated macOS bundle and on a Windows install under `%ProgramFiles%` (or a portable build's mount) -- so all writable state (the SQLite database, VAPID keys) lives in the per-user data dir (`~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows), **never inside the bundle / install dir** (see [Writable Data Directory](#writable-data-directory)).
|
|
||||||
|
|
||||||
On macOS `electron-builder` produces **two per-architecture DMGs** — one `arm64` (Apple Silicon), one `x64` (Intel) — via `--mac --arm64 --x64` (not a merged universal binary; the release ships both), ad-hoc signed by default so anyone can build a working `.dmg` without a paid Apple Developer account; real Developer ID signing and notarization are opt-in via environment variables (`CSC_LINK`, `APPLE_ID`, etc.). On Windows it produces an **NSIS installer `.exe`** and a **no-install portable `.exe`** (both x64), using `assets/icon.ico` (generated from the source PNG by `desktop/scripts/build-win-icon.ps1`) as the application and tray icon. **`electron-builder` packages for the host OS** -- DMGs build on macOS, Windows `.exe`s build on Windows -- so the two artifacts come from two CI jobs (see below). The `desktop/scripts/prebuild.js` guard also **self-heals** a `better-sqlite3` native binary that a prior cross-arch DMG build (`electron-builder --mac --x64/--arm64`) left compiled for the wrong CPU architecture -- it detects the mismatch via `file` and re-runs `electron-builder install-app-deps`, so `desktop:dev` and `desktop:test` do not fail with `ERR_DLOPEN_FAILED`; on Windows it shells the `.cmd` shims for `npm`/`npx`. CI runs a path-filtered `🍎 macOS Desktop (DMG)` job on `macos-latest` (artifact `ClaudeCodeMonitor-dmg`) and a `🪟 Windows Desktop (EXE)` job on `windows-latest` (artifact `ClaudeCodeMonitor-win`); the release attaches both. See [`desktop/README.md`](./desktop/README.md) for the full build pipeline, build-performance notes, and signing details.
|
|
||||||
|
|
||||||
### Relation to Standalone Deployment
|
|
||||||
|
|
||||||
The desktop app is a fourth deployment mode alongside Development, Production, and Container (see [Deployment Modes](#deployment-modes)). The data path is **identical to the standalone Production path** -- Claude Code hooks `POST /api/hooks/event` to the embedded Express server, which writes to SQLite and broadcasts over WebSocket to the renderer. The only structural difference is that the server runs inside the Electron main process instead of a standalone `node server/index.js`, and the renderer is a `BrowserWindow` rather than a browser tab pointed at the same origin.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
| Area | Approach |
|
| Area | Approach |
|
||||||
@@ -2643,26 +2371,6 @@ graph LR
|
|||||||
| **File watching** | `node --watch` + Vite HMR | None |
|
| **File watching** | `node --watch` + Vite HMR | None |
|
||||||
| **Source maps** | Inline | External files |
|
| **Source maps** | Inline | External files |
|
||||||
|
|
||||||
### Desktop App (macOS & Windows)
|
|
||||||
|
|
||||||
The native desktop app (macOS `.app`/`.dmg`, Windows NSIS / portable `.exe`) is a self-contained deployment mode: a single Electron process embeds the Express server in-process and renders the React client in a `BrowserWindow`. No terminal, no separate `npm start`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
LAUNCH["Open Claude Code Monitor<br/>(.app / .exe)"] --> MAIN["Electron main process<br/>(Node 22 / Electron 35)"]
|
|
||||||
MAIN --> HOST["server-host.ts<br/>port discovery + adopt"]
|
|
||||||
HOST --> SERVER["server/index.js (in-process)<br/>Port 4820 → fallback"]
|
|
||||||
SERVER -->|serves| DIST["client/dist/<br/>(extraResources)"]
|
|
||||||
MAIN --> WIN["BrowserWindow"]
|
|
||||||
WIN --> SERVER
|
|
||||||
|
|
||||||
style MAIN fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
style SERVER fill:#339933,stroke:#5cb85c,color:#fff
|
|
||||||
style DIST fill:#646CFF,stroke:#818cf8,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
The hook ingestion path (Claude Code hooks → `POST /api/hooks/event` → SQLite → WebSocket) is **identical to the standalone Production path** -- only the process that hosts the server differs. See [Desktop App Architecture](#desktop-app-architecture-macos--windows--electron) for the full design.
|
|
||||||
|
|
||||||
### MCP Sidecar (Optional)
|
### MCP Sidecar (Optional)
|
||||||
|
|
||||||
The MCP server runs as a sidecar alongside the dashboard, connecting to the same API. It supports three transport modes:
|
The MCP server runs as a sidecar alongside the dashboard, connecting to the same API. It supports three transport modes:
|
||||||
@@ -2748,58 +2456,6 @@ docker run -d -p 4820:4820 \
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `CCAM_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
|
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `CCAM_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
|
||||||
|
|
||||||
### Cloud Deployment
|
|
||||||
|
|
||||||
For production cloud deployments, the `deployments/` directory provides enterprise-grade infrastructure supporting four cloud providers and multiple deployment strategies.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TB
|
|
||||||
subgraph "Deployment Pipeline"
|
|
||||||
direction LR
|
|
||||||
CI["CI Pipeline<br/>Build · Test · Scan"] --> DEPLOY["Deployment<br/>Helm · Kustomize · Terraform"]
|
|
||||||
DEPLOY --> VERIFY["Verification<br/>Health Check · Smoke Tests"]
|
|
||||||
VERIFY -->|Fail| ROLLBACK["Rollback<br/>Instant Revert"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Infrastructure"
|
|
||||||
direction TB
|
|
||||||
subgraph "Compute"
|
|
||||||
BLUE["Blue Slot<br/>Current Version"]
|
|
||||||
GREEN["Green Slot<br/>New Version"]
|
|
||||||
end
|
|
||||||
LB["Load Balancer<br/>TLS 1.3 · WebSocket<br/>Weighted Routing"]
|
|
||||||
PV["Persistent Storage<br/>Encrypted NFS"]
|
|
||||||
MON["Monitoring<br/>Prometheus · Grafana<br/>13 Alert Rules"]
|
|
||||||
OTEL["OTel Collector<br/>Coralogix"]
|
|
||||||
end
|
|
||||||
|
|
||||||
LB -->|"Active"| BLUE
|
|
||||||
LB -.->|"Standby"| GREEN
|
|
||||||
BLUE & GREEN --> PV
|
|
||||||
MON -->|"Scrape"| BLUE & GREEN
|
|
||||||
BLUE & GREEN -->|"logs + metrics + traces"| OTEL
|
|
||||||
|
|
||||||
style BLUE fill:#2563eb,color:#fff
|
|
||||||
style GREEN fill:#16a34a,color:#fff
|
|
||||||
style LB fill:#7c3aed,color:#fff
|
|
||||||
style CI fill:#2088ff,color:#fff
|
|
||||||
style OTEL fill:#4f46e5,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
| Capability | Details |
|
|
||||||
| --- | --- |
|
|
||||||
| **Cloud Providers** | AWS (ECS Fargate + ALB), GCP (Cloud Run + GCLB), Azure (ACI + App Gateway), OCI (OKE + LBaaS) |
|
|
||||||
| **Deployment Methods** | Helm chart, Kustomize overlays, Terraform modules |
|
|
||||||
| **Release Strategies** | Rolling update, blue-green (instant switchover), canary (automated analysis) |
|
|
||||||
| **Environments** | Dev, staging, production with per-environment configuration |
|
|
||||||
| **CI/CD** | GitHub Actions and GitLab CI pipelines with Trivy security scanning |
|
|
||||||
| **Observability** | Prometheus scraping, 13 alert rules, Grafana dashboard (16 panels), Alertmanager routing, Coralogix full-stack observability (logs, metrics, traces, SLO tracking) via OpenTelemetry Collector |
|
|
||||||
| **Operations** | Scripts for deploy, rollback, blue-green switch, database backup/restore, teardown |
|
|
||||||
| **Security** | Restricted PSS, network policies, TLS enforcement, OIDC auth, no long-lived credentials |
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> 📘 **Full guide:** See [DEPLOYMENT.md](DEPLOYMENT.md) for step-by-step deployment instructions, and [deployments/README.md](deployments/README.md) for the infrastructure technical reference.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Statusline Utility
|
## Statusline Utility
|
||||||
@@ -2896,7 +2552,7 @@ Claude Code invokes this command on each update, piping a JSON payload to stdin.
|
|||||||
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
||||||
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||||
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
||||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Custom theme config for the dark UI |
|
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light toggle re-themes the app with one class flip rather than per-component `dark:` variants |
|
||||||
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
||||||
| **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) |
|
| **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) |
|
||||||
| **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues |
|
| **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues |
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
||||||
- `client/`: React + Vite UI.
|
- `client/`: React + Vite UI.
|
||||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
|
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
|
||||||
- `mcp/`: local MCP server exposing dashboard operations as tools.
|
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
|
||||||
|
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||||
|
|
||||||
## Lanes
|
## Lanes
|
||||||
|
|
||||||
@@ -22,7 +23,10 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
|||||||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`.
|
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`.
|
||||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||||
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
|
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
|
||||||
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence.
|
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||||||
|
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
||||||
|
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
|
||||||
|
- **A profile's `profile.env` is parsed, never sourced.** Hooks are executed deliberately; config is only read. Sourcing arbitrary shell from a user's repository into the dashboard process would be a code-execution path. Hook names always come from the fixed allowlist in `server/lib/lane-profile.js`, never from a request.
|
||||||
- Pipeline templates are JSON files (`server/data/pipelines/`) with node definitions; custom templates override built-ins when `DASHBOARD_PIPELINES_DIR` is set.
|
- Pipeline templates are JSON files (`server/data/pipelines/`) with node definitions; custom templates override built-ins when `DASHBOARD_PIPELINES_DIR` is set.
|
||||||
- Nodes render in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped, amber), `pending` (not reached).
|
- Nodes render in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped, amber), `pending` (not reached).
|
||||||
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
||||||
@@ -46,6 +50,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
|||||||
- Server tests: `npm run test:server`
|
- Server tests: `npm run test:server`
|
||||||
- Client tests: `npm run test:client`
|
- Client tests: `npm run test:client`
|
||||||
- MCP install/build/start: `npm run mcp:install`, `npm run mcp:build`, `npm run mcp:start`
|
- MCP install/build/start: `npm run mcp:install`, `npm run mcp:build`, `npm run mcp:start`
|
||||||
|
- MCP build freshness: `npm run mcp:check-build` (must pass whenever `mcp/src` changes)
|
||||||
- MCP typecheck: `npm run mcp:typecheck`
|
- MCP typecheck: `npm run mcp:typecheck`
|
||||||
- CLI (after setup): `ccam <command>` — terminal access to the full dashboard surface (`bin/ccam.js`; `ccam help` lists commands)
|
- CLI (after setup): `ccam <command>` — terminal access to the full dashboard surface (`bin/ccam.js`; `ccam help` lists commands)
|
||||||
|
|
||||||
@@ -68,3 +73,4 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
|||||||
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
|
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
|
||||||
- Use project skills from `.claude/skills/` for repeatable workflows.
|
- Use project skills from `.claude/skills/` for repeatable workflows.
|
||||||
- Use `.claude/agents/` subagents for focused review or investigation passes.
|
- Use `.claude/agents/` subagents for focused review or investigation passes.
|
||||||
|
- **Declare lane stage even in plain chat, not just inside skills.** `ccam stage` (see `docs/LANES.md` § Reporting a stage) is a reporting command, not a skill-only ritual — any Claude session working inside an adopted lane's cwd should call it on real stage transitions (starting to plan, starting to implement, running tests, opening the PR, etc.), whether or not a skill is driving. Tool-event detection (`server/lib/stage-detect.js`) only ever paints the amber "detected" badge, never the blue `current` ring — a lane worked entirely through plain chat with no `ccam stage` calls will show a stale `current` stage no matter how much real work happens. Skip it only when `ccam stage` reports no lane owns the cwd (not adopted).
|
||||||
|
|||||||
-1080
File diff suppressed because it is too large
Load Diff
-253
@@ -1,253 +0,0 @@
|
|||||||
# Claude Code Monitor — Desktop App (macOS & Windows)
|
|
||||||
|
|
||||||
The dashboard ships with an optional **native desktop app** (built with Electron 35) that wraps the existing server + client into a single application you install once and forget — a macOS `.app` (shipped as a `.dmg`) and a Windows `.exe` (an NSIS installer plus a no-install portable build). Everything you see in the browser at `localhost:4820` lives inside this window, with native OS lifecycle on top: a menu-bar / notification-area (tray) icon, a native application menu, auto-start at login, and a single quit button that cleans up the server.
|
|
||||||
|
|
||||||
## Why this exists in addition to the PWA
|
|
||||||
|
|
||||||
The PWA (added in #144) makes the dashboard installable in Chromium-based browsers, which is great for users who already keep the server running. The desktop app solves the orthogonal problem: **starting and keeping the server running** without a terminal window. Concretely:
|
|
||||||
|
|
||||||
| Capability | PWA | Desktop App |
|
|
||||||
|---|---|---|
|
|
||||||
| Installs to dock / Applications | ✅ | ✅ |
|
|
||||||
| Manages the Express server | ❌ — user must `npm start` separately | ✅ — embedded in-process |
|
|
||||||
| Auto-starts at login | ❌ | ✅ via macOS Login Items / Windows `HKCU\…\Run` |
|
|
||||||
| Menu-bar / notification-area (tray) icon for always-on status | ❌ | ✅ |
|
|
||||||
| Native application menu (⌘ / Ctrl shortcuts, etc.) | ❌ | ✅ |
|
|
||||||
| Survives browser restart | ⚠️ depends on browser | ✅ |
|
|
||||||
|
|
||||||
The two coexist — install whichever fits your workflow.
|
|
||||||
|
|
||||||
## Quick install
|
|
||||||
|
|
||||||
**Option A — download a pre-built installer** (recommended):
|
|
||||||
|
|
||||||
1. Open [**Releases → latest**](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) and grab the asset for your platform. Every `master` commit that bumps the version in `package.json` cuts a new `vX.Y.Z` release automatically (CI publishes it), so this link always lands on the current build — no GitHub sign-in required.
|
|
||||||
|
|
||||||
| Platform | Asset | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| macOS (Apple Silicon) | `ClaudeCodeMonitor-<ver>-arm64.dmg` | drag into `/Applications` |
|
|
||||||
| macOS (Intel) | `ClaudeCodeMonitor-<ver>-x64.dmg` | drag into `/Applications` |
|
|
||||||
| Windows (installer) | `ClaudeCodeMonitor-Setup-<ver>-x64.exe` | per-user install, no admin |
|
|
||||||
| Windows (portable) | `ClaudeCodeMonitor-<ver>-x64-portable.exe` | run without installing |
|
|
||||||
|
|
||||||
2. Want a **per-commit build** instead of waiting for a release? Every green CI run uploads a workflow artifact (sign-in required, 14-day retention) — `ClaudeCodeMonitor-dmg` from the `🍎 macOS Desktop (DMG)` job and `ClaudeCodeMonitor-win` from the `🪟 Windows Desktop (EXE)` job:
|
|
||||||
```bash
|
|
||||||
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-dmg # or ClaudeCodeMonitor-win
|
|
||||||
```
|
|
||||||
3. **macOS:** double-click the DMG → drag `Claude Code Monitor.app` into your `Applications` folder. Open it; macOS may show a Gatekeeper warning the first time — see [Gatekeeper & SmartScreen](#gatekeeper--smartscreen-first-launch) below.
|
|
||||||
4. **Windows:** run `ClaudeCodeMonitor-Setup-<ver>-x64.exe` (per-user, no admin) and follow the wizard, or just run the `*-portable.exe` to launch without installing. Windows **SmartScreen** may show *"Windows protected your PC"* the first time — see [Gatekeeper & SmartScreen](#gatekeeper--smartscreen-first-launch) below.
|
|
||||||
|
|
||||||
**Option B — build locally:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In the project root, after `git clone`:
|
|
||||||
npm run setup # installs root + client + vscode-extension deps
|
|
||||||
npm run build # builds the React client
|
|
||||||
npm run desktop:install # installs Electron + electron-builder
|
|
||||||
|
|
||||||
# Build for macOS (run ON macOS) — pick one:
|
|
||||||
npm run desktop:dmg:arm64 # Apple Silicon only — FAST (~1 min); use this for your own Mac
|
|
||||||
npm run desktop:dmg:x64 # Intel only — FAST
|
|
||||||
npm run desktop:dmg # BOTH per-arch DMGs (arm64 + x64) — the release build; slower (packages each arch)
|
|
||||||
npm run desktop:dmg:universal # ONE merged universal DMG (arm64 + x86_64 in a single file) — optional, slowest
|
|
||||||
|
|
||||||
# Build for Windows (run ON Windows) — pick one:
|
|
||||||
npm run desktop:win # NSIS installer → desktop/release/ClaudeCodeMonitor-Setup-<ver>-x64.exe
|
|
||||||
npm run desktop:win:portable # no-install portable → desktop/release/ClaudeCodeMonitor-<ver>-x64-portable.exe
|
|
||||||
|
|
||||||
# electron-builder packages for the HOST OS — you cannot build a Windows .exe
|
|
||||||
# on macOS or a macOS .dmg on Windows.
|
|
||||||
|
|
||||||
# Open the macOS DMG you just built. desktop:dmg:arm64 / :x64 wipe release/ and emit
|
|
||||||
# one DMG; desktop:dmg wipes release/ and emits both (…-arm64.dmg + …-x64.dmg).
|
|
||||||
open desktop/release/ClaudeCodeMonitor-*-arm64.dmg # …-x64.dmg for the Intel build
|
|
||||||
```
|
|
||||||
|
|
||||||
> **`desktop:dmg` builds both architectures, so it takes longer.** It packages
|
|
||||||
> and ad-hoc-signs the app **twice** — once for `arm64`, once for `x64` — and
|
|
||||||
> emits two separate DMGs (`…-arm64.dmg` + `…-x64.dmg`). It does **not** merge
|
|
||||||
> them into a single universal binary; the release ships the two per-arch DMGs.
|
|
||||||
> For running on **your own Mac**, use the arch-specific command
|
|
||||||
> (`desktop:dmg:arm64` / `desktop:dmg:x64`) — half the work, and it finishes in
|
|
||||||
> about a minute. CI runs `desktop:dmg` for you and uploads both DMGs as the
|
|
||||||
> `ClaudeCodeMonitor-dmg` artifact, so you rarely need to build them locally.
|
|
||||||
|
|
||||||
## What happens when you launch the app
|
|
||||||
|
|
||||||
1. The Electron main process picks a free port — preferring **4820**, falling back to 4821–4829, then a random high port if all those are taken.
|
|
||||||
2. If something already answers `/api/health` on port 4820 (e.g. you ran `npm start` in a terminal), the app **adopts that server** and skips starting a second one. No double-binding, no SQLite contention.
|
|
||||||
3. Otherwise it `require()`s `server/index.js` directly in-process — same Node runtime as the main process, same memory. Boot is typically under two seconds.
|
|
||||||
4. On startup the server records its **live port** to `~/.claude/.agent-dashboard.json`. The Claude Code hook handler reads that file, so events still reach the dashboard when the app bound a fallback port instead of 4820.
|
|
||||||
5. The dashboard window opens — unless the app was launched at login (on macOS via Login Items; on Windows via the `HKCU\…\Run` entry, detected through a `--ccam-hidden` launch arg since Windows has no `wasOpenedAtLogin`), in which case it stays tray-only.
|
|
||||||
6. A tray icon appears — the macOS **menu bar** or the Windows **notification area**. One click opens a dropdown with a **live status snapshot** (server port, active sessions, working agents, events today — all clickable to jump into the dashboard) plus *Open Dashboard*, *Open in Browser*, *Restart Server*, *Show Logs*, *Open at Login* (toggle), and *Quit*.
|
|
||||||
|
|
||||||
## Lifecycle semantics
|
|
||||||
|
|
||||||
- **Closing the window hides it.** The server keeps running, the tray icon stays, and (on macOS) the **dock icon stays too** — clicking either re-opens the window. Independent signals that the app is still alive.
|
|
||||||
- **Quitting** (⌘Q / Ctrl+Q, *Quit* in the application menu, or *Quit* in the tray menu) pops a confirmation dialog — *"Quit Claude Code Monitor? Press ⌘Q again to skip this prompt and quit immediately."* Press **Quit** in the dialog, or **press ⌘Q / Ctrl+Q a second time** to bypass the prompt. Either way the SQLite handle is checkpointed cleanly before the process exits.
|
|
||||||
- **Tray** — the macOS menu bar / Windows notification area. macOS uses a black template glyph the OS tints for light/dark menu bars; Windows uses the colored `icon.ico`, because a template glyph would vanish on the dark taskbar. A single click (left or right) opens the dropdown, which shows a **live status snapshot** pulled straight from the embedded SQLite handle each time it opens: server port, active sessions, working agents, and events today. Snapshot rows are clickable — they open the dashboard. The tray's *Open Dashboard* reliably **raises** the window even when it is minimized or behind other windows. (The application menu's *File ▸ Open Dashboard* / ⌘1 is **macOS-only** — on Windows/Linux a window-attached menu accelerator can't reopen a hidden window, so reopening is the tray's job there.)
|
|
||||||
- **Window / taskbar icon** — the `BrowserWindow` sets its `icon` to the colored app logo (`icon.ico` on Windows, `icon.png` elsewhere — the same logo as the macOS Dock, rendered from `assets/icon.svg`), so an unpackaged `desktop:dev` run shows the real app logo in the title bar / taskbar instead of the generic Electron icon. The macOS dev Dock icon is set too; packaged apps already get theirs from the bundle `.icns`/`.exe`.
|
|
||||||
- **Open-at-login toggle:** flip *Open at Login* in the tray menu (or the app menu). Both platforms go through Electron's first-party `app.*LoginItemSettings` API — no third-party deps. On **macOS** it registers via the `SMAppService` API, so the entry appears under → *System Settings → General → Login Items*. On **Windows** it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry, visible under *Task Manager → Startup*; a login-triggered launch is detected via a `--ccam-hidden` arg (Windows has no `wasOpenedAtLogin`). On Linux the toggle is a no-op (unsupported).
|
|
||||||
- **Single-instance:** double-launching just focuses the existing window. No second server, no port collision. (Applies on every platform.)
|
|
||||||
- **Logs** live at `~/Library/Logs/Claude Code Monitor/desktop.log` on macOS and `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows (use *Show Logs* in the tray menu to open the folder).
|
|
||||||
- **Your data** (the SQLite database and VAPID keys) lives outside the app bundle / install dir, so it **survives app reinstalls and updates** — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows. The Windows NSIS uninstaller **keeps this data by default** (`deleteAppDataOnUninstall: false`), mirroring how dragging the `.app` to the Trash on macOS never touches your data.
|
|
||||||
- **The `claude` CLI on PATH.** On **macOS** the app resolves it using your login-shell `PATH`, recovered at startup — so "Run Claude" works even though a Finder/Dock-launched app would otherwise only inherit a minimal `PATH`. On **Windows** the inherited user `PATH` already includes it, so no recovery is needed.
|
|
||||||
- **Notifications** (including the in-dashboard *Send test notification* button) are delivered as **native OS toasts** on both platforms when running inside the app — the embedded server calls Electron's `Notification` API directly. On Windows the app sets an `AppUserModelId` (`com.vn.smartgift.ccam.desktop`, matching the electron-builder `appId`) so toasts attribute to the app and its taskbar windows group correctly. Web Push doesn't work reliably inside Electron (Chromium-in-Electron ships without Firebase Cloud Messaging credentials, so `pushManager.subscribe` returns endpoints nothing can deliver to), and this path bypasses it entirely. The web dashboard at `npm start` continues to use Web Push as before.
|
|
||||||
- **Coexists with the web dashboard.** You can run the desktop app and `npm run dev` (or `npm start`) at the same time. Each server writes its `{port, pid, startedAt, dataDir}` entry to a shared discovery file at `~/.claude/.agent-dashboard.json`, and the Claude Code hook handler POSTs to **one ingest target per unique SQLite data directory** (lowest port wins when both share `~/.claude/agent-dashboard`, so events are never double-ingested). Servers with **different** databases (e.g. the desktop app's Application Support dir alongside `npm run dev`) still each receive hooks and stay real-time.
|
|
||||||
|
|
||||||
## File layout (for contributors)
|
|
||||||
|
|
||||||
```
|
|
||||||
desktop/
|
|
||||||
├── package.json # Electron + electron-builder
|
|
||||||
├── tsconfig.json
|
|
||||||
├── electron-builder.yml # macOS (dmg) + Windows (nsis/portable) targets; signing/notarization hooks
|
|
||||||
├── assets/ # icon.svg + generated icon.icns (macOS) + icon.ico (Windows) + tray PNGs
|
|
||||||
├── src/
|
|
||||||
│ ├── main.ts # main process entry, lifecycle; setAppUserModelId on win32
|
|
||||||
│ ├── server-host.ts # in-process Express boot, port discovery, adopt
|
|
||||||
│ ├── window.ts # BrowserWindow + persisted state
|
|
||||||
│ ├── tray.ts # tray icon (platform image: template PNG on macOS, icon.ico on Windows) + context menu
|
|
||||||
│ ├── menu.ts # native application menu
|
|
||||||
│ ├── login-item.ts # open-at-login (macOS Login Items + Windows HKCU\…\Run startup)
|
|
||||||
│ ├── shell-path.ts # recover the user's shell PATH (find `claude`)
|
|
||||||
│ ├── preload.ts # (empty — kept for future renderer bridges)
|
|
||||||
│ ├── logger.ts # file logger
|
|
||||||
│ └── constants.ts # incl. APP_ID (matches electron-builder appId)
|
|
||||||
├── scripts/
|
|
||||||
│ ├── install.js # `desktop:install` wrapper: runs npm install, then prints actionable native-dep help + exits non-zero on failure
|
|
||||||
│ ├── preflight.js # shared native-dep check (hasBetterSqliteBinary) + per-OS prerequisite help (printNativeDepHelp)
|
|
||||||
│ ├── prebuild.js # ensures root + client are built before tsc; shells npm/npx on Windows (.cmd shims); fails fast with setup help when the better-sqlite3 native binary is missing
|
|
||||||
│ ├── build-icons.sh # SVG → PNG/ICNS + tray PNGs via qlmanage/sips/iconutil (macOS)
|
|
||||||
│ ├── build-win-icon.ps1 # icon.png → icon.ico for Windows (PowerShell + .NET)
|
|
||||||
│ └── notarize.js # electron-builder afterSign hook (opt-in; macOS only)
|
|
||||||
└── tests/
|
|
||||||
└── smoke.test.mjs # spawn-and-probe /api/health (resolves the real electron binary via createRequire)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Changes outside `desktop/` are deliberately minimal:**
|
|
||||||
|
|
||||||
- `server/index.js` — a behavior-preserving refactor: the post-listen bootstrap (one-time legacy-session import, update scheduler, Claude Code config watcher, orphaned-run reconciliation) was extracted into an exported `startBackgroundServices()` so the embedded server runs exactly what `node server/index.js` runs. The standalone server path is functionally unchanged. (The legacy-session import previously sat in the standalone-only `require.main` block, so the desktop dashboard started empty — moving it into `startBackgroundServices()` fixes that.) It also now publishes its live port via `server/lib/server-info.js` on startup.
|
|
||||||
- `server/lib/server-info.js` *(new)* — writes/reads the `~/.claude/.agent-dashboard.json` port discovery file.
|
|
||||||
- `scripts/hook-handler.js` — resolves the dashboard port from the discovery file (falling back to `CLAUDE_DASHBOARD_PORT`, then 4820), so hook events reach the server even when it bound a fallback port.
|
|
||||||
|
|
||||||
`client/`, `mcp/`, and `vscode-extension/` are untouched. The Electron main process is otherwise just a host for the same code.
|
|
||||||
|
|
||||||
## Gatekeeper & SmartScreen (first launch)
|
|
||||||
|
|
||||||
### macOS — Gatekeeper
|
|
||||||
|
|
||||||
The DMG is **ad-hoc signed** by default — that's all the project can offer without a paid Apple Developer ID. macOS will warn the first time you open it: *"Apple could not verify…"*.
|
|
||||||
|
|
||||||
Two ways past it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Easiest: strip the quarantine attribute from the DMG before opening.
|
|
||||||
xattr -cr ~/Downloads/ClaudeCodeMonitor-*.dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
Or open → *System Settings → Privacy & Security*, scroll to the blocked DMG, click *Open Anyway*.
|
|
||||||
|
|
||||||
### Windows — SmartScreen
|
|
||||||
|
|
||||||
The Windows `.exe` (both the installer and the portable build) is **unsigned** by default, so Windows **SmartScreen** may show *"Windows protected your PC"* the first time you run it. Click **More info → Run anyway** to launch it.
|
|
||||||
|
|
||||||
Authenticode signing is opt-in for the maintainer: provide a code-signing certificate via `CSC_LINK` (a base64-encoded `.p12`) and `CSC_KEY_PASSWORD` and electron-builder signs the `.exe` automatically — no code change required. A signed build skips the SmartScreen prompt.
|
|
||||||
|
|
||||||
### Notarization (for the maintainer)
|
|
||||||
|
|
||||||
When you're ready to make this go away for everyone, add these three repository secrets:
|
|
||||||
|
|
||||||
| Secret | Where it comes from |
|
|
||||||
|---|---|
|
|
||||||
| `APPLE_ID` | Your Apple ID email |
|
|
||||||
| `APPLE_TEAM_ID` | Your Apple Developer team ID |
|
|
||||||
| `APPLE_APP_SPECIFIC_PASSWORD` | An app-specific password created at appleid.apple.com |
|
|
||||||
|
|
||||||
Optionally, also `CSC_LINK` (base64-encoded `.p12`) and `CSC_KEY_PASSWORD` to provide an explicit Developer ID certificate from outside the runner keychain. The CI workflow picks them up automatically — no code change required. See [`desktop/scripts/notarize.js`](desktop/scripts/notarize.js) for the hook.
|
|
||||||
|
|
||||||
> Local builds are **always ad-hoc signed**: the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`, so a code-signing certificate already in your macOS keychain is never auto-discovered (an Apple Development cert would otherwise be picked up and fail distribution-type signing). Real signing activates only through the explicit `CSC_LINK` certificate above — that path is unaffected by the flag.
|
|
||||||
|
|
||||||
## Development workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Hot-iterate on the main process (rebuilds tsc on save would be next steps;
|
|
||||||
# v1 ships without watch mode — just re-run desktop:dev after changes):
|
|
||||||
npm run desktop:dev
|
|
||||||
|
|
||||||
# Smoke test (also runs in CI on macOS):
|
|
||||||
npm run desktop:test
|
|
||||||
|
|
||||||
# macOS — single-architecture DMG — fast (~1 min):
|
|
||||||
npm run desktop:dmg:arm64 # or desktop:dmg:x64 for Intel
|
|
||||||
|
|
||||||
# macOS — both per-arch DMGs — slower (builds + signs each architecture):
|
|
||||||
npm run desktop:dmg
|
|
||||||
|
|
||||||
# macOS — one merged universal DMG (arm64 + x86_64 in a single file) — optional, slowest:
|
|
||||||
npm run desktop:dmg:universal
|
|
||||||
|
|
||||||
# Windows — NSIS installer / no-install portable (run ON Windows):
|
|
||||||
npm run desktop:win # NSIS installer .exe
|
|
||||||
npm run desktop:win:portable # no-install portable .exe
|
|
||||||
```
|
|
||||||
|
|
||||||
> electron-builder packages for the **host OS** — build DMGs on macOS and the
|
|
||||||
> Windows `.exe`s on Windows. The Windows icon regenerates from `icon.png` with
|
|
||||||
> `npm run build:win-icon` (PowerShell + .NET); the macOS icns + tray PNGs come
|
|
||||||
> from `npm run build:icons`. On Windows, `better-sqlite3` is fetched as a
|
|
||||||
> prebuilt Electron binary by `npm run desktop:install` (its postinstall runs
|
|
||||||
> `electron-builder install-app-deps`), so no Visual Studio C++ toolchain is
|
|
||||||
> needed in the common case. If that fetch/rebuild *does* fail (no C++ toolchain,
|
|
||||||
> or a Node version with no prebuilt binary), `npm run desktop:install` — and any
|
|
||||||
> `desktop:*` build, gated by `prebuild.js` — prints the exact per-OS fix plus a
|
|
||||||
> no-toolchain alternative and **fails loudly** rather than crashing at runtime:
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> cd desktop
|
|
||||||
> npm install --ignore-scripts
|
|
||||||
> node node_modules/electron/install.js
|
|
||||||
> npx electron-builder install-app-deps
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> A Node LTS (20/22) ships prebuilt `better-sqlite3` binaries and avoids the
|
|
||||||
> compile entirely.
|
|
||||||
|
|
||||||
> After `npm run clean` in `desktop/`, you must `npm run build` again before
|
|
||||||
> packaging — `clean` removes `out/`, and `electron-builder` only packages, it
|
|
||||||
> does not compile. The `desktop:dmg*` scripts chain the build for you; a bare
|
|
||||||
> `electron-builder` call does not, and fails with
|
|
||||||
> _"entry file out/main.js does not exist"_.
|
|
||||||
|
|
||||||
The smoke test does not exercise the BrowserWindow (no display on headless CI). It spawns Electron, waits for the embedded server to answer `/api/health`, then shuts down. Anything that depends on the renderer is part of the manual QA checklist on the PR.
|
|
||||||
|
|
||||||
## Known caveats
|
|
||||||
|
|
||||||
- **Bundle size** ≈ 80 MB DMG, ≈ 250 MB on disk. The standard Electron tax. The Windows installer is comparable. Tauri would cut this dramatically but at the cost of a sidecar-process model and a Rust toolchain dependency — fair to revisit in a follow-up PR if bundle size becomes a real complaint.
|
|
||||||
- **Native modules**: `better-sqlite3` is rebuilt against Electron's Node version automatically via `electron-builder install-app-deps` in the desktop workspace's `postinstall`. On Windows it is fetched as a **prebuilt Electron binary**, so no Visual Studio C++ toolchain is needed in the common case. If that build *does* fail (or the binary is missing afterward), `npm run desktop:install` — and any `desktop:*` build — prints the exact per-OS fix (Windows: Visual Studio Build Tools with the "Desktop development with C++" workload; macOS: `xcode-select --install`; Linux: build-essential + python3) plus a no-toolchain alternative (`npm install --ignore-scripts` → `node node_modules/electron/install.js` → `npx electron-builder install-app-deps`), and exits non-zero — failing loudly at install/build time rather than crashing at runtime. Even so, if the module is unavailable the server falls back to `node:sqlite` (per #37), so the app still boots.
|
|
||||||
- **Per-architecture DMGs**: `npm run desktop:dmg` builds **both** macOS DMGs (one `arm64`, one `x64`) — the release build, and slower because it packages each architecture separately. It does **not** produce a merged universal binary; the release ships the two per-arch DMGs. `npm run desktop:dmg:arm64` and `npm run desktop:dmg:x64` build a single architecture instead — much faster, and roughly half the disk. If you specifically want a **single merged universal binary** (both slices in one `.dmg`, `lipo`-fat), `npm run desktop:dmg:universal` produces one via `@electron/universal` — the slowest option, and not what the release ships, but handy for hand-distributing one file that runs on any Mac.
|
|
||||||
- **Auto-update**: not wired on either platform. The current update path is *re-download the latest installer* (DMG on macOS, `.exe` on Windows). `electron-updater` + GitHub Releases is the natural follow-up.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| "Apple could not verify…" on first launch (macOS) | Unnotarized DMG | `xattr -cr ~/Downloads/ClaudeCodeMonitor-*.dmg` |
|
|
||||||
| "Windows protected your PC" on first launch (Windows) | The `.exe` is unsigned by default (SmartScreen) | Click **More info → Run anyway**. To remove the prompt for everyone, the maintainer can enable Authenticode signing via `CSC_LINK` + `CSC_KEY_PASSWORD` |
|
|
||||||
| macOS prompts to install Rosetta when opening the app | You installed the **x64** build on an Apple Silicon Mac | Check your arch with `uname -m` (`arm64` → Apple Silicon, build with `desktop:dmg:arm64`). The arch-specific `desktop:dmg:arm64` / `desktop:dmg:x64` builds each wipe `release/` and emit a single DMG whose mounted-volume title states the architecture — e.g. *Claude Code Monitor (Apple Silicon)* — so there is no ambiguous window to drag from. (`desktop:dmg` emits both per-arch DMGs at once, for release.) If stale DMGs from an older build linger, clear them with `rm -rf desktop/release` and rebuild |
|
|
||||||
| Window shows but content is blank (macOS) | Server didn't boot — check `~/Library/Logs/Claude Code Monitor/desktop.log` | Restart from tray → *Restart Server* |
|
|
||||||
| Window shows but content is blank (Windows) | Server didn't boot — check `%APPDATA%\Claude Code Monitor\logs\desktop.log` | Restart from tray → *Restart Server* |
|
|
||||||
| Tray icon missing (macOS) | The OS hides tray icons when the menu bar is full | Move other menu-bar items aside, or look in the overflow chevron |
|
|
||||||
| Tray icon missing (Windows) | Windows tucked it into the notification-area overflow | Click the **^** overflow chevron in the taskbar; drag the icon out to keep it pinned |
|
|
||||||
| App didn't auto-start at login (macOS) | Login Items entry got revoked by macOS | Toggle *Open at Login* off and on again from the tray menu |
|
|
||||||
| App didn't auto-start at login (Windows) | The `HKCU\…\Run` startup entry is missing or was disabled | Toggle *Open at Login* off and on again from the tray menu, then confirm the entry under *Task Manager → Startup* is **Enabled** |
|
|
||||||
| `npm run desktop:win` / `:win:portable` fails or produces nothing | electron-builder packages for the host OS — you ran it on macOS/Linux | Build the Windows `.exe` **on Windows** (and DMGs on macOS) |
|
|
||||||
| Desktop build/install fails on `better-sqlite3` / native binary missing | No C++ toolchain, or no prebuilt for your Node version | Run `npm run desktop:install` and follow the printed help, or use the no-toolchain alternative (`npm install --ignore-scripts` → `node node_modules/electron/install.js` → `npx electron-builder install-app-deps`); or use Node LTS 20/22 |
|
|
||||||
| Port 4820 already in use, app refuses to start | Something other than the dashboard is on 4820 and it doesn't answer `/api/health` | The app will pick a fallback (4821–4829, then a random high port) — check the tray menu's port indicator |
|
|
||||||
| Dashboard stays empty — 0 sessions, 0 agents, no real-time updates | The app bound a fallback port (4820 was taken), and the Claude Code hooks were posting events to the wrong port | Fixed — the server publishes its live port to `~/.claude/.agent-dashboard.json` and the hook handler reads it. After upgrading from a pre-fix build, **start a new Claude Code session** so the updated hooks take effect |
|
|
||||||
| `desktop:dmg` seems slow | Not stuck — it packages two architectures back-to-back (`arch=x64` then `arch=arm64`) | Wait it out, or build a single architecture with `desktop:dmg:arm64` / `desktop:dmg:x64` |
|
|
||||||
| Build fails: `entry file out/main.js does not exist` | `electron-builder` was run without compiling TypeScript first | Build via `npm run desktop:dmg*` (chains the build); don't invoke `electron-builder` bare |
|
|
||||||
| Signing fails with `Application … could not be found` | A code-signing certificate in your keychain was auto-discovered | Fixed — the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`; build via `npm run desktop:dmg*` |
|
|
||||||
| "Run Claude" reports the `claude` CLI isn't on your PATH | A Finder/Dock-launched app inherits launchd's minimal PATH, not your shell PATH | Fixed — the app recovers your login-shell PATH at startup. If it persists, ensure `claude` is a real executable (not a shell alias/function) and on your shell PATH |
|
|
||||||
| Imported history / sessions vanished after updating the app | Older builds stored the database inside the (replaceable) app bundle | Fixed — data now lives in `~/Library/Application Support/Claude Code Monitor/data/` and survives reinstalls. After upgrading from a pre-fix build, re-run **Import History → Rescan** once |
|
|
||||||
| Signing fails: `Application … could not be found` after retries | A keychain code-signing certificate was auto-discovered | Fixed — the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`; build via `npm run desktop:dmg*` |
|
|
||||||
@@ -35,6 +35,10 @@ RUN npm run build
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Runs Claude Code sessions inside a named tmux session per lane so both the
|
||||||
|
# dashboard (via node-pty attach) and a real terminal can share one live pane.
|
||||||
|
RUN apk add --no-cache tmux
|
||||||
|
|
||||||
COPY --from=server-deps /app/node_modules ./node_modules/
|
COPY --from=server-deps /app/node_modules ./node_modules/
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
|
|||||||
+22
-140
@@ -1,6 +1,27 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
A step-by-step guide to get the Claude Code Agent Monitor up and running on your machine, with optional sections for importing history, running in a container, and using the native desktop app (macOS & Windows).
|
A step-by-step guide to get the Claude Code Agent Monitor up and running on your machine, with optional sections for importing history and running in a container.
|
||||||
|
|
||||||
|
## Fastest path — install it as a Claude Code plugin
|
||||||
|
|
||||||
|
If all you want is a working dashboard, skip every step below. From Claude Code:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||||
|
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
The next session start installs the hooks, boots the server, puts the `ccam` CLI
|
||||||
|
on PATH and connects the MCP tools — no clone, no `npm run setup`, no
|
||||||
|
`npm run install-hooks`, no manual `npm start`. Run `/ccam-doctor` to check the
|
||||||
|
result and `/ccam-open` to build the UI and get the URL.
|
||||||
|
|
||||||
|
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||||
|
the server stores data through `node:sqlite`). Full behavior, including the
|
||||||
|
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
|
Follow the steps below instead when you want a checkout — to develop the
|
||||||
|
dashboard, run it from source, or stay on Node 20.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -168,122 +189,6 @@ Open **http://localhost:4820** in your browser.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Desktop App (macOS & Windows) (optional)
|
|
||||||
|
|
||||||
If you'd rather not keep a terminal window open, the project also ships an Electron 35-based **native desktop app** (the `desktop/` workspace), available for both **macOS** and **Windows**. It embeds the Express server in-process, renders the built React client in a `BrowserWindow`, registers a menu-bar / notification-area (tray) icon, and offers a one-click "Open at Login" toggle. Everything you'd see in the browser at `localhost:4820` lives inside a single app you install once — distributed as a macOS `.app` (in a `.dmg`) and a Windows `.exe` (an NSIS installer plus a no-install portable build).
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
| For… | You need |
|
|
||||||
|---|---|
|
|
||||||
| Downloading a pre-built installer (macOS) | macOS — nothing else |
|
|
||||||
| Downloading a pre-built installer (Windows) | Windows 10/11 (x64) — nothing else |
|
|
||||||
| Building the DMG locally (macOS) | macOS, Node.js 20+ (22+ recommended), npm 9+, and **Xcode command-line tools** (`xcode-select --install`) so the native `better-sqlite3` module can be rebuilt for Electron's ABI |
|
|
||||||
| Building the `.exe` locally (Windows) | Windows, Node.js 20+ (22+ recommended), npm 9+. `better-sqlite3` is fetched as a **prebuilt Electron binary** by `npm run desktop:install`, so no Visual Studio C++ toolchain is needed in the common case. If the build _does_ fail, `npm run desktop:install` prints the exact fix (Visual Studio Build Tools + "Desktop development with C++") plus a no-toolchain alternative and exits non-zero rather than failing silently |
|
|
||||||
|
|
||||||
### Way 1 — Download a pre-built installer
|
|
||||||
|
|
||||||
The fastest path. There are two flavours:
|
|
||||||
|
|
||||||
**1a. From the latest GitHub Release** *(recommended — public, no sign-in)*
|
|
||||||
|
|
||||||
Open [**Releases → latest**](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) and download the asset for your platform. CI publishes a new `vX.Y.Z` release automatically every time the version in `package.json` is bumped on `master`, so this link always points at the current shipping build.
|
|
||||||
|
|
||||||
| Platform | Asset | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| macOS (Apple Silicon) | `ClaudeCodeMonitor-<ver>-arm64.dmg` | drag into `/Applications` |
|
|
||||||
| macOS (Intel) | `ClaudeCodeMonitor-<ver>-x64.dmg` | drag into `/Applications` |
|
|
||||||
| Windows (installer) | `ClaudeCodeMonitor-Setup-<ver>-x64.exe` | per-user install, no admin |
|
|
||||||
| Windows (portable) | `ClaudeCodeMonitor-<ver>-x64-portable.exe` | run without installing |
|
|
||||||
|
|
||||||
**1b. From the per-commit CI artifact** *(useful for testing master before it's tagged — sign-in required, 14-day retention)*
|
|
||||||
|
|
||||||
Every green run of the desktop CI jobs uploads a packaged artifact — `ClaudeCodeMonitor-dmg` from the `🍎 macOS Desktop (DMG)` job and `ClaudeCodeMonitor-win` from the `🪟 Windows Desktop (EXE)` job:
|
|
||||||
|
|
||||||
- **Via the GitHub UI:** open the latest passing run under [Actions](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/actions/workflows/ci.yml?query=branch%3Amaster+is%3Asuccess), scroll to **Artifacts**, and download `ClaudeCodeMonitor-dmg` (macOS) or `ClaudeCodeMonitor-win` (Windows).
|
|
||||||
- **Via the `gh` CLI:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-dmg # macOS
|
|
||||||
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-win # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
Unzip the macOS artifact to get the `.dmg`s, or the Windows artifact to get the NSIS installer + portable `.exe`s.
|
|
||||||
|
|
||||||
Then jump to [Install the app](#install-the-app).
|
|
||||||
|
|
||||||
### Way 2 — Build the installer locally
|
|
||||||
|
|
||||||
From the project root, after `git clone`. electron-builder packages for the **host OS**, so build the macOS DMG on a Mac and the Windows `.exe` on Windows. The common prelude is the same:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run setup # install root + client + vscode-extension deps
|
|
||||||
npm run build # build the React client (the SPA the window loads)
|
|
||||||
npm run desktop:install # install Electron + electron-builder into desktop/
|
|
||||||
|
|
||||||
# macOS (run on macOS):
|
|
||||||
npm run desktop:dmg:arm64 # fast single-arch DMG → desktop/release/
|
|
||||||
|
|
||||||
# Windows (run on Windows):
|
|
||||||
npm run desktop:win # NSIS installer .exe → desktop/release/
|
|
||||||
```
|
|
||||||
|
|
||||||
The artifact lands in `desktop/release/`. Pick the build command that matches your goal:
|
|
||||||
|
|
||||||
| Command | Platform / Architecture | Speed | Use when |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `npm run desktop:dmg` | macOS — both per-arch DMGs (arm64 + x64) | **Slower** | Building the release DMGs for everyone |
|
|
||||||
| `npm run desktop:dmg:arm64` | macOS — Apple Silicon only | Fast (~1 min) | Building for your own Apple Silicon Mac |
|
|
||||||
| `npm run desktop:dmg:x64` | macOS — Intel only | Fast (~1 min) | Building for your own Intel Mac |
|
|
||||||
| `npm run desktop:dmg:universal` | macOS — one merged universal DMG (arm64 + x86_64) | **Slowest** | Hand-distributing a single file that runs on any Mac (not what the release ships) |
|
|
||||||
| `npm run desktop:win` | Windows — NSIS installer `.exe` (x64) | — | Building the per-user installer |
|
|
||||||
| `npm run desktop:win:portable` | Windows — portable `.exe` (x64) | — | Building the no-install portable build |
|
|
||||||
| `npm run desktop:install` | — | — | Install Electron + electron-builder deps; preflights the native `better-sqlite3` build and prints actionable setup help on failure |
|
|
||||||
| `npm run desktop:build` | — | — | TypeScript compile only (`out/`) |
|
|
||||||
| `npm run desktop:dev` | — | — | Build, then launch Electron locally |
|
|
||||||
| `npm run desktop:test` | — | — | Smoke test (spawn Electron, probe `/api/health`) |
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> **DMGs build on macOS; Windows `.exe`s build on Windows** — electron-builder packages for the host OS. On macOS, `npm run desktop:dmg` builds the app **twice** (one tree per architecture) and emits **both** per-arch DMGs (`arm64` + `x64`) — the release build. It does **not** merge them into a single universal binary; the two DMGs are what ship. **When building for your own Mac, use `desktop:dmg:arm64` or `desktop:dmg:x64`** — a single architecture finishes in roughly a minute. CI already builds both DMGs and the Windows `.exe`s for you (see Way 1).
|
|
||||||
|
|
||||||
### Install the app
|
|
||||||
|
|
||||||
**macOS.** Each `desktop:dmg*` build wipes `release/` first. `desktop:dmg:arm64`
|
|
||||||
→ `…-arm64.dmg` and `desktop:dmg:x64` → `…-x64.dmg` each emit a single DMG whose
|
|
||||||
mounted-volume title states the architecture (e.g. *Claude Code Monitor (Apple
|
|
||||||
Silicon)*); `desktop:dmg` emits **both** (`…-arm64.dmg` + `…-x64.dmg`) for
|
|
||||||
release. Install the one matching your Mac: an x64 build on Apple Silicon makes
|
|
||||||
macOS prompt for Rosetta.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
open desktop/release/ClaudeCodeMonitor-*-arm64.dmg # the arch you built
|
|
||||||
```
|
|
||||||
|
|
||||||
1. The DMG mounts — drag **Claude Code Monitor** into your `Applications` folder.
|
|
||||||
2. The DMG is ad-hoc signed, so macOS Gatekeeper shows a warning (*"Apple could not verify…"*) on first launch. Strip the quarantine attribute, then open it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xattr -cr "/Applications/Claude Code Monitor.app"
|
|
||||||
open "/Applications/Claude Code Monitor.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, open → *System Settings → Privacy & Security* and click *Open Anyway*.
|
|
||||||
|
|
||||||
**Windows.**
|
|
||||||
|
|
||||||
1. Run `ClaudeCodeMonitor-Setup-<ver>-x64.exe`. It installs **per-user** (no administrator elevation) and lets you pick the install directory — or run the `*-portable.exe` to launch without installing.
|
|
||||||
2. The installer is **unsigned** by default, so Windows **SmartScreen** may show *"Windows protected your PC"* on first launch — click **More info → Run anyway**.
|
|
||||||
3. Launch from the Start menu / desktop shortcut.
|
|
||||||
|
|
||||||
Once running, the embedded server boots on port `4820` (or adopts an already-healthy server on `4820`, or falls back to `4821`–`4829` / a random high port), the menu-bar / notification-area (tray) icon appears, and the dashboard window opens. **Hooks are installed automatically on first boot** — an install-only user does not need `npm run install-hooks`; just start a new Claude Code session. Closing the window hides it but keeps the server running; **Quit** from the tray exits.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The packaged app stores its SQLite database and VAPID keys in a per-user app-data directory **outside** the app bundle / install dir — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows. Your imported history and events therefore **survive app reinstalls and updates** (the Windows NSIS uninstaller keeps this data by default). (Older macOS builds kept the database inside the bundle, which is read-only once installed and code-signed — that broke History Import; it is now fixed. If you are upgrading from a pre-fix build, there is a one-time data gap: re-run **Settings → Import History → Rescan** once.)
|
|
||||||
|
|
||||||
Full user guide: [`DESKTOP.md`](DESKTOP.md). Contributor / architecture reference: [`desktop/README.md`](desktop/README.md). Desktop-specific setup details (logs, auto-start, port adoption) are in [SETUP.md → Desktop App Setup](./SETUP.md#desktop-app-setup).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Optional: Local MCP server
|
## Optional: Local MCP server
|
||||||
|
|
||||||
If you want AI agents to call dashboard functionality through MCP tools, run the local MCP server in `mcp/`:
|
If you want AI agents to call dashboard functionality through MCP tools, run the local MCP server in `mcp/`:
|
||||||
@@ -448,17 +353,6 @@ If you see an error box at startup saying *"SQLite backend not available"*, eith
|
|||||||
|
|
||||||
Then run: `npm rebuild better-sqlite3`
|
Then run: `npm rebuild better-sqlite3`
|
||||||
|
|
||||||
### Desktop build or install fails on the native dependency
|
|
||||||
|
|
||||||
Unlike the root server (which falls back to `node:sqlite`), the desktop app **requires** `better-sqlite3` built for Electron's ABI. If that build can't happen, `npm run desktop:install` (and the desktop `prebuild` gate that runs before every `desktop:*` build) now stops with copy-pasteable setup help instead of a raw node-gyp trace or a runtime crash: it lists the per-OS C++ toolchain prerequisites (Windows: Visual Studio Build Tools + "Desktop development with C++"; macOS: `xcode-select --install`; Linux: build-essential + python3), notes that Node LTS 20/22 ship prebuilt binaries, and offers a no-toolchain alternative:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd desktop
|
|
||||||
npm install --ignore-scripts
|
|
||||||
node node_modules/electron/install.js
|
|
||||||
npx electron-builder install-app-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
### `npm run dev` fails immediately
|
### `npm run dev` fails immediately
|
||||||
|
|
||||||
Ensure both server and client dependencies are installed:
|
Ensure both server and client dependencies are installed:
|
||||||
@@ -482,18 +376,6 @@ The Vite dev server and Express server run on different ports. Make sure both ar
|
|||||||
|
|
||||||
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
||||||
|
|
||||||
### Desktop App (macOS & Windows) issues
|
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| *"Apple could not verify…"* on first launch (macOS) | The DMG is ad-hoc signed (no paid Apple Developer ID) | `xattr -cr "/Applications/Claude Code Monitor.app"`, then open it — or use *System Settings → Privacy & Security → Open Anyway* |
|
|
||||||
| *"Windows protected your PC"* on first launch (Windows) | The `.exe` is unsigned by default, so SmartScreen prompts | Click **More info → Run anyway** |
|
|
||||||
| `npm run desktop:dmg` seems slow (macOS) | Not hung — it packages two architectures back-to-back (`arch=x64` then `arch=arm64`) | Wait it out, or use `npm run desktop:dmg:arm64` / `npm run desktop:dmg:x64` for a fast single-arch build |
|
|
||||||
| `entry file out/main.js does not exist` | `npm run clean` (in `desktop/`) deleted `out/`; `electron-builder` only packages, it does not compile | Re-run `npm run desktop:build` (or just use a `desktop:dmg*` / `desktop:win*` script, which chains the build) |
|
|
||||||
| Desktop window opens but is blank | The embedded server failed `/api/health` within 30 s | Check the desktop log (`~/Library/Logs/Claude Code Monitor/desktop.log` on macOS, `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows), then tray → *Restart Server* |
|
|
||||||
| "Run Claude" says `claude` is not on your PATH | A Finder/Dock-launched macOS app only inherits launchd's minimal PATH, not your login-shell PATH (on Windows the process already inherits the user PATH) | The app recovers your login-shell PATH at startup so it can find and spawn the `claude` CLI. If it still fails, make sure `claude` is a real executable on your shell PATH — not a shell alias or function |
|
|
||||||
| Imported history vanished after updating the app | Older builds stored the database inside the (replaceable) `.app` bundle | Fixed — data now lives in the per-user app-data dir (`~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows) and survives reinstalls/updates. After upgrading from a pre-fix build, re-run **Settings → Import History → Rescan** once |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ tool call to an Express + SQLite server, a React UI updates over WebSocket, and
|
|||||||
|
|
||||||
Internal build — all rights reserved.
|
Internal build — all rights reserved.
|
||||||
|
|
||||||
|
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||||
@@ -15,8 +17,10 @@ Internal build — all rights reserved.
|
|||||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||||
that never calls `ccam stage` still shows progress — rendered dashed amber and
|
that never calls `ccam stage` still shows progress — rendered dashed amber and
|
||||||
never as done, because an inference is not evidence.
|
never as done, because an inference is not evidence.
|
||||||
- **Run Claude from the browser.** Spawn a session in a lane's directory, stream
|
- **Run Claude from the browser.** A real terminal (tmux + a real PTY,
|
||||||
its output, send follow-ups, resume any past session.
|
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
|
||||||
|
terminal too via `ccam lanes shell`.
|
||||||
- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
|
- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -26,7 +30,25 @@ are verified on — node 25 currently breaks 6 server tests through a
|
|||||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||||
change.
|
change.
|
||||||
|
|
||||||
## Install and run
|
## Install as a Claude Code plugin
|
||||||
|
|
||||||
|
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||||
|
`npm run setup`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||||
|
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
The first session start installs the hooks, boots the server, puts `ccam` on
|
||||||
|
PATH and connects the MCP tools; it runs detached, so the session never waits on
|
||||||
|
it. `/ccam-doctor` reports the state, `/ccam-open` builds the UI and prints the
|
||||||
|
URL, `/ccam-update` refreshes after a plugin update. This path needs Node
|
||||||
|
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
||||||
|
Details, including what to delete on uninstall:
|
||||||
|
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
|
## Install from a checkout
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run setup # root, client and vscode-extension dependencies
|
npm run setup # root, client and vscode-extension dependencies
|
||||||
@@ -42,8 +64,11 @@ Development, with hot reload:
|
|||||||
npm run dev # server on :4820, Vite client on :5173
|
npm run dev # server on :4820, Vite client on :5173
|
||||||
```
|
```
|
||||||
|
|
||||||
`DASHBOARD_PORT` overrides the port. `postinstall` writes the Claude Code hook
|
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
||||||
entries that feed the dashboard.
|
built UI is served from (defaults to `client/dist`; the plugin install points it
|
||||||
|
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
||||||
|
that feed the dashboard — do not run it when the `ccam` plugin is installed, or
|
||||||
|
every event is counted twice.
|
||||||
|
|
||||||
## The CLI
|
## The CLI
|
||||||
|
|
||||||
@@ -54,6 +79,7 @@ ccam status # is the dashboard up
|
|||||||
ccam start # start it in the background and wait for healthy
|
ccam start # start it in the background and wait for healthy
|
||||||
ccam sessions # recent sessions
|
ccam sessions # recent sessions
|
||||||
ccam lanes # lanes with stage and progress
|
ccam lanes # lanes with stage and progress
|
||||||
|
ccam lanes pipeline # this lane's pipeline template, or switch it
|
||||||
ccam stage <name> # declare the current lane's stage
|
ccam stage <name> # declare the current lane's stage
|
||||||
ccam tail # live event feed
|
ccam tail # live event feed
|
||||||
```
|
```
|
||||||
@@ -80,8 +106,31 @@ events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
|
|||||||
move backwards between work sessions. Detection never writes the declared stage,
|
move backwards between work sessions. Detection never writes the declared stage,
|
||||||
and an inferred node never renders as done.
|
and an inferred node never renders as done.
|
||||||
|
|
||||||
|
A lane's pipeline template can be switched after creation — `ccam lanes
|
||||||
|
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
|
||||||
|
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
|
||||||
|
repository declares a profile at `<repo>/.ccam/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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccam lanes up # boot the stack of the lane owning this directory
|
||||||
|
ccam lanes runtime # slot, ports, service health
|
||||||
|
ccam lanes logs api # tail a service log
|
||||||
|
ccam lanes 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
|
||||||
|
same user and share the network.
|
||||||
|
|
||||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
||||||
preflight contract, the Workspace page, and `GET /api/lanes/:id/git`.
|
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
|
||||||
|
runtime/profile contract.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -100,8 +149,7 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
|||||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||||
| `bin/ccam.js` | CLI |
|
| `bin/ccam.js` | CLI |
|
||||||
| `mcp/` | MCP server exposing read-only dashboard tools |
|
| `mcp/` | MCP server exposing read-only dashboard tools |
|
||||||
| `desktop/` | Electron wrapper that embeds the server |
|
| `docs/` | Architecture, API, lanes, database |
|
||||||
| `docs/` | Architecture, API, lanes, database, deployment |
|
|
||||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
@@ -110,5 +158,5 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
|||||||
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
|
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
|
||||||
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
||||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
||||||
- [`INSTALL.md`](INSTALL.md) · [`DEPLOYMENT.md`](DEPLOYMENT.md) · [`DESKTOP.md`](DESKTOP.md)
|
- [`INSTALL.md`](INSTALL.md)
|
||||||
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
# Claude Code Monitor
|
||||||
|
|
||||||
|
Bản build nội bộ SmartGift. Dashboard local-first cho Claude Code: hooks POST mỗi
|
||||||
|
tool call lên server Express + SQLite, React UI cập nhật qua WebSocket, và
|
||||||
|
**lane** (làn đường) theo dõi công việc song song của agent qua một pipeline.
|
||||||
|
|
||||||
|
Internal build — all rights reserved.
|
||||||
|
|
||||||
|
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
|
||||||
|
|
||||||
|
## Làm được gì
|
||||||
|
|
||||||
|
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
|
||||||
|
được: tool call, token usage, cost, cây subagent, transcript.
|
||||||
|
- **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 `ccam 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 từ trình duyệt.** Spawn session trong thư mục của lane, stream
|
||||||
|
output, gửi follow-up, resume session cũ bất kỳ.
|
||||||
|
- **Analytics, alert, Kanban và workflow view**, cộng thêm MCP server và CLI.
|
||||||
|
|
||||||
|
## Yêu cầu
|
||||||
|
|
||||||
|
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
|
||||||
|
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
|
||||||
|
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
`npm run setup`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||||
|
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
Lần session-start đầu tiên sẽ cài hooks, boot server, đưa `ccam` vào PATH và
|
||||||
|
kết nối MCP tools; chạy detached nên session không phải chờ. `/ccam-doctor`
|
||||||
|
báo trạng thái, `/ccam-open` build UI và in URL, `/ccam-update` refresh sau
|
||||||
|
khi plugin update. Đường này cần Node **>= 22.5** (không dùng
|
||||||
|
`better-sqlite3` native, server dùng `node:sqlite`). Chi tiết, kể cả những gì
|
||||||
|
cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
|
## Cài từ checkout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup # cài dependency cho root, client và vscode-extension
|
||||||
|
npm run build # build client vào client/dist
|
||||||
|
npm start # serve client đã build + API trên :4820
|
||||||
|
```
|
||||||
|
|
||||||
|
Mở <http://localhost:4820>.
|
||||||
|
|
||||||
|
Development, có hot reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # server trên :4820, Vite client trên :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
`DASHBOARD_PORT` override port, `DASHBOARD_CLIENT_DIST` override nơi UI đã
|
||||||
|
build được serve (mặc định `client/dist`; bản cài qua plugin trỏ vào runtime
|
||||||
|
directory riêng của nó). `postinstall` ghi các hook entry Claude Code để nạp
|
||||||
|
dữ liệu cho dashboard — đừng chạy nó khi plugin `ccam` đã cài, không thì mỗi
|
||||||
|
event bị đếm hai lần.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
`ccam` được link sẵn bởi `npm run setup`; không thì gọi `node bin/ccam.js`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccam status # dashboard có đang chạy không
|
||||||
|
ccam start # start ngầm (background) và chờ tới khi healthy
|
||||||
|
ccam sessions # session gần đây
|
||||||
|
ccam lanes # danh sách lane kèm stage và progress
|
||||||
|
ccam lanes pipeline # pipeline template của lane này, hoặc đổi nó
|
||||||
|
ccam stage <name> # khai báo stage hiện tại của lane
|
||||||
|
ccam tail # xem live event feed
|
||||||
|
```
|
||||||
|
|
||||||
|
`ccam --help` liệt kê phần còn lại.
|
||||||
|
|
||||||
|
## Lane
|
||||||
|
|
||||||
|
Lane 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.
|
||||||
|
- **managed** (tự quản lý) — git worktree do dashboard tạo dưới `LANES_ROOT`.
|
||||||
|
Dashboard sở hữu toàn bộ vòng đời và có thể reset/xóa nó, phía sau một guard
|
||||||
|
ba lớp kiểm tra khi hủy (destroy guard) và một bước preflight đếm số mà bên
|
||||||
|
gọi phải echo lại.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccam lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||||
|
ccam lanes add --repo /path/to/repo --slug my-feature # managed worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage khai báo (declared) đến từ `ccam 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ó
|
||||||
|
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 — `ccam lanes
|
||||||
|
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
|
||||||
|
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>/.ccam/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ừ đó:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccam lanes up # boot stack của lane sở hữu thư mục hiện tại
|
||||||
|
ccam lanes runtime # slot, port, tình trạng service
|
||||||
|
ccam lanes logs api # tail log của một service
|
||||||
|
ccam lanes 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.
|
||||||
|
|
||||||
|
[`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ộ
|
||||||
|
hợp đồng runtime/profile.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:server # node:test
|
||||||
|
npm run test:client # Vitest
|
||||||
|
```
|
||||||
|
|
||||||
|
Cả hai phải xanh (pass) trước khi commit; pre-commit hook chạy chúng cộng
|
||||||
|
thêm Prettier.
|
||||||
|
|
||||||
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
|
| Đường dẫn | Là gì |
|
||||||
|
|---|---|
|
||||||
|
| `server/` | Express API, schema SQLite, hook ingest, thư viện lane và worktree |
|
||||||
|
| `client/` | Dashboard React 18 + Vite + Tailwind |
|
||||||
|
| `bin/ccam.js` | CLI |
|
||||||
|
| `mcp/` | MCP server expose các tool đọc dữ liệu dashboard (read-only) |
|
||||||
|
| `docs/` | Architecture, API, lanes, 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/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)
|
||||||
|
- [`CLAUDE.md`](CLAUDE.md) — quy tắc agent làm việc trong repo này phải tuân theo
|
||||||
@@ -98,6 +98,7 @@ Container-specific behavior:
|
|||||||
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
||||||
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
||||||
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
||||||
|
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `ccam` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
|
||||||
| `CCAM_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
| `CCAM_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
||||||
| `CCAM_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
| `CCAM_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||||
| `CCAM_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
| `CCAM_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||||
@@ -117,7 +118,7 @@ DASHBOARD_PORT=9000 npm run dev
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agent-dashboard.json` so the Claude Code hook handler discovers it without any env var.
|
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agent-dashboard.json` so the Claude Code hook handler discovers it without any env var.
|
||||||
>
|
>
|
||||||
> Multiple dashboards can run side by side — for example `npm run dev` and the desktop app (macOS or Windows) at the same time. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
||||||
>
|
>
|
||||||
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
||||||
>
|
>
|
||||||
@@ -216,59 +217,6 @@ The dashboard, landing page, and wiki each ship as independent Progressive Web A
|
|||||||
|
|
||||||
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
||||||
|
|
||||||
### Desktop App Setup
|
|
||||||
|
|
||||||
The `desktop/` workspace ships the dashboard as a **native desktop app** for both **macOS** (a `.app` distributed as a `.dmg`) and **Windows** (an `.exe` — an NSIS installer plus a no-install portable build), built with Electron 35. It is an Electron shell that **embeds the existing Express server in-process** — it does not reimplement anything. For installation (download a pre-built installer from the [latest GitHub Release](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) or the per-commit `ClaudeCodeMonitor-dmg` / `ClaudeCodeMonitor-win` CI artifact, or build one locally — then on macOS mount, drag, Gatekeeper bypass; on Windows run the installer / portable, SmartScreen bypass), see [INSTALL.md → Desktop App (macOS & Windows)](./INSTALL.md#desktop-app-macos--windows-optional). The full user guide is [`DESKTOP.md`](./DESKTOP.md); the contributor / architecture reference is [`desktop/README.md`](./desktop/README.md).
|
|
||||||
|
|
||||||
This section covers the parts of running the desktop app that matter for setup.
|
|
||||||
|
|
||||||
**Building and running.** All commands run from the repo root. electron-builder packages for the **host OS** — build the macOS DMG on a Mac (`desktop:dmg*`) and the Windows `.exe` on Windows (`desktop:win*`):
|
|
||||||
|
|
||||||
| Script | Command | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `desktop:install` | `npm run desktop:install` | Install Electron + electron-builder into `desktop/`; fetches `better-sqlite3` as a prebuilt Electron binary for Electron's ABI (no Visual Studio C++ toolchain needed in the common case; on macOS, Xcode CLI tools cover any fallback build). Preflights the native `better-sqlite3` build; on failure prints actionable per-OS setup help plus a no-toolchain alternative and exits non-zero (also enforced by the desktop `prebuild` gate) |
|
|
||||||
| `desktop:build` | `npm run desktop:build` | Prebuild guard + `tsc` → `desktop/out/` |
|
|
||||||
| `desktop:dev` | `npm run desktop:dev` | Build, then launch Electron against `out/main.js` |
|
|
||||||
| `desktop:test` | `npm run desktop:test` | Build, then run the smoke test (spawn Electron, probe `/api/health`) |
|
|
||||||
| `desktop:dmg` | `npm run desktop:dmg` | **macOS** — **both** per-arch DMGs (arm64 + x64) → `desktop/release/`. Correct for release. **Slower** (packages each arch). |
|
|
||||||
| `desktop:dmg:arm64` | `npm run desktop:dmg:arm64` | **macOS** — Apple-Silicon-only DMG → `desktop/release/`. **Fast (~1 min).** |
|
|
||||||
| `desktop:dmg:x64` | `npm run desktop:dmg:x64` | **macOS** — Intel-only DMG → `desktop/release/`. **Fast (~1 min).** |
|
|
||||||
| `desktop:win` | `npm run desktop:win` | **Windows** — NSIS installer `.exe` (x64) → `desktop/release/`. |
|
|
||||||
| `desktop:win:portable` | `npm run desktop:win:portable` | **Windows** — no-install portable `.exe` (x64) → `desktop/release/`. |
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Every `desktop:dmg*` / `desktop:win*` script chains `npm run build` first. Running `electron-builder` bare skips the TypeScript compile and fails with `entry file out/main.js does not exist`. `npm run clean` inside `desktop/` deletes `out/` and `release/` — after a clean you must `npm run desktop:build` again before packaging.
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> On macOS, building a DMG rebuilds the native `better-sqlite3` module for the **target** architecture, which can leave it built for the wrong CPU arch for your local machine. The desktop `prebuild` step auto-heals this — it rebuilds `better-sqlite3` for the local machine on the next `desktop:build` — so `npm run desktop:dev` and `npm run desktop:test` keep working after a cross-arch DMG build with no manual `npm run desktop:install` needed.
|
|
||||||
|
|
||||||
**Hooks are auto-installed by the app.** On its first **owned-server** boot the desktop app writes the Claude Code hook configuration to `~/.claude/settings.json` itself, then starts the background services (update scheduler, `cc-watcher` config watcher, orphaned-run reconciliation) — the same `startBackgroundServices()` that `node server/index.js` runs. An install-only user (macOS or Windows) therefore never needs `npm run install-hooks` from a checkout: just **start a new Claude Code session** after the app is running. (If the app *adopts* an existing server instead of starting its own, that server already did its own hook bootstrap — see port adoption below.)
|
|
||||||
|
|
||||||
**Port-adoption behavior.** When the desktop app launches, its embedded server picks a port:
|
|
||||||
|
|
||||||
1. It prefers **`4820`**.
|
|
||||||
2. If a healthy dashboard server already answers `GET /api/health` on `4820` (for example you ran `npm start` in a terminal), the app **adopts that server** instead of double-binding — no SQLite contention. An adopted server is *not* owned by the app, so quitting the app leaves it running.
|
|
||||||
3. Otherwise it falls back to `4821`–`4829`, then to a random high port (`49152`–`49500`).
|
|
||||||
|
|
||||||
The chosen port is shown in the tray menu. The embedded server also honors the dashboard env vars in [Environment variables](#environment-variables) (`DASHBOARD_PORT` is set automatically by the desktop host).
|
|
||||||
|
|
||||||
**Data directory.** The packaged app stores its SQLite database and VAPID keys in a per-user app-data directory — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows — **outside** the app bundle / install dir. The desktop host sets `DASHBOARD_DATA_DIR` to this per-user location automatically. Keeping writable state out of the bundle means a packaged, code-signed (and therefore read-only) `.app` never tries to write inside itself, and your imported history and events **survive app reinstalls and updates** (the Windows NSIS uninstaller keeps this data by default). (Older macOS builds kept the database inside the bundle, which broke History Import; after upgrading from a pre-fix build, re-run **Settings → Import History → Rescan** once to close the one-time data gap.)
|
|
||||||
|
|
||||||
**`claude` CLI resolution.** A Finder/Dock-launched macOS app inherits only launchd's minimal `PATH`, not your login-shell `PATH`. So the app can find and spawn the `claude` CLI for the "Run Claude" feature, the desktop host recovers your login-shell `PATH` at startup. (On Windows the process already inherits the user `PATH`, so no recovery is needed.) If "Run Claude" still reports that `claude` is not on `PATH`, make sure `claude` is a real executable on your shell `PATH` — a shell alias or function cannot be spawned.
|
|
||||||
|
|
||||||
**Auto-start at login.** Toggle *Open at Login* from the tray menu or the application menu. On macOS it registers via the first-party `SMAppService` API (Electron's `app.setLoginItemSettings`), so the entry appears under → *System Settings → General → Login Items*. On Windows it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry, visible in *Task Manager → Startup*. When the app is launched at login, it starts **tray-only** — the dashboard window stays hidden until you click the tray icon.
|
|
||||||
|
|
||||||
**Logs.** The Electron main process has no terminal when launched from Finder / the Start menu, so it writes to a per-user log file:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/Library/Logs/Claude Code Monitor/desktop.log # macOS
|
|
||||||
%APPDATA%\Claude Code Monitor\logs\desktop.log # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
Open it from the tray menu → **Show Logs**. Set `CCAM_DESKTOP_VERBOSE=1` to also mirror `info`/`warn` lines to stdout when running via `npm run desktop:dev`.
|
|
||||||
|
|
||||||
**Lifecycle reminder.** Closing the dashboard window only **hides** it — the server and tray keep running. **Quit** (⌘Q or tray → *Quit*) shuts the embedded server down gracefully and exits. Double-launching just focuses the existing window (single-instance lock); it never starts a second server.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
@@ -606,48 +554,3 @@ If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and
|
|||||||
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
|
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
|
||||||
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
|
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
|
||||||
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
|
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### macOS desktop app — `npm run desktop:dmg` is slow
|
|
||||||
|
|
||||||
This is expected. `desktop:dmg` compiles, packages, and ad-hoc-signs the app **twice** — once for `arm64`, once for `x64` — and emits **both** per-arch DMGs (`…-arm64.dmg` + `…-x64.dmg`). It does not merge them into a single universal binary; the two per-arch DMGs are what ship. Packaging two architectures back-to-back is what takes the time; it is not hung.
|
|
||||||
|
|
||||||
For a build that targets your own Mac, use a single-arch command instead — it builds one architecture and finishes in roughly a minute:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run desktop:dmg:arm64 # Apple Silicon
|
|
||||||
npm run desktop:dmg:x64 # Intel
|
|
||||||
```
|
|
||||||
|
|
||||||
CI already produces both DMGs — pulled either from the [latest GitHub Release](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) (CI auto-publishes a `vX.Y.Z` when `package.json` is bumped on `master`) or from the per-commit `ClaudeCodeMonitor-dmg` workflow artifact — so you rarely need to build them locally.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Desktop app — `entry file out/main.js does not exist`
|
|
||||||
|
|
||||||
You ran `electron-builder` without a TypeScript compile. `npm run clean` (in `desktop/`) deletes `out/`, and `electron-builder` only packages — it does not compile. Re-run `npm run desktop:build` first, or use a `desktop:dmg*` / `desktop:win*` script (each one chains `npm run build` for you). Never invoke `electron-builder` bare.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### macOS desktop app — Gatekeeper blocks the app on first launch
|
|
||||||
|
|
||||||
The DMG is **ad-hoc signed** by default (the project ships no paid Apple Developer ID), so macOS shows *"Apple could not verify…"* the first time you open the app. Strip the quarantine attribute:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xattr -cr "/Applications/Claude Code Monitor.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
Or open → *System Settings → Privacy & Security* and click *Open Anyway*. Real Developer ID signing and notarization are opt-in via the `CSC_LINK` / `CSC_KEY_PASSWORD` and `APPLE_ID` / `APPLE_TEAM_ID` / `APPLE_APP_SPECIFIC_PASSWORD` repository secrets — see [`DESKTOP.md`](./DESKTOP.md#notarization-for-the-maintainer).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Windows desktop app — SmartScreen blocks the app on first launch
|
|
||||||
|
|
||||||
The Windows `.exe` (NSIS installer and portable build) is **unsigned** by default, so Windows SmartScreen shows *"Windows protected your PC"* the first time you run it. Click **More info → Run anyway**. Authenticode signing is opt-in via the `CSC_LINK` / `CSC_KEY_PASSWORD` repository secrets — CI picks them up automatically when provided.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Desktop app — no sessions appearing
|
|
||||||
|
|
||||||
The desktop app installs hooks on its **first owned-server boot**, not before. After the app is running, start a **new** Claude Code session and confirm `~/.claude/settings.json` contains entries referencing `hook-handler.js`. If the app adopted an existing server on `4820`, that server's own hook configuration applies instead. For a blank dashboard window, check the desktop log (`~/Library/Logs/Claude Code Monitor/desktop.log` on macOS, `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows) via tray → *Show Logs* and use tray → *Restart Server*.
|
|
||||||
|
|||||||
+870
-18
@@ -190,8 +190,9 @@ async function api(method, pathname, body, options = {}) {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
const get = (p) => api("GET", p);
|
const get = (p, b, options) => api("GET", p, undefined, options);
|
||||||
const post = (p, b, options) => api("POST", p, b, options);
|
const post = (p, b, options) => api("POST", p, b, options);
|
||||||
|
const patch = (p, b, options) => api("PATCH", p, b, options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print the standard "server is not running" indicator and exit 1. Every
|
* Print the standard "server is not running" indicator and exit 1. Every
|
||||||
@@ -1520,6 +1521,133 @@ async function cmdLanesAdd(args) {
|
|||||||
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam skills install` — copy this checkout's `.claude/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||||
|
* (owning session died) and cap oversized hook logs, across every lane on
|
||||||
|
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||||
|
* action, no HTTP round-trip.
|
||||||
|
*/
|
||||||
|
function cmdLanesGc(args) {
|
||||||
|
const dryRun = args.includes("--dry-run");
|
||||||
|
const laneGc = require(path.join(REPO_ROOT, "server", "lib", "lane-gc.js"));
|
||||||
|
|
||||||
|
const reaped = laneGc.reapOrphanMcp({ dryRun });
|
||||||
|
if (reaped.length) {
|
||||||
|
console.log(
|
||||||
|
`${dryRun ? "would reap" : "reaped"} ${reaped.length} orphaned MCP process(es): ${reaped.join(", ")}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("no orphaned MCP processes found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const capped = laneGc.capOversizedLogs({ dryRun });
|
||||||
|
if (capped.length) {
|
||||||
|
for (const { path: logPath, sizeBefore } of capped) {
|
||||||
|
const mb = (sizeBefore / (1024 * 1024)).toFixed(1);
|
||||||
|
console.log(`${dryRun ? "would cap" : "capped"} ${logPath} (${mb}MB -> 2MB)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("no oversized logs found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes profile init <repo>` — detect a Node.js project and scaffold
|
||||||
|
* `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||||
|
* talk to the dashboard server at all.
|
||||||
|
*/
|
||||||
|
function cmdLanesProfileInit(args) {
|
||||||
|
const repo = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
const force = args.includes("--force");
|
||||||
|
if (!repo) {
|
||||||
|
console.error("usage: ccam lanes profile init <repo> [--force]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(repo);
|
||||||
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
||||||
|
console.error(`✖ not a directory: ${resolved}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
|
||||||
|
const facts = laneDetect.detectNode(resolved);
|
||||||
|
if (!facts) {
|
||||||
|
console.error(
|
||||||
|
`✖ No detectable Node.js project at ${resolved} (looked for backend/package.json +\n` +
|
||||||
|
" frontend/package.json, or a root package.json).\n" +
|
||||||
|
" Auto-scaffolding currently supports Node.js repos in that layout only.\n" +
|
||||||
|
" Write .ccam/profile/ by hand — see docs/LANES.md."
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = laneDetect.scaffoldProfile(resolved, facts, { force });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "EPROFILEEXISTS") {
|
||||||
|
console.error(`✖ ${err.message} — pass --force to overwrite it.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${c.green("✔")} Scaffolded .ccam/profile/ at ${resolved} (${facts.layout})`);
|
||||||
|
for (const file of result.written) console.log(` wrote ${file}`);
|
||||||
|
if (result.todos.length) {
|
||||||
|
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: ccam lanes profile check ${repo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes profile check [<path>]` — validate a profile without needing a
|
||||||
|
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||||
|
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
||||||
|
* against a bare repo right after `profile init`.
|
||||||
|
*/
|
||||||
|
async function cmdLanesProfileCheck(args) {
|
||||||
|
const target = args.find((arg) => !arg.startsWith("--")) || process.cwd();
|
||||||
|
const resolved = path.resolve(target);
|
||||||
|
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
|
||||||
|
const result = await laneDetect.checkProfile(resolved);
|
||||||
|
|
||||||
|
if (result.errors.length === 0) {
|
||||||
|
console.log(`${c.green("✔")} profile at ${resolved} looks good`);
|
||||||
|
} else {
|
||||||
|
console.log(`${c.red(`✖ ${result.errors.length} problem(s) at ${resolved}:`)}`);
|
||||||
|
for (const error of result.errors) console.log(` ${error}`);
|
||||||
|
}
|
||||||
|
for (const warning of result.warnings) console.log(`${c.yellow("⚠")} ${warning}`);
|
||||||
|
process.exitCode = result.ok ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep these confirmation facts in lockstep with expectedFields() in
|
// Keep these confirmation facts in lockstep with expectedFields() in
|
||||||
// server/routes/lanes.js. The server remains authoritative and rejects an
|
// server/routes/lanes.js. The server remains authoritative and rejects an
|
||||||
// incomplete or stale echo, while the CLI shows exactly what it will send.
|
// incomplete or stale echo, while the CLI shows exactly what it will send.
|
||||||
@@ -1543,13 +1671,19 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
const id = args.find((arg) => !arg.startsWith("--"));
|
const id = args.find((arg) => !arg.startsWith("--"));
|
||||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||||
if (!id) {
|
if (!id) {
|
||||||
console.error(`usage: ccam lanes ${action} <id> [--force] --yes`);
|
const extra = action === "reset" ? " [--keep-db]" : "";
|
||||||
|
console.error(`usage: ccam lanes ${action} <id> [--force]${extra} --yes`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
||||||
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
|
printLaneFacts(`Preflight for ${action} lane #${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";
|
||||||
|
console.log(` database: ${preflight.database} (${fate})`);
|
||||||
|
}
|
||||||
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
|
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
|
||||||
console.log("Warnings:");
|
console.log("Warnings:");
|
||||||
for (const warning of preflight.warnings) console.log(` ${warning}`);
|
for (const warning of preflight.warnings) console.log(` ${warning}`);
|
||||||
@@ -1579,6 +1713,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]]));
|
const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]]));
|
||||||
const body = { confirm: true, expect };
|
const body = { confirm: true, expect };
|
||||||
if (args.includes("--force")) body.force = true;
|
if (args.includes("--force")) body.force = true;
|
||||||
|
if (keepDb) body.keepDb = true;
|
||||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
const error = result.data?.error || {};
|
const error = result.data?.error || {};
|
||||||
@@ -1638,11 +1773,583 @@ async function cmdLanes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which lane a command is about: an explicit id, or the lane owning the working
|
||||||
|
* directory (longest path-boundary match, the same rule the server uses). A
|
||||||
|
* session running inside a lane never has to know its own id.
|
||||||
|
*
|
||||||
|
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||||
|
* Scanning the whole argv for a number would swallow flag values —
|
||||||
|
* `ccam lanes logs web --tail 4096` would have addressed lane 4096.
|
||||||
|
*
|
||||||
|
* @param {string[]} args - Arguments after the subcommand.
|
||||||
|
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||||
|
*/
|
||||||
|
async function resolveLaneArg(args) {
|
||||||
|
const flagValue = (name) => {
|
||||||
|
const i = args.indexOf(`--${name}`);
|
||||||
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const explicit = flagValue("lane");
|
||||||
|
if (explicit) return { laneId: explicit, rest: args };
|
||||||
|
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||||
|
|
||||||
|
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||||
|
const { lanes } = await get("/api/lanes");
|
||||||
|
const match = lanes
|
||||||
|
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
||||||
|
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
||||||
|
if (!match) {
|
||||||
|
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { laneId: match.id, rest: args };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per declared port: name, number, listening, and any base drift. */
|
||||||
|
function printRuntime(runtime) {
|
||||||
|
if (!runtime.available) {
|
||||||
|
console.log("no .ccam/profile for this lane — 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: ccam lanes up");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
|
||||||
|
if (runtime.database) {
|
||||||
|
console.log(` database ${runtime.database.name} (test: ${runtime.database.testName})`);
|
||||||
|
}
|
||||||
|
if (runtime.redisIndex != null) {
|
||||||
|
console.log(` redis logical db ${runtime.redisIndex}`);
|
||||||
|
}
|
||||||
|
for (const [name, info] of Object.entries(runtime.ports)) {
|
||||||
|
const drift =
|
||||||
|
info.port && info.port !== info.expected ? ` ⚠ base expects ${info.expected}` : "";
|
||||||
|
console.log(
|
||||||
|
` ${name.padEnd(10)} :${String(info.port ?? "-").padEnd(6)} ` +
|
||||||
|
`${info.listening ? "listening" : "down"}${drift}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const service of runtime.services) {
|
||||||
|
console.log(
|
||||||
|
` ${service.name.padEnd(10)} pid ${service.pid} ${service.alive ? "alive" : "gone"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (runtime.lastError) {
|
||||||
|
console.log(` last error: ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
|
||||||
|
}
|
||||||
|
if (runtime.logs.length) console.log(` logs: ${runtime.logs.join(", ")} (${runtime.logDir})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||||
|
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||||
|
* lane id.
|
||||||
|
*
|
||||||
|
* Every subcommand resolves the lane from the working directory when no id is
|
||||||
|
* given, so a session inside a lane can call them without knowing its id — this
|
||||||
|
* is the surface the driving skill uses.
|
||||||
|
*/
|
||||||
|
async function cmdLanesRuntime(sub, args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const { laneId, rest: laneArgs } = resolved;
|
||||||
|
|
||||||
|
if (sub === "runtime") {
|
||||||
|
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "up") {
|
||||||
|
const body = {};
|
||||||
|
if (laneArgs.includes("--no-build")) body.build = false;
|
||||||
|
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}`);
|
||||||
|
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…`);
|
||||||
|
const deadline = Date.now() + 15 * 60 * 1000;
|
||||||
|
for (;;) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
||||||
|
if (runtime.healthy) {
|
||||||
|
printRuntime(runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runtime.lastError) {
|
||||||
|
console.error(`✖ ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
|
||||||
|
printRuntime(runtime);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
console.error("✖ timed out waiting for the stack to become healthy");
|
||||||
|
printRuntime(runtime);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "logs") {
|
||||||
|
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!svc) {
|
||||||
|
console.error("usage: ccam lanes logs [<id>] <service> [--tail bytes]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const i = laneArgs.indexOf("--tail");
|
||||||
|
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.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
|
||||||
|
process.stdout.write(log.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "hook") {
|
||||||
|
const name = laneArgs[0];
|
||||||
|
if (!name || name.startsWith("--")) {
|
||||||
|
console.error("usage: ccam lanes hook [<id>] <name> [args…]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await post(
|
||||||
|
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||||
|
{ args: laneArgs.slice(1) },
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ hook ${name} → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`lane #${laneId} running hook ${name} — follow it with: ccam lanes logs ${laneId} ${name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "sync-base") {
|
||||||
|
const mode = laneArgs.includes("--check")
|
||||||
|
? "check"
|
||||||
|
: laneArgs.includes("--continue")
|
||||||
|
? "continue"
|
||||||
|
: "merge";
|
||||||
|
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||||
|
const result = await post(
|
||||||
|
`/api/lanes/${laneId}/sync-base`,
|
||||||
|
{ mode, branch },
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ sync-base → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 5) {
|
||||||
|
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
|
||||||
|
for (const c of result.collisions) {
|
||||||
|
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
|
||||||
|
}
|
||||||
|
process.exitCode = 5;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 4) {
|
||||||
|
console.error(`✖ lane #${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: ccam lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
|
||||||
|
);
|
||||||
|
process.exitCode = 4;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "check") {
|
||||||
|
if (result.devDelta === null) {
|
||||||
|
console.log("DEV_DELTA: unknown (no merge-base with origin/development)");
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`DEV_DELTA: ${result.devDelta.length} file(s) changed on origin/development since merge-base`
|
||||||
|
);
|
||||||
|
for (const f of result.devDelta) console.log(` ${f}`);
|
||||||
|
if (result.overlap.length) {
|
||||||
|
console.log(
|
||||||
|
`DEV_OVERLAP: ${result.overlap.length} file(s) — the upstream delta touches the feature's files:`
|
||||||
|
);
|
||||||
|
for (const f of result.overlap) console.log(` ${f}`);
|
||||||
|
} else {
|
||||||
|
console.log("DEV_OVERLAP: none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`lane #${laneId} preflight vs origin/development: OK`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default lock holder identity for the calling lane: `lane<slot>` when
|
||||||
|
* the lane has one allocated, else `lane<id>` — a lane's own row id — as a
|
||||||
|
* fallback for a lane that has never brought its runtime up. `--holder`
|
||||||
|
* always overrides both.
|
||||||
|
*
|
||||||
|
* @param {string[]} argsAfterName - Args AFTER the lock name has already been
|
||||||
|
* consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))`
|
||||||
|
* call) — `resolveLaneArg` treats a leading all-digits positional as a lane
|
||||||
|
* id, so the lock name itself must never reach it (a lock literally named
|
||||||
|
* e.g. "3" would otherwise be misread as lane 3).
|
||||||
|
*/
|
||||||
|
async function defaultHolder(argsAfterName) {
|
||||||
|
const explicit = (() => {
|
||||||
|
const i = argsAfterName.indexOf("--holder");
|
||||||
|
return i > -1 ? argsAfterName[i + 1] : undefined;
|
||||||
|
})();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
const resolved = await resolveLaneArg(argsAfterName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||||
|
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtLockRow(lock) {
|
||||||
|
const mins = Math.floor(lock.ageSec / 60);
|
||||||
|
return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lock status [<name>]` — one lock, or every held lock. */
|
||||||
|
async function cmdLockStatus(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (name) {
|
||||||
|
const { locks } = await get("/api/locks");
|
||||||
|
const lock = locks.find((l) => l.name === name);
|
||||||
|
console.log(lock ? fmtLockRow(lock) : `${name}: free`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { locks } = await get("/api/locks");
|
||||||
|
if (!locks.length) {
|
||||||
|
console.log("no locks held");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const lock of locks) console.log(fmtLockRow(lock));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lock acquire <name> [--holder X] [--timeout N]` — polls until the
|
||||||
|
* lock is free (or `--timeout` seconds elapse). Prints a status line every
|
||||||
|
* ~60s of continued waiting so a long wait never reads as a hung command —
|
||||||
|
* this is the CLI-side "heartbeat" the design calls for; it is terminal
|
||||||
|
* output, not a dashboard liveness signal.
|
||||||
|
*/
|
||||||
|
async function cmdLockAcquire(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!name) {
|
||||||
|
console.error("usage: ccam lock acquire <name> [--holder X] [--timeout seconds]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Strip the lock name before handing args to defaultHolder/resolveLaneArg —
|
||||||
|
// see defaultHolder's doc comment for why the name must never reach it.
|
||||||
|
const holder = await defaultHolder(args.filter((a) => a !== name));
|
||||||
|
if (!holder) return; // resolveLaneArg already printed an error
|
||||||
|
|
||||||
|
const timeoutIdx = args.indexOf("--timeout");
|
||||||
|
const timeoutMs =
|
||||||
|
timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null;
|
||||||
|
const deadline = timeoutMs ? Date.now() + timeoutMs : null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let lastPrinted = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const result = await post(
|
||||||
|
`/api/locks/${encodeURIComponent(name)}/acquire`,
|
||||||
|
{ holder },
|
||||||
|
{
|
||||||
|
allowError: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result.status === undefined || result.data?.acquired) {
|
||||||
|
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastPrinted >= 60_000) {
|
||||||
|
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
|
console.log(
|
||||||
|
`… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)`
|
||||||
|
);
|
||||||
|
lastPrinted = Date.now();
|
||||||
|
}
|
||||||
|
if (deadline && Date.now() >= deadline) {
|
||||||
|
console.error(`✖ timed out waiting for lock "${name}"`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lock release <name> [--holder X]`. */
|
||||||
|
async function cmdLockRelease(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!name) {
|
||||||
|
console.error("usage: ccam lock release <name> [--holder X]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const holder = await defaultHolder(args.filter((a) => a !== name));
|
||||||
|
if (!holder) return;
|
||||||
|
const result = await post(
|
||||||
|
`/api/locks/${encodeURIComponent(name)}/release`,
|
||||||
|
{ holder },
|
||||||
|
{
|
||||||
|
allowError: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${c.green("✔")} released lock "${name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
|
* `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
|
||||||
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the
|
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the
|
||||||
* dashboard shows a declared stage instead of an inferred one.
|
* dashboard shows a declared stage instead of an inferred one.
|
||||||
*/
|
*/
|
||||||
|
function fmtFeatureRow(f) {
|
||||||
|
const marker = f.archived_at ? " " : "▶ ";
|
||||||
|
return `${marker}${f.slug.padEnd(24)} ${String(f.stage).padEnd(12)} ${f.progress}%${
|
||||||
|
f.archived_at ? ` (archived ${fmtTime(f.archived_at)})` : ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam feature list [<id>] [--cwd path]` — every feature this lane has activated. */
|
||||||
|
async function cmdFeatureList(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
||||||
|
if (!features.length) {
|
||||||
|
console.log("no features activated yet — start one with: ccam feature activate <slug>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const f of features) console.log(fmtFeatureRow(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam feature activate <slug> [--title X] [<id>] [--cwd path]`. */
|
||||||
|
async function cmdFeatureActivate(args) {
|
||||||
|
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!slug) {
|
||||||
|
console.error("usage: ccam feature activate <slug> [--title text]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flag = (name) => {
|
||||||
|
const i = args.indexOf(`--${name}`);
|
||||||
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
|
if (!resolved) return;
|
||||||
|
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
||||||
|
slug,
|
||||||
|
title: flag("title"),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||||
|
/** `ccam lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||||
|
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||||
|
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||||
|
async function cmdLanesProofLink(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
||||||
|
console.log(
|
||||||
|
linked
|
||||||
|
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||||
|
: "proof/ already linked, nothing to do"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam 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(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam 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. */
|
||||||
|
async function cmdLanesMcpSync(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${c.green("✔")} synced: ${result.servers.join(", ")}`);
|
||||||
|
if (result.profilesSeeded.length) {
|
||||||
|
console.log(` seeded profiles: ${result.profilesSeeded.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lanes integration <name> [<id>]` — check whether a named
|
||||||
|
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||||
|
* its .ccam/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||||
|
async function cmdLanesIntegration(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!name) {
|
||||||
|
console.error("usage: ccam lanes integration <name> [<id>]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||||
|
if (!resolved) return;
|
||||||
|
const { enabled } = await get(
|
||||||
|
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||||
|
);
|
||||||
|
console.log(enabled ? "on" : "off");
|
||||||
|
process.exitCode = enabled ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||||
|
* template a lane renders against. `ccam lanes add --pipeline` could only set
|
||||||
|
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||||
|
* was stuck on `default` with no way back to a 16-node template.
|
||||||
|
*/
|
||||||
|
async function cmdLanesPipeline(args) {
|
||||||
|
const target = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== target));
|
||||||
|
if (!resolved) return;
|
||||||
|
|
||||||
|
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(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||||
|
console.log(
|
||||||
|
`${c.green("✔")} lane #${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
|
||||||
|
// different node list, so a stage that meant something in the old pipeline
|
||||||
|
// can land nowhere in the new one. Same warning as `ccam stage`, same reason.
|
||||||
|
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}" — ` +
|
||||||
|
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a real terminal to the exact tmux session the dashboard uses for
|
||||||
|
* this lane's run. Creates it if it doesn't exist yet (`tmux new-session -A`
|
||||||
|
* is create-or-attach, the same idempotent semantics as clicking Start on
|
||||||
|
* the dashboard). The user types `claude` themselves inside — this command's
|
||||||
|
* only job is landing them in the right named session.
|
||||||
|
*/
|
||||||
|
async function cmdLanesShell(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||||
|
const sessionName = `ccam-lane-${lane.id}`;
|
||||||
|
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
child.on("close", 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) {
|
||||||
|
console.error("usage: ccam feature show <slug>");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
|
if (!resolved) return;
|
||||||
|
const result = await get(
|
||||||
|
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||||
|
undefined,
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ feature "${slug}" → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = result.feature;
|
||||||
|
console.log(`${f.slug} ${f.archived_at ? "(archived)" : "(active)"}`);
|
||||||
|
console.log(` stage: ${f.stage} status: ${f.status} progress: ${f.progress}%`);
|
||||||
|
for (const node of f.pipeline_nodes) console.log(` ${node.state.padEnd(18)} ${node.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdStage(args) {
|
async function cmdStage(args) {
|
||||||
const stage = args[0];
|
const stage = args[0];
|
||||||
if (!stage || stage.startsWith("--")) {
|
if (!stage || stage.startsWith("--")) {
|
||||||
@@ -1657,20 +2364,11 @@ async function cmdStage(args) {
|
|||||||
return i > -1 ? args[i + 1] : undefined;
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
let laneId = flag("lane");
|
// `--lane` wins, otherwise the lane owning this directory. The stage name is
|
||||||
if (!laneId) {
|
// args[0], so only what follows it can carry a lane reference.
|
||||||
const cwd = require("path").resolve(flag("cwd") || process.cwd());
|
const resolved = await resolveLaneArg(args.slice(1));
|
||||||
const { lanes } = await get("/api/lanes");
|
if (!resolved) return;
|
||||||
const match = lanes
|
const laneId = resolved.laneId;
|
||||||
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
|
||||||
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
|
||||||
if (!match) {
|
|
||||||
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
laneId = match.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
||||||
stage,
|
stage,
|
||||||
@@ -1680,6 +2378,22 @@ async function cmdStage(args) {
|
|||||||
result: flag("result"),
|
result: flag("result"),
|
||||||
});
|
});
|
||||||
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
||||||
|
|
||||||
|
// A stage name matching no node (nor alias) still stores — setStage takes the
|
||||||
|
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
|
||||||
|
// `current` and progress reads 0. Warn, never fail: a typo must not break a
|
||||||
|
// declaration the pipeline can still record, but it must not pass silently.
|
||||||
|
// Skipped for `--result fail`, which paints the node `failed` rather than
|
||||||
|
// `current` and would otherwise look identical to an unknown stage.
|
||||||
|
const nodes = lane.pipeline_nodes || [];
|
||||||
|
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
|
||||||
|
console.error(
|
||||||
|
c.yellow(
|
||||||
|
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
|
||||||
|
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command catalog ─────────────────────────────────────────────────────────
|
// ── Command catalog ─────────────────────────────────────────────────────────
|
||||||
@@ -1780,12 +2494,91 @@ const COMMAND_GROUPS = [
|
|||||||
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
|
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
|
||||||
"Provision a managed worktree lane",
|
"Provision a managed worktree lane",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"lanes profile init",
|
||||||
|
"<repo> [--force]",
|
||||||
|
"Detect a Node.js project and scaffold .ccam/profile/",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes profile check",
|
||||||
|
"[<path>]",
|
||||||
|
"Validate a profile (path defaults to cwd, not a lane id)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes pipeline",
|
||||||
|
"[<template-id>] [<id>]",
|
||||||
|
"Show, or switch, which pipeline template a lane renders against",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes shell",
|
||||||
|
"[<id>]",
|
||||||
|
"Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"lanes reset|remove|purge",
|
"lanes reset|remove|purge",
|
||||||
"<id> [--force] --yes",
|
"<id> [--force] [--keep-db] --yes",
|
||||||
"Show preflight facts, then perform a destructive lane action",
|
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes 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)",
|
||||||
|
],
|
||||||
|
["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, …)"],
|
||||||
|
[
|
||||||
|
"lanes 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)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes proof-link",
|
||||||
|
"[<id>]",
|
||||||
|
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes agents install",
|
||||||
|
"[<id>]",
|
||||||
|
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes mcp sync",
|
||||||
|
"[<id>]",
|
||||||
|
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes integration",
|
||||||
|
"<name> [<id>]",
|
||||||
|
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes gc",
|
||||||
|
"[--dry-run]",
|
||||||
|
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"skills install",
|
||||||
|
"",
|
||||||
|
"Install .claude/skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"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)",
|
||||||
],
|
],
|
||||||
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
|
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
|
||||||
|
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
|
||||||
|
[
|
||||||
|
"feature activate",
|
||||||
|
"<slug> [--title text] [<id>]",
|
||||||
|
"Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feature show",
|
||||||
|
"<slug> [<id>]",
|
||||||
|
"Show one feature's saved pipeline (works on an archived one too)",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -2571,12 +3364,71 @@ async function runCommand(argv) {
|
|||||||
if (rest[0] === "add") {
|
if (rest[0] === "add") {
|
||||||
return cmdLanesAdd(rest.slice(1));
|
return cmdLanesAdd(rest.slice(1));
|
||||||
}
|
}
|
||||||
|
if (rest[0] === "profile") {
|
||||||
|
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
|
||||||
|
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
|
||||||
|
console.error(
|
||||||
|
"usage: ccam lanes profile init <repo> [--force] | ccam lanes profile check [<path>]"
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (["reset", "remove", "purge"].includes(rest[0])) {
|
if (["reset", "remove", "purge"].includes(rest[0])) {
|
||||||
return cmdLanesLifecycle(rest[0], rest.slice(1));
|
return cmdLanesLifecycle(rest[0], rest.slice(1));
|
||||||
}
|
}
|
||||||
|
if (["up", "down", "runtime", "logs", "hook", "sync-base"].includes(rest[0])) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
if (rest[0] === "integration") {
|
||||||
|
return cmdLanesIntegration(rest.slice(1));
|
||||||
|
}
|
||||||
|
if (rest[0] === "pipeline") {
|
||||||
|
return cmdLanesPipeline(rest.slice(1));
|
||||||
|
}
|
||||||
|
if (rest[0] === "shell") {
|
||||||
|
return cmdLanesShell(rest.slice(1));
|
||||||
|
}
|
||||||
|
if (rest[0] === "gc") {
|
||||||
|
return cmdLanesGc(rest.slice(1));
|
||||||
|
}
|
||||||
return cmdLanes();
|
return cmdLanes();
|
||||||
|
case "skills": {
|
||||||
|
if (rest[0] === "install") return cmdSkillsInstall();
|
||||||
|
console.error("usage: ccam skills install");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "lock": {
|
||||||
|
const sub = rest[0];
|
||||||
|
if (sub === "status") return cmdLockStatus(rest.slice(1));
|
||||||
|
if (sub === "acquire") return cmdLockAcquire(rest.slice(1));
|
||||||
|
if (sub === "release") return cmdLockRelease(rest.slice(1));
|
||||||
|
console.error(
|
||||||
|
"usage: ccam lock status [<name>] | ccam lock acquire <name> [--holder X] [--timeout N] | ccam lock release <name> [--holder X]"
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "stage":
|
case "stage":
|
||||||
return cmdStage(rest);
|
return cmdStage(rest);
|
||||||
|
case "feature": {
|
||||||
|
const sub = rest[0];
|
||||||
|
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
||||||
|
if (sub === "activate") return cmdFeatureActivate(rest.slice(1));
|
||||||
|
if (sub === "show") return cmdFeatureShow(rest.slice(1));
|
||||||
|
console.error(
|
||||||
|
"usage: ccam feature list | ccam feature activate <slug> [--title text] | ccam feature show <slug>"
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "open":
|
case "open":
|
||||||
return cmdOpen();
|
return cmdOpen();
|
||||||
case "version":
|
case "version":
|
||||||
|
|||||||
+8
-4
@@ -54,7 +54,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
|||||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||||
- **Tailwind CSS 3.4** - Utility-first CSS framework for rapid UI development
|
- **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
|
||||||
- **React Router 6.28** - Client-side routing with nested layouts
|
- **React Router 6.28** - Client-side routing with nested layouts
|
||||||
- **WebSocket** - Real-time event streaming from server
|
- **WebSocket** - Real-time event streaming from server
|
||||||
- **Lucide Icons** - Modern, consistent icon set
|
- **Lucide Icons** - Modern, consistent icon set
|
||||||
@@ -203,17 +203,18 @@ client/
|
|||||||
│ │
|
│ │
|
||||||
│ ├── hooks/
|
│ ├── hooks/
|
||||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||||
│ │ └── useNotifications.ts # Browser push notification triggers
|
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||||
|
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
|
||||||
│ │
|
│ │
|
||||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||||||
│ ├── App.tsx # Root component + router setup
|
│ ├── App.tsx # Root component + router setup
|
||||||
│ ├── main.tsx # Entry point
|
│ ├── main.tsx # Entry point
|
||||||
│ └── index.css # Tailwind + custom utilities
|
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
|
||||||
│
|
│
|
||||||
├── public/ # Static assets (sw.js service worker)
|
├── public/ # Static assets (sw.js service worker)
|
||||||
├── index.html # HTML template
|
├── index.html # HTML template
|
||||||
├── vite.config.ts # Vite + proxy config
|
├── vite.config.ts # Vite + proxy config
|
||||||
├── tailwind.config.js # Custom dark theme
|
├── tailwind.config.js # Dark/light color tokens (`darkMode: "class"`, CSS-variable-backed)
|
||||||
├── tsconfig.json # Strict TypeScript config
|
├── tsconfig.json # Strict TypeScript config
|
||||||
└── package.json
|
└── package.json
|
||||||
```
|
```
|
||||||
@@ -399,6 +400,9 @@ Server broadcasts these event types over WebSocket:
|
|||||||
| `notification.received` | Notification object | Notification hook |
|
| `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_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 |
|
| `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_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
|
### EventBus Pattern
|
||||||
|
|
||||||
|
|||||||
Generated
+17
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-sankey": "^0.12.3",
|
"d3-sankey": "^0.12.3",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
@@ -2100,6 +2102,21 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
|||||||
+4
-2
@@ -7,12 +7,14 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "NODE_OPTIONS=--no-experimental-webstorage vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-sankey": "^0.12.3",
|
"d3-sankey": "^0.12.3",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./components/Layout`
|
* - `./components/Layout`
|
||||||
* - `./components/SplashScreen`
|
* - `./components/SplashScreen`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./StatusBadge`
|
* - `./StatusBadge`
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
@@ -194,14 +189,14 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 overflow-hidden">
|
<div className="min-w-0 overflow-hidden">
|
||||||
<p className="text-sm font-medium text-gray-200 truncate">
|
<p className="text-sm font-medium text-fg-secondary truncate">
|
||||||
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
|
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
|
||||||
229d93fd" or "Main Agent - work - e3f8e613") swap the
|
229d93fd" or "Main Agent - work - e3f8e613") swap the
|
||||||
placeholder for the real session name when one exists; custom
|
placeholder for the real session name when one exists; custom
|
||||||
(sub)agent names are left untouched. */}
|
(sub)agent names are left untouched. */}
|
||||||
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
|
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
|
||||||
</p>
|
</p>
|
||||||
{subtitle && <p className="text-[11px] text-gray-500 truncate">{subtitle}</p>}
|
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* compact: cards are narrow — inline reason chip would squeeze the
|
{/* compact: cards are narrow — inline reason chip would squeeze the
|
||||||
@@ -210,10 +205,10 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{agent.task && (
|
{agent.task && (
|
||||||
<p className="text-xs text-gray-400 mb-3 line-clamp-2 leading-relaxed">{agent.task}</p>
|
<p className="text-xs text-fg-secondary mb-3 line-clamp-2 leading-relaxed">{agent.task}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-[11px] text-gray-500 min-w-0 overflow-hidden flex-wrap">
|
<div className="flex items-center gap-3 text-[11px] text-fg-muted min-w-0 overflow-hidden flex-wrap">
|
||||||
{agent.current_tool && (
|
{agent.current_tool && (
|
||||||
<span className="flex items-center gap-1 flex-shrink-0">
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
<Wrench className="w-3 h-3" />
|
<Wrench className="w-3 h-3" />
|
||||||
@@ -243,7 +238,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
{t("ran")}
|
{t("ran")}
|
||||||
{formatDuration(agent.started_at, agent.ended_at)}
|
{formatDuration(agent.started_at, agent.ended_at)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600 flex-shrink-0">{timeAgo(agent.ended_at)}</span>
|
<span className="text-fg-muted flex-shrink-0">{timeAgo(agent.ended_at)}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1 flex-shrink-0">
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
|||||||
@@ -27,11 +27,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
@@ -403,8 +398,8 @@ export function AlertsNotifications() {
|
|||||||
onClick={() => setTab(tb.key)}
|
onClick={() => setTab(tb.key)}
|
||||||
className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${
|
className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${
|
||||||
active
|
active
|
||||||
? "bg-surface-4 text-gray-100 shadow-sm"
|
? "bg-surface-4 text-fg-primary shadow-sm"
|
||||||
: "text-gray-500 hover:text-gray-300 hover:bg-surface-3"
|
: "text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
@@ -413,10 +408,10 @@ export function AlertsNotifications() {
|
|||||||
<span
|
<span
|
||||||
className={`text-[10px] font-semibold rounded-full px-1.5 min-w-[18px] text-center ${
|
className={`text-[10px] font-semibold rounded-full px-1.5 min-w-[18px] text-center ${
|
||||||
tb.key === "activity"
|
tb.key === "activity"
|
||||||
? "text-amber-300 bg-amber-500/15"
|
? "text-status-warning bg-status-warning/15"
|
||||||
: active
|
: active
|
||||||
? "text-accent bg-accent/15"
|
? "text-accent bg-accent/15"
|
||||||
: "text-gray-400 bg-surface-2"
|
: "text-fg-secondary bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tb.badge}
|
{tb.badge}
|
||||||
@@ -432,8 +427,8 @@ export function AlertsNotifications() {
|
|||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-200">{t("rules.title")}</h4>
|
<h4 className="text-sm font-semibold text-fg-secondary">{t("rules.title")}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{ts("alertsHub.rulesHint")}</p>
|
<p className="text-xs text-fg-muted mt-0.5">{ts("alertsHub.rulesHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -450,7 +445,7 @@ export function AlertsNotifications() {
|
|||||||
{formOpen && (
|
{formOpen && (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 p-3 mb-3 space-y-3">
|
<div className="rounded-lg border border-border bg-surface-2 p-3 mb-3 space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.name")}
|
{t("rules.form.name")}
|
||||||
<FieldHelp description={t("rules.help.name")} />
|
<FieldHelp description={t("rules.help.name")} />
|
||||||
@@ -463,7 +458,7 @@ export function AlertsNotifications() {
|
|||||||
className="input mt-1 w-full"
|
className="input mt-1 w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.type")}
|
{t("rules.form.type")}
|
||||||
<FieldHelp title={t("rules.form.type")} description={t("rules.help.type")} />
|
<FieldHelp title={t("rules.form.type")} description={t("rules.help.type")} />
|
||||||
@@ -480,16 +475,16 @@ export function AlertsNotifications() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
|
<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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-gray-500">{t(`ruleTypeHints.${form.rule_type}`)}</p>
|
<p className="text-[11px] text-fg-muted">{t(`ruleTypeHints.${form.rule_type}`)}</p>
|
||||||
|
|
||||||
{form.rule_type === "event_pattern" && (
|
{form.rule_type === "event_pattern" && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.eventType")}
|
{t("rules.form.eventType")}
|
||||||
<FieldHelp
|
<FieldHelp
|
||||||
@@ -506,7 +501,7 @@ export function AlertsNotifications() {
|
|||||||
className="input mt-1 w-full"
|
className="input mt-1 w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.toolName")}
|
{t("rules.form.toolName")}
|
||||||
<FieldHelp
|
<FieldHelp
|
||||||
@@ -523,7 +518,7 @@ export function AlertsNotifications() {
|
|||||||
className="input mt-1 w-full"
|
className="input mt-1 w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.summaryContains")}
|
{t("rules.form.summaryContains")}
|
||||||
<FieldHelp
|
<FieldHelp
|
||||||
@@ -540,7 +535,7 @@ export function AlertsNotifications() {
|
|||||||
className="input mt-1 w-full"
|
className="input mt-1 w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.count")}
|
{t("rules.form.count")}
|
||||||
<FieldHelp description={t("rules.help.count")} />
|
<FieldHelp description={t("rules.help.count")} />
|
||||||
@@ -554,7 +549,7 @@ export function AlertsNotifications() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{parseInt(form.count, 10) > 1 && (
|
{parseInt(form.count, 10) > 1 && (
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.windowMinutes")}
|
{t("rules.form.windowMinutes")}
|
||||||
<FieldHelp description={t("rules.help.window")} />
|
<FieldHelp description={t("rules.help.window")} />
|
||||||
@@ -574,7 +569,7 @@ export function AlertsNotifications() {
|
|||||||
{(form.rule_type === "inactivity" || form.rule_type === "status_duration") && (
|
{(form.rule_type === "inactivity" || form.rule_type === "status_duration") && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
{form.rule_type === "status_duration" && (
|
{form.rule_type === "status_duration" && (
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.agentStatus")}
|
{t("rules.form.agentStatus")}
|
||||||
<FieldHelp description={t("rules.help.status")} />
|
<FieldHelp description={t("rules.help.status")} />
|
||||||
@@ -588,11 +583,11 @@ export function AlertsNotifications() {
|
|||||||
<option value="working">working</option>
|
<option value="working">working</option>
|
||||||
<option value="waiting">waiting</option>
|
<option value="waiting">waiting</option>
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
|
<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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.minutes")}
|
{t("rules.form.minutes")}
|
||||||
<FieldHelp
|
<FieldHelp
|
||||||
@@ -616,7 +611,7 @@ export function AlertsNotifications() {
|
|||||||
|
|
||||||
{form.rule_type === "token_threshold" && (
|
{form.rule_type === "token_threshold" && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t("rules.form.totalTokens")}
|
{t("rules.form.totalTokens")}
|
||||||
<FieldHelp description={t("rules.help.totalTokens")} />
|
<FieldHelp description={t("rules.help.totalTokens")} />
|
||||||
@@ -633,7 +628,7 @@ export function AlertsNotifications() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<label className="block text-xs text-gray-400">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="mb-1.5 flex items-center gap-1">
|
<span className="mb-1.5 flex items-center gap-1">
|
||||||
{t("rules.form.cooldown")}
|
{t("rules.form.cooldown")}
|
||||||
<FieldHelp description={t("rules.help.cooldown")} />
|
<FieldHelp description={t("rules.help.cooldown")} />
|
||||||
@@ -655,7 +650,7 @@ export function AlertsNotifications() {
|
|||||||
{saving ? t("rules.saving") : t("rules.create")}
|
{saving ? t("rules.saving") : t("rules.create")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{formError && <p className="text-xs text-red-400">{formError}</p>}
|
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -680,7 +675,7 @@ export function AlertsNotifications() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-medium truncate ${rule.enabled ? "text-gray-200" : "text-gray-500 line-through"}`}
|
className={`text-sm font-medium truncate ${rule.enabled ? "text-fg-secondary" : "text-fg-muted line-through"}`}
|
||||||
>
|
>
|
||||||
{rule.name}
|
{rule.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -688,7 +683,7 @@ export function AlertsNotifications() {
|
|||||||
{t(`ruleTypes.${rule.rule_type}`)}
|
{t(`ruleTypes.${rule.rule_type}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
<p className="text-xs text-fg-muted mt-0.5 truncate">
|
||||||
{describeRule(rule, t)} ·{" "}
|
{describeRule(rule, t)} ·{" "}
|
||||||
{t("rules.cooldown", { seconds: rule.cooldown_seconds })}
|
{t("rules.cooldown", { seconds: rule.cooldown_seconds })}
|
||||||
</p>
|
</p>
|
||||||
@@ -698,8 +693,8 @@ export function AlertsNotifications() {
|
|||||||
onClick={() => onToggleRule(rule)}
|
onClick={() => onToggleRule(rule)}
|
||||||
className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
|
className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
|
||||||
rule.enabled
|
rule.enabled
|
||||||
? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
|
? "border-status-success/30 text-status-success hover:bg-status-success/10"
|
||||||
: "border-border text-gray-500 hover:text-gray-300 hover:bg-surface-3"
|
: "border-border text-fg-muted hover:text-fg-secondary hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
title={rule.enabled ? t("rules.disable") : t("rules.enable")}
|
title={rule.enabled ? t("rules.disable") : t("rules.enable")}
|
||||||
>
|
>
|
||||||
@@ -707,7 +702,7 @@ export function AlertsNotifications() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmRule(rule)}
|
onClick={() => setConfirmRule(rule)}
|
||||||
className="p-1.5 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
|
||||||
title={t("rules.delete")}
|
title={t("rules.delete")}
|
||||||
aria-label={t("rules.delete")}
|
aria-label={t("rules.delete")}
|
||||||
>
|
>
|
||||||
@@ -728,10 +723,10 @@ export function AlertsNotifications() {
|
|||||||
{tab === "activity" && (
|
{tab === "activity" && (
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||||
<h4 className="text-sm font-semibold text-gray-200">
|
<h4 className="text-sm font-semibold text-fg-secondary">
|
||||||
{t("feed.title")}
|
{t("feed.title")}
|
||||||
{unacked > 0 && (
|
{unacked > 0 && (
|
||||||
<span className="ml-2 text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded-full px-2 py-0.5">
|
<span className="ml-2 text-[10px] font-semibold text-status-warning bg-status-warning/10 border border-status-warning/30 rounded-full px-2 py-0.5">
|
||||||
{t("feed.unackedCount", { count: unacked })}
|
{t("feed.unackedCount", { count: unacked })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -782,17 +777,17 @@ export function AlertsNotifications() {
|
|||||||
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2.5 ${
|
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2.5 ${
|
||||||
alert.acknowledged_at
|
alert.acknowledged_at
|
||||||
? "border-border bg-surface-2 opacity-70"
|
? "border-border bg-surface-2 opacity-70"
|
||||||
: "border-amber-500/30 bg-amber-500/5"
|
: "border-status-warning/30 bg-status-warning/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BellRing
|
<BellRing
|
||||||
className={`w-3.5 h-3.5 flex-shrink-0 ${alert.acknowledged_at ? "text-gray-500" : "text-amber-400"}`}
|
className={`w-3.5 h-3.5 flex-shrink-0 ${alert.acknowledged_at ? "text-fg-muted" : "text-status-warning"}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-200 truncate">{alert.message}</span>
|
<span className="text-sm text-fg-secondary truncate">{alert.message}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-500 mt-0.5 font-mono">
|
<p className="text-[11px] text-fg-muted mt-0.5 font-mono">
|
||||||
{timeAgo(alert.triggered_at)} · {alert.rule_name}
|
{timeAgo(alert.triggered_at)} · {alert.rule_name}
|
||||||
{alert.session_id && (
|
{alert.session_id && (
|
||||||
<>
|
<>
|
||||||
@@ -810,7 +805,7 @@ export function AlertsNotifications() {
|
|||||||
{!alert.acknowledged_at && (
|
{!alert.acknowledged_at && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onAck(alert.id)}
|
onClick={() => onAck(alert.id)}
|
||||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-gray-300 hover:text-gray-100 hover:bg-surface-3 transition-colors flex-shrink-0"
|
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
{t("feed.ack")}
|
{t("feed.ack")}
|
||||||
|
|||||||
@@ -31,11 +31,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `CheckboxProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `CheckboxProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `Checkbox` — exported API; see TSDoc on the symbol for behavior.
|
* - `Checkbox` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -105,7 +100,9 @@ export function Checkbox({ checked, onChange, label, className, labelClassName }
|
|||||||
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||||
</span>
|
</span>
|
||||||
{label != null && (
|
{label != null && (
|
||||||
<span className={labelClassName ?? "text-xs text-gray-400 group-hover:text-gray-300"}>
|
<span
|
||||||
|
className={labelClassName ?? "text-xs text-fg-secondary group-hover:text-fg-secondary"}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -133,16 +133,16 @@ export function ConfirmModal({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 p-5">
|
<div className="flex items-start gap-3 p-5">
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<div className="w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center flex-shrink-0">
|
<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-red-400" />
|
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 id={titleId} className="text-sm font-semibold text-gray-100">
|
<h3 id={titleId} className="text-sm font-semibold text-fg-primary">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{message && (
|
{message && (
|
||||||
<p id={messageId} className="text-xs text-gray-400 mt-1 leading-relaxed">
|
<p id={messageId} className="text-xs text-fg-secondary mt-1 leading-relaxed">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -151,7 +151,7 @@ export function ConfirmModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-gray-500 hover:text-gray-300 p-1 -mt-1 -mr-1"
|
className="text-fg-muted hover:text-fg-secondary p-1 -mt-1 -mr-1"
|
||||||
aria-label={cancelLabel}
|
aria-label={cancelLabel}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -172,7 +172,7 @@ export function ConfirmModal({
|
|||||||
disabled={busy || disabled}
|
disabled={busy || disabled}
|
||||||
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
|
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
|
||||||
destructive
|
destructive
|
||||||
? "text-red-200 bg-red-500/15 border border-red-500/30 hover:bg-red-500/25"
|
? "text-status-danger bg-status-danger/15 border border-status-danger/30 hover:bg-status-danger/25"
|
||||||
: "btn-primary"
|
: "btn-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `DateTimePicker` — exported API; see TSDoc on the symbol for behavior.
|
* - `DateTimePicker` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
@@ -180,7 +175,7 @@ export function DateTimePicker({
|
|||||||
? "bg-accent text-white font-medium"
|
? "bg-accent text-white font-medium"
|
||||||
: isToday
|
: isToday
|
||||||
? "bg-surface-3 text-accent font-medium"
|
? "bg-surface-3 text-accent font-medium"
|
||||||
: "hover:bg-surface-2 text-gray-300 hover:text-white"
|
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{i}
|
{i}
|
||||||
@@ -202,12 +197,12 @@ export function DateTimePicker({
|
|||||||
title={title}
|
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`}
|
||||||
>
|
>
|
||||||
<Calendar className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
<Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
|
||||||
<span className={`flex-1 truncate ${!dateObj ? "text-gray-500" : "text-gray-200"}`}>
|
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
|
||||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||||
</span>
|
</span>
|
||||||
{dateObj && (
|
{dateObj && (
|
||||||
<X className="w-3 h-3 text-gray-500 hover:text-white shrink-0" onClick={clearValue} />
|
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -222,11 +217,11 @@ export function DateTimePicker({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||||
}
|
}
|
||||||
className="p-1 hover:bg-surface-2 rounded text-gray-400 hover:text-white"
|
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs font-medium text-gray-200">
|
<span className="text-xs font-medium text-fg-secondary">
|
||||||
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
|
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -234,7 +229,7 @@ export function DateTimePicker({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||||
}
|
}
|
||||||
className="p-1 hover:bg-surface-2 rounded text-gray-400 hover:text-white"
|
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -244,7 +239,7 @@ export function DateTimePicker({
|
|||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
||||||
<div key={day} className="w-6 text-center text-[10px] font-medium text-gray-500">
|
<div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -254,7 +249,7 @@ export function DateTimePicker({
|
|||||||
|
|
||||||
{/* Time Picker */}
|
{/* Time Picker */}
|
||||||
<div className="pt-3 border-t border-border flex items-center justify-between">
|
<div className="pt-3 border-t border-border flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 text-gray-400">
|
<div className="flex items-center gap-1.5 text-fg-secondary">
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
<span className="text-[11px] font-medium">Time</span>
|
<span className="text-[11px] font-medium">Time</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +257,7 @@ export function DateTimePicker({
|
|||||||
type="time"
|
type="time"
|
||||||
value={timeValue}
|
value={timeValue}
|
||||||
onChange={handleTimeChange}
|
onChange={handleTimeChange}
|
||||||
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-gray-200 focus:outline-none focus:border-accent w-[85px] time-input-custom"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `EmptyStateProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `EmptyStateProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `EmptyState` — exported API; see TSDoc on the symbol for behavior.
|
* - `EmptyState` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -91,10 +86,10 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<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-gray-500" />
|
<Icon className="w-6 h-6 text-fg-muted" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-medium text-gray-300 mb-2">{title}</h3>
|
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
|
||||||
<p className="text-sm text-gray-500 max-w-md mb-6">{description}</p>
|
<p className="text-sm text-fg-muted max-w-md mb-6">{description}</p>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
* - `../lib/event-grouping`
|
* - `../lib/event-grouping`
|
||||||
@@ -284,7 +279,7 @@ function SummaryBlock({
|
|||||||
return (
|
return (
|
||||||
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
|
<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-black/20">
|
||||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide font-semibold">
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
|
||||||
{t("eventDetail.summary")}
|
{t("eventDetail.summary")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,19 +288,19 @@ function SummaryBlock({
|
|||||||
<span className="text-base leading-none" aria-hidden="true">
|
<span className="text-base leading-none" aria-hidden="true">
|
||||||
{summary.icon}
|
{summary.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] text-gray-100 font-medium break-words">
|
<span className="text-[12px] text-fg-primary font-medium break-words">
|
||||||
{summary.headline}
|
{summary.headline}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{summary.bullets.length > 0 && (
|
{summary.bullets.length > 0 && (
|
||||||
<ul className="list-disc pl-6 space-y-0.5 text-[11px] text-gray-400">
|
<ul className="list-disc pl-6 space-y-0.5 text-[11px] text-fg-secondary">
|
||||||
{summary.bullets.map((b, i) => (
|
{summary.bullets.map((b, i) => (
|
||||||
<li key={i}>{b}</li>
|
<li key={i}>{b}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{hint && (
|
{hint && (
|
||||||
<div className="text-[11px] text-gray-500 italic pt-1 border-t border-border/40">
|
<div className="text-[11px] text-fg-muted italic pt-1 border-t border-border/40">
|
||||||
↓ {hint}
|
↓ {hint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -337,7 +332,7 @@ function FieldRow({
|
|||||||
if (view) {
|
if (view) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||||
<div className="text-gray-500 font-mono pt-2">{label}</div>
|
<div className="text-fg-muted font-mono pt-2">{label}</div>
|
||||||
<div>{view}</div>
|
<div>{view}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -348,7 +343,7 @@ function FieldRow({
|
|||||||
if (view) {
|
if (view) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||||
<div className="text-gray-500 font-mono pt-2">{label}</div>
|
<div className="text-fg-muted font-mono pt-2">{label}</div>
|
||||||
<div>{view}</div>
|
<div>{view}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -358,8 +353,8 @@ function FieldRow({
|
|||||||
if (isInlineScalar(value)) {
|
if (isInlineScalar(value)) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||||
<div className="text-gray-500 font-mono pt-0.5">{label}</div>
|
<div className="text-fg-muted font-mono pt-0.5">{label}</div>
|
||||||
<div className="text-gray-300 font-mono break-all">
|
<div className="text-fg-secondary font-mono break-all">
|
||||||
<ScalarValue value={value} />
|
<ScalarValue value={value} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +363,7 @@ function FieldRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
|
||||||
<div className="text-gray-500 font-mono pt-2">{label}</div>
|
<div className="text-fg-muted font-mono pt-2">{label}</div>
|
||||||
<CodeView value={value} />
|
<CodeView value={value} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -382,11 +377,11 @@ function isInlineScalar(value: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ScalarValue({ value }: { value: unknown }) {
|
function ScalarValue({ value }: { value: unknown }) {
|
||||||
if (value == null) return <span className="text-gray-500 italic">null</span>;
|
if (value == null) return <span className="text-fg-muted italic">null</span>;
|
||||||
if (typeof value === "boolean") {
|
if (typeof value === "boolean") {
|
||||||
const color = value
|
const color = value
|
||||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||||
: "text-gray-400 border-gray-500/30 bg-gray-500/10";
|
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
|
||||||
return (
|
return (
|
||||||
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
|
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
|
||||||
);
|
);
|
||||||
@@ -402,12 +397,12 @@ function CodeView({ value }: { value: unknown }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
<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="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
|
||||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
|
||||||
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
||||||
</span>
|
</span>
|
||||||
<CopyButton text={text} />
|
<CopyButton text={text} />
|
||||||
</div>
|
</div>
|
||||||
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words max-h-96 overflow-auto">
|
<pre className="px-3 py-2 text-fg-secondary whitespace-pre-wrap break-words max-h-96 overflow-auto">
|
||||||
{text}
|
{text}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `./DateTimePicker`
|
* - `./DateTimePicker`
|
||||||
@@ -232,16 +227,16 @@ export function EventFilters({
|
|||||||
return (
|
return (
|
||||||
<div className="card p-3 space-y-2">
|
<div className="card p-3 space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Filter className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
<Filter className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
<div className="relative flex-1 min-w-[180px]">
|
<div className="relative flex-1 min-w-[180px]">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-fg-muted" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={(e) => setSearchDraft(e.target.value)}
|
onChange={(e) => setSearchDraft(e.target.value)}
|
||||||
placeholder={t("eventFilters.searchPlaceholder")}
|
placeholder={t("eventFilters.searchPlaceholder")}
|
||||||
aria-label={t("eventFilters.searchPlaceholder")}
|
aria-label={t("eventFilters.searchPlaceholder")}
|
||||||
className="w-full bg-surface-2 border border-border rounded pl-7 pr-2 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-accent"
|
className="w-full bg-surface-2 border border-border rounded pl-7 pr-2 py-1.5 text-xs text-fg-secondary placeholder-fg-muted focus:outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -251,7 +246,7 @@ export function EventFilters({
|
|||||||
title={t("eventFilters.from")}
|
title={t("eventFilters.from")}
|
||||||
placeholder={t("eventFilters.from")}
|
placeholder={t("eventFilters.from")}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-600">→</span>
|
<span className="text-xs text-fg-muted">→</span>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={value.to}
|
value={value.to}
|
||||||
onChange={(val: string) => onChange({ ...value, to: val })}
|
onChange={(val: string) => onChange({ ...value, to: val })}
|
||||||
@@ -263,7 +258,7 @@ export function EventFilters({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(EMPTY_FILTERS)}
|
onClick={() => onChange(EMPTY_FILTERS)}
|
||||||
className="flex items-center gap-1 text-[11px] px-2 py-1 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer"
|
className="flex items-center gap-1 text-[11px] px-2 py-1 rounded text-fg-secondary hover:text-fg-primary hover:bg-surface-2 cursor-pointer"
|
||||||
aria-label={t("eventFilters.clearAll")}
|
aria-label={t("eventFilters.clearAll")}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
@@ -359,7 +354,7 @@ function ChipGroup({
|
|||||||
className={`text-[11px] px-2 py-1 rounded border cursor-pointer flex items-center gap-1.5 ${
|
className={`text-[11px] px-2 py-1 rounded border cursor-pointer flex items-center gap-1.5 ${
|
||||||
selectedCount > 0
|
selectedCount > 0
|
||||||
? "border-accent/40 bg-accent/10 text-accent"
|
? "border-accent/40 bg-accent/10 text-accent"
|
||||||
: "border-border bg-surface-2 text-gray-400 hover:text-gray-200"
|
: "border-border bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
@@ -374,7 +369,7 @@ function ChipGroup({
|
|||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<p className="text-[11px] text-gray-500 px-2 py-1 italic">
|
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
|
||||||
{t("eventFilters.noOptions")}
|
{t("eventFilters.noOptions")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -384,7 +379,7 @@ function ChipGroup({
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={opt}
|
key={opt}
|
||||||
className="flex items-center gap-2 px-2 py-1 text-[11px] text-gray-300 rounded hover:bg-surface-3 cursor-pointer"
|
className="flex items-center gap-2 px-2 py-1 text-[11px] text-fg-secondary rounded hover:bg-surface-3 cursor-pointer"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -28,11 +28,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./StatusBadge`
|
* - `./StatusBadge`
|
||||||
*
|
*
|
||||||
@@ -71,17 +66,17 @@ export function EventFiltersInfo() {
|
|||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
return (
|
return (
|
||||||
<details className="card bg-surface-2/40 border border-border rounded overflow-hidden">
|
<details className="card bg-surface-2/40 border border-border rounded overflow-hidden">
|
||||||
<summary className="cursor-pointer select-none px-3 py-2 flex items-center text-[11px] text-gray-400 hover:text-gray-200 hover:bg-surface-2/80">
|
<summary className="cursor-pointer select-none px-3 py-2 flex items-center text-[11px] text-fg-secondary hover:text-fg-primary hover:bg-surface-2/80">
|
||||||
<Info className="w-3.5 h-3.5 mr-2" />
|
<Info className="w-3.5 h-3.5 mr-2" />
|
||||||
<span className="font-semibold uppercase tracking-wide mr-1.5">
|
<span className="font-semibold uppercase tracking-wide mr-1.5">
|
||||||
{t("eventFilters.help.title")}
|
{t("eventFilters.help.title")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 font-normal">- {t("eventFilters.help.subtitle")}</span>
|
<span className="text-fg-muted font-normal">- {t("eventFilters.help.subtitle")}</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
<Section title={t("eventFilters.help.statusesTitle")}>
|
<Section title={t("eventFilters.help.statusesTitle")}>
|
||||||
<p className="text-[11px] text-gray-500 mb-2">{t("eventFilters.help.statusesIntro")}</p>
|
<p className="text-[11px] text-fg-muted mb-2">{t("eventFilters.help.statusesIntro")}</p>
|
||||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11px]">
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[11px]">
|
||||||
<dt>
|
<dt>
|
||||||
<AgentStatusBadge status="working" />
|
<AgentStatusBadge status="working" />
|
||||||
@@ -103,17 +98,19 @@ export function EventFiltersInfo() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("eventFilters.help.lifecycleTitle")}>
|
<Section title={t("eventFilters.help.lifecycleTitle")}>
|
||||||
<p className="text-[11px] text-gray-400 mb-2">{t("eventFilters.help.lifecycleDesc")}</p>
|
<p className="text-[11px] text-fg-secondary mb-2">
|
||||||
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-gray-300 whitespace-pre-wrap">
|
{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">
|
||||||
{t("eventFilters.help.lifecycleFlow")}
|
{t("eventFilters.help.lifecycleFlow")}
|
||||||
</code>
|
</code>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("eventFilters.help.filtersTitle")}>
|
<Section title={t("eventFilters.help.filtersTitle")}>
|
||||||
<ul className="list-disc pl-5 space-y-1 text-[11px] text-gray-400">
|
<ul className="list-disc pl-5 space-y-1 text-[11px] text-fg-secondary">
|
||||||
<li>{t("eventFilters.help.filterTip1")}</li>
|
<li>{t("eventFilters.help.filterTip1")}</li>
|
||||||
<li>{t("eventFilters.help.filterTip2")}</li>
|
<li>{t("eventFilters.help.filterTip2")}</li>
|
||||||
<li className="text-amber-300/90">{t("eventFilters.help.filterTipGrouping")}</li>
|
<li className="text-status-warning/90">{t("eventFilters.help.filterTipGrouping")}</li>
|
||||||
<li>{t("eventFilters.help.filterTip3")}</li>
|
<li>{t("eventFilters.help.filterTip3")}</li>
|
||||||
<li>{t("eventFilters.help.filterTip4")}</li>
|
<li>{t("eventFilters.help.filterTip4")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -157,9 +154,9 @@ export function EventFiltersInfo() {
|
|||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<details className="group" open>
|
<details className="group" open>
|
||||||
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-gray-300 hover:bg-surface-2/60 flex items-center gap-2">
|
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-fg-secondary hover:bg-surface-2/60 flex items-center gap-2">
|
||||||
<span className="text-gray-500 transition-transform group-open:rotate-90">▶</span>
|
<span className="text-fg-muted transition-transform group-open:rotate-90">▶</span>
|
||||||
<span className="font-semibold uppercase tracking-wide text-gray-400">{title}</span>
|
<span className="font-semibold uppercase tracking-wide text-fg-secondary">{title}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="px-3 pb-3 pt-1">{children}</div>
|
<div className="px-3 pb-3 pt-1">{children}</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -170,8 +167,8 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|||||||
function Field({ label, desc }: { label: string; desc: string }) {
|
function Field({ label, desc }: { label: string; desc: string }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<dt className="font-semibold text-gray-300 whitespace-nowrap">{label}</dt>
|
<dt className="font-semibold text-fg-secondary whitespace-nowrap">{label}</dt>
|
||||||
<dd className="text-gray-400">{desc}</dd>
|
<dd className="text-fg-secondary">{desc}</dd>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `FieldHelpProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `FieldHelpProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `FieldHelp` — exported API; see TSDoc on the symbol for behavior.
|
* - `FieldHelp` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -136,7 +131,7 @@ export function FieldHelp({ title, description, examples, note }: FieldHelpProps
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-fg-muted hover:text-fg-secondary transition-colors"
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-3.5 h-3.5" />
|
<HelpCircle className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -154,11 +149,11 @@ export function FieldHelp({ title, description, examples, note }: FieldHelpProps
|
|||||||
}}
|
}}
|
||||||
className="rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/40 p-3 w-[300px] pointer-events-none"
|
className="rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/40 p-3 w-[300px] pointer-events-none"
|
||||||
>
|
>
|
||||||
{title && <p className="text-xs font-semibold text-gray-200 mb-1">{title}</p>}
|
{title && <p className="text-xs font-semibold text-fg-secondary mb-1">{title}</p>}
|
||||||
<p className="text-[11px] leading-relaxed text-gray-400">{description}</p>
|
<p className="text-[11px] leading-relaxed text-fg-secondary">{description}</p>
|
||||||
{examples && examples.length > 0 && (
|
{examples && examples.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-600 mb-1">
|
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">
|
||||||
{t("examples")}
|
{t("examples")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -173,7 +168,7 @@ export function FieldHelp({ title, description, examples, note }: FieldHelpProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{note && <p className="text-[10px] text-gray-500 mt-2 leading-relaxed">{note}</p>}
|
{note && <p className="text-[10px] text-fg-muted mt-2 leading-relaxed">{note}</p>}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
@@ -285,12 +280,12 @@ export function ImportHistory() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||||
<History className="w-4 h-4 text-gray-500" />
|
<History className="w-4 h-4 text-fg-muted" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mb-1">{t("import.description")}</p>
|
<p className="text-xs text-fg-muted mb-1">{t("import.description")}</p>
|
||||||
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
|
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
|
||||||
|
|
||||||
<div className="card p-5 space-y-5">
|
<div className="card p-5 space-y-5">
|
||||||
{/* Step-by-step instructions */}
|
{/* Step-by-step instructions */}
|
||||||
@@ -299,33 +294,33 @@ export function ImportHistory() {
|
|||||||
onClick={() => setInstructionsOpen((v) => !v)}
|
onClick={() => setInstructionsOpen((v) => !v)}
|
||||||
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
|
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-gray-300 uppercase tracking-wider">
|
<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-400" />
|
<ListChecks className="w-3.5 h-3.5 text-blue-500" />
|
||||||
{t("import.instructions")}
|
{t("import.instructions")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-gray-500">{instructionsOpen ? "▾" : "▸"}</span>
|
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
|
||||||
</button>
|
</button>
|
||||||
{instructionsOpen && (
|
{instructionsOpen && (
|
||||||
<div className="px-4 py-4 space-y-4 text-sm text-gray-300 bg-surface-1">
|
<div className="px-4 py-4 space-y-4 text-sm text-fg-secondary bg-surface-1">
|
||||||
{/* Default location card */}
|
{/* Default location card */}
|
||||||
{guide && (
|
{guide && (
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs bg-surface-2 border border-border rounded-md px-3 py-2">
|
<div className="flex flex-wrap items-center gap-2 text-xs bg-surface-2 border border-border rounded-md px-3 py-2">
|
||||||
<HardDrive className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
<HardDrive className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
<span className="text-gray-400">{t("import.defaultLocation")}:</span>
|
<span className="text-fg-secondary">{t("import.defaultLocation")}:</span>
|
||||||
<code className="font-mono text-gray-200 truncate">
|
<code className="font-mono text-fg-secondary truncate">
|
||||||
{guide.default_projects_dir_display}
|
{guide.default_projects_dir_display}
|
||||||
</code>
|
</code>
|
||||||
{guide.default_projects_dir_exists ? (
|
{guide.default_projects_dir_exists ? (
|
||||||
<span className="inline-flex items-center gap-1 text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
|
<span className="inline-flex items-center gap-1 text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
{t("import.locationFound")}
|
{t("import.locationFound")}
|
||||||
<span className="text-gray-500 ml-1">
|
<span className="text-fg-muted ml-1">
|
||||||
· {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "}
|
· {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "}
|
||||||
{guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")}
|
{guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1 text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full">
|
<span className="inline-flex items-center gap-1 text-status-warning bg-status-warning/10 border border-status-warning/20 px-2 py-0.5 rounded-full">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
{t("import.locationMissing")}
|
{t("import.locationMissing")}
|
||||||
</span>
|
</span>
|
||||||
@@ -339,13 +334,13 @@ export function ImportHistory() {
|
|||||||
<Step title={t("import.stepArchive")} body={t("import.stepArchiveBody")}>
|
<Step title={t("import.stepArchive")} body={t("import.stepArchiveBody")}>
|
||||||
{guide && (
|
{guide && (
|
||||||
<div className="mt-2 flex items-center gap-2 bg-surface-2 border border-border rounded-md px-3 py-2">
|
<div className="mt-2 flex items-center gap-2 bg-surface-2 border border-border rounded-md px-3 py-2">
|
||||||
<Terminal className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
<Terminal className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
<code className="flex-1 text-xs font-mono text-gray-200 truncate">
|
<code className="flex-1 text-xs font-mono text-fg-secondary truncate">
|
||||||
{guide.archive_command}
|
{guide.archive_command}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
onClick={copyArchiveCmd}
|
onClick={copyArchiveCmd}
|
||||||
className="text-xs text-gray-400 hover:text-gray-200 flex items-center gap-1 flex-shrink-0"
|
className="text-xs text-fg-secondary hover:text-fg-primary flex items-center gap-1 flex-shrink-0"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
@@ -364,7 +359,7 @@ export function ImportHistory() {
|
|||||||
<Step title={t("import.stepVerify")} body={t("import.stepVerifyBody")} />
|
<Step title={t("import.stepVerify")} body={t("import.stepVerifyBody")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-gray-500 flex items-start gap-2 pt-2 border-t border-border">
|
<div className="text-[11px] text-fg-muted flex items-start gap-2 pt-2 border-t border-border">
|
||||||
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||||
<span>{t("import.accuracyNote")}</span>
|
<span>{t("import.accuracyNote")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,8 +404,8 @@ export function ImportHistory() {
|
|||||||
{mode === "rescan" && (
|
{mode === "rescan" && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<FolderOpen className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
<FolderOpen className="w-4 h-4 text-fg-muted flex-shrink-0" />
|
||||||
<code className="font-mono text-xs text-gray-300 truncate">
|
<code className="font-mono text-xs text-fg-secondary truncate">
|
||||||
{guide?.default_projects_dir_display || "~/.claude/projects"}
|
{guide?.default_projects_dir_display || "~/.claude/projects"}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,7 +435,7 @@ export function ImportHistory() {
|
|||||||
className="input w-full text-sm font-mono"
|
className="input w-full text-sm font-mono"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-gray-500 mt-1.5">{t("import.folderHelper")}</p>
|
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -475,13 +470,13 @@ export function ImportHistory() {
|
|||||||
}}
|
}}
|
||||||
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
||||||
dragging
|
dragging
|
||||||
? "border-blue-400 bg-blue-500/5"
|
? "border-blue-500 bg-blue-600/5"
|
||||||
: "border-border hover:border-gray-500 bg-surface-1"
|
: "border-border hover:border-border-light bg-surface-1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<UploadCloud className="w-6 h-6 text-gray-500 mx-auto mb-2" />
|
<UploadCloud className="w-6 h-6 text-fg-muted mx-auto mb-2" />
|
||||||
<p className="text-sm text-gray-300">{t("import.dropzoneHint")}</p>
|
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
|
||||||
<p className="text-[11px] text-gray-500 mt-1">{t("import.dropzoneSub")}</p>
|
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -493,17 +488,17 @@ export function ImportHistory() {
|
|||||||
</div>
|
</div>
|
||||||
{files.length > 0 && (
|
{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">
|
<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-gray-400">
|
<span className="text-fg-secondary">
|
||||||
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" />
|
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-fg-muted" />
|
||||||
{t("import.filesSelected", { count: files.length })}
|
{t("import.filesSelected", { count: files.length })}
|
||||||
<span className="text-gray-600 ml-2">({formatBytes(totalSize)})</span>
|
<span className="text-fg-muted ml-2">({formatBytes(totalSize)})</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
}}
|
}}
|
||||||
className="text-gray-500 hover:text-gray-300 text-[11px]"
|
className="text-fg-muted hover:text-fg-secondary text-[11px]"
|
||||||
>
|
>
|
||||||
{t("import.clearSelection")}
|
{t("import.clearSelection")}
|
||||||
</button>
|
</button>
|
||||||
@@ -543,13 +538,13 @@ export function ImportHistory() {
|
|||||||
}}
|
}}
|
||||||
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
||||||
dragging
|
dragging
|
||||||
? "border-blue-400 bg-blue-500/5"
|
? "border-blue-500 bg-blue-600/5"
|
||||||
: "border-border hover:border-gray-500 bg-surface-1"
|
: "border-border hover:border-border-light bg-surface-1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<DatabaseBackup className="w-6 h-6 text-gray-500 mx-auto mb-2" />
|
<DatabaseBackup className="w-6 h-6 text-fg-muted mx-auto mb-2" />
|
||||||
<p className="text-sm text-gray-300">{t("import.backupHint")}</p>
|
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
|
||||||
<p className="text-[11px] text-gray-500 mt-1">{t("import.backupSub")}</p>
|
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
|
||||||
<input
|
<input
|
||||||
ref={backupInputRef}
|
ref={backupInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -560,17 +555,17 @@ export function ImportHistory() {
|
|||||||
</div>
|
</div>
|
||||||
{backupFile && (
|
{backupFile && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
<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-gray-400 min-w-0">
|
<span className="text-fg-secondary min-w-0">
|
||||||
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-gray-500" />
|
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-fg-muted" />
|
||||||
<span className="font-mono truncate">{backupFile.name}</span>
|
<span className="font-mono truncate">{backupFile.name}</span>
|
||||||
<span className="text-gray-600 ml-2">({formatBytes(backupFile.size)})</span>
|
<span className="text-fg-muted ml-2">({formatBytes(backupFile.size)})</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBackupFile(null);
|
setBackupFile(null);
|
||||||
if (backupInputRef.current) backupInputRef.current.value = "";
|
if (backupInputRef.current) backupInputRef.current.value = "";
|
||||||
}}
|
}}
|
||||||
className="text-gray-500 hover:text-gray-300 text-[11px]"
|
className="text-fg-muted hover:text-fg-secondary text-[11px]"
|
||||||
>
|
>
|
||||||
{t("import.clearSelection")}
|
{t("import.clearSelection")}
|
||||||
</button>
|
</button>
|
||||||
@@ -596,11 +591,11 @@ export function ImportHistory() {
|
|||||||
|
|
||||||
{/* In-flight progress */}
|
{/* In-flight progress */}
|
||||||
{running && progressText && (
|
{running && progressText && (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400 bg-surface-2 border border-border rounded-md px-3 py-2">
|
<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-400 flex-shrink-0" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
|
||||||
<span className="truncate">{progressText}</span>
|
<span className="truncate">{progressText}</span>
|
||||||
{progress?.current && (
|
{progress?.current && (
|
||||||
<code className="font-mono text-[11px] text-gray-600 truncate">
|
<code className="font-mono text-[11px] text-fg-muted truncate">
|
||||||
· {progress.current.split("/").slice(-2).join("/")}
|
· {progress.current.split("/").slice(-2).join("/")}
|
||||||
</code>
|
</code>
|
||||||
)}
|
)}
|
||||||
@@ -609,7 +604,7 @@ export function ImportHistory() {
|
|||||||
|
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
{errorMsg && (
|
{errorMsg && (
|
||||||
<div className="flex items-start gap-2 text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
|
<div className="flex items-start gap-2 text-xs text-status-danger bg-status-danger/10 border border-status-danger/20 rounded-md px-3 py-2">
|
||||||
<XCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
<XCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
<span>{errorMsg}</span>
|
<span>{errorMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -617,8 +612,8 @@ export function ImportHistory() {
|
|||||||
|
|
||||||
{/* Result summary */}
|
{/* Result summary */}
|
||||||
{result && !running && (
|
{result && !running && (
|
||||||
<div className="border border-emerald-500/20 bg-emerald-500/5 rounded-lg px-4 py-3 space-y-2">
|
<div className="border border-status-success/20 bg-status-success/5 rounded-lg px-4 py-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider">
|
<div className="flex items-center gap-2 text-xs font-semibold text-status-success uppercase tracking-wider">
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
{t("import.result.title")}
|
{t("import.result.title")}
|
||||||
</div>
|
</div>
|
||||||
@@ -626,26 +621,26 @@ export function ImportHistory() {
|
|||||||
<ResultStat
|
<ResultStat
|
||||||
label={t("import.result.imported", { count: result.imported })}
|
label={t("import.result.imported", { count: result.imported })}
|
||||||
value={result.imported}
|
value={result.imported}
|
||||||
color="text-emerald-300"
|
color="text-status-success"
|
||||||
/>
|
/>
|
||||||
<ResultStat
|
<ResultStat
|
||||||
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
||||||
value={result.backfilled ?? 0}
|
value={result.backfilled ?? 0}
|
||||||
color="text-blue-300"
|
color="text-blue-400"
|
||||||
/>
|
/>
|
||||||
<ResultStat
|
<ResultStat
|
||||||
label={t("import.result.skipped", { count: result.skipped })}
|
label={t("import.result.skipped", { count: result.skipped })}
|
||||||
value={result.skipped}
|
value={result.skipped}
|
||||||
color="text-gray-400"
|
color="text-fg-secondary"
|
||||||
/>
|
/>
|
||||||
<ResultStat
|
<ResultStat
|
||||||
label={t("import.result.errors", { count: result.errors })}
|
label={t("import.result.errors", { count: result.errors })}
|
||||||
value={result.errors}
|
value={result.errors}
|
||||||
color={result.errors > 0 ? "text-red-300" : "text-gray-500"}
|
color={result.errors > 0 ? "text-status-danger" : "text-fg-muted"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{typeof result.files_scanned === "number" && (
|
{typeof result.files_scanned === "number" && (
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-fg-muted">
|
||||||
{t("import.result.filesScanned", { count: result.files_scanned })}
|
{t("import.result.filesScanned", { count: result.files_scanned })}
|
||||||
{result.path ? ` · ${result.path}` : ""}
|
{result.path ? ` · ${result.path}` : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -655,8 +650,8 @@ export function ImportHistory() {
|
|||||||
|
|
||||||
{/* Restore-from-backup result summary */}
|
{/* Restore-from-backup result summary */}
|
||||||
{backupResult && !running && (
|
{backupResult && !running && (
|
||||||
<div className="border border-emerald-500/20 bg-emerald-500/5 rounded-lg px-4 py-3 space-y-2">
|
<div className="border border-status-success/20 bg-status-success/5 rounded-lg px-4 py-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider">
|
<div className="flex items-center gap-2 text-xs font-semibold text-status-success uppercase tracking-wider">
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
{t("import.backupResult.title")}
|
{t("import.backupResult.title")}
|
||||||
</div>
|
</div>
|
||||||
@@ -666,14 +661,14 @@ export function ImportHistory() {
|
|||||||
count: backupResult.sessions_imported,
|
count: backupResult.sessions_imported,
|
||||||
})}
|
})}
|
||||||
value={backupResult.sessions_imported}
|
value={backupResult.sessions_imported}
|
||||||
color="text-emerald-300"
|
color="text-status-success"
|
||||||
/>
|
/>
|
||||||
<ResultStat
|
<ResultStat
|
||||||
label={t("import.backupResult.sessionsSkipped", {
|
label={t("import.backupResult.sessionsSkipped", {
|
||||||
count: backupResult.sessions_skipped,
|
count: backupResult.sessions_skipped,
|
||||||
})}
|
})}
|
||||||
value={backupResult.sessions_skipped}
|
value={backupResult.sessions_skipped}
|
||||||
color="text-gray-400"
|
color="text-fg-secondary"
|
||||||
/>
|
/>
|
||||||
<ResultStat
|
<ResultStat
|
||||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||||
@@ -686,7 +681,7 @@ export function ImportHistory() {
|
|||||||
color="text-cyan-300"
|
color="text-cyan-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-fg-muted">
|
||||||
{t("import.backupResult.detail", {
|
{t("import.backupResult.detail", {
|
||||||
agents: backupResult.agents,
|
agents: backupResult.agents,
|
||||||
workflows: backupResult.workflows,
|
workflows: backupResult.workflows,
|
||||||
@@ -712,8 +707,8 @@ function Step({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-200">{title}</p>
|
<p className="text-sm font-medium text-fg-secondary">{title}</p>
|
||||||
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">{body}</p>
|
<p className="text-xs text-fg-secondary mt-1 whitespace-pre-line">{body}</p>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -737,19 +732,19 @@ function ModeButton({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||||
active
|
active
|
||||||
? "border-blue-500/40 bg-blue-500/10"
|
? "border-blue-600/40 bg-blue-600/10"
|
||||||
: "border-border bg-surface-2 hover:bg-surface-3"
|
: "border-border bg-surface-2 hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
||||||
active ? "text-blue-300" : "text-gray-300"
|
active ? "text-blue-400" : "text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-500 leading-snug">{desc}</p>
|
<p className="text-[11px] text-fg-muted leading-snug">{desc}</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -758,7 +753,7 @@ function ResultStat({ label, value, color }: { label: string; value: number; col
|
|||||||
return (
|
return (
|
||||||
<div className="bg-surface-2 rounded-md px-2.5 py-2">
|
<div className="bg-surface-2 rounded-md px-2.5 py-2">
|
||||||
<p className={`text-sm font-semibold ${color}`}>{value.toLocaleString()}</p>
|
<p className={`text-sm font-semibold ${color}`}>{value.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-gray-500 uppercase tracking-wider mt-0.5">{label}</p>
|
<p className="text-[10px] text-fg-muted uppercase tracking-wider mt-0.5">{label}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Sidebar`
|
* - `./Sidebar`
|
||||||
* - `./UpdateNotifier`
|
* - `./UpdateNotifier`
|
||||||
|
|||||||
@@ -30,11 +30,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
@@ -101,14 +96,17 @@ const EMPTY_FORM: RemoteSourceInput = {
|
|||||||
/** Compact status pill for a source's last-known sync state. */
|
/** Compact status pill for a source's last-known sync state. */
|
||||||
function StatusPill({ status }: { status: RemoteSource["status"] }) {
|
function StatusPill({ status }: { status: RemoteSource["status"] }) {
|
||||||
const map: Record<RemoteSource["status"], { cls: string; label: string; pulse?: boolean }> = {
|
const map: Record<RemoteSource["status"], { cls: string; label: string; pulse?: boolean }> = {
|
||||||
idle: { cls: "text-gray-400 bg-gray-500/10 border-gray-500/20", label: "Idle" },
|
idle: { cls: "text-fg-secondary bg-surface-4/10 border-border-light/20", label: "Idle" },
|
||||||
syncing: {
|
syncing: {
|
||||||
cls: "text-amber-300 bg-amber-500/10 border-amber-500/25",
|
cls: "text-status-warning bg-status-warning/10 border-status-warning/25",
|
||||||
label: "Syncing",
|
label: "Syncing",
|
||||||
pulse: true,
|
pulse: true,
|
||||||
},
|
},
|
||||||
ok: { cls: "text-emerald-300 bg-emerald-500/10 border-emerald-500/25", label: "OK" },
|
ok: { cls: "text-status-success bg-status-success/10 border-status-success/25", label: "OK" },
|
||||||
error: { cls: "text-red-300 bg-red-500/10 border-red-500/25", label: "Error" },
|
error: {
|
||||||
|
cls: "text-status-danger bg-status-danger/10 border-status-danger/25",
|
||||||
|
label: "Error",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const s = map[status] || map.idle;
|
const s = map[status] || map.idle;
|
||||||
return (
|
return (
|
||||||
@@ -318,17 +316,17 @@ export function RemoteSources() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||||
<Cloud className="w-4 h-4 text-gray-500" />
|
<Cloud className="w-4 h-4 text-fg-muted" />
|
||||||
{t("remoteSources.title", "Remote Data Sources")}
|
{t("remoteSources.title", "Remote Data Sources")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-fg-muted mb-4">
|
||||||
{t(
|
{t(
|
||||||
"remoteSources.description",
|
"remoteSources.description",
|
||||||
"Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here."
|
"Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">
|
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">
|
||||||
{t(
|
{t(
|
||||||
"cursorPathsNote",
|
"cursorPathsNote",
|
||||||
"Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)."
|
"Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)."
|
||||||
@@ -339,11 +337,11 @@ export function RemoteSources() {
|
|||||||
<div className="card p-5 mb-4">
|
<div className="card p-5 mb-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Wifi className="w-4 h-4 text-accent" />
|
<Wifi className="w-4 h-4 text-accent" />
|
||||||
<span className="text-sm font-medium text-gray-200">
|
<span className="text-sm font-medium text-fg-secondary">
|
||||||
{t("remoteSources.scopeTitle", "Data scope")}
|
{t("remoteSources.scopeTitle", "Data scope")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
<p className="text-xs text-fg-muted mb-3">
|
||||||
{t(
|
{t(
|
||||||
"remoteSources.scopeDesc",
|
"remoteSources.scopeDesc",
|
||||||
"Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost."
|
"Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost."
|
||||||
@@ -399,13 +397,13 @@ export function RemoteSources() {
|
|||||||
{active && (
|
{active && (
|
||||||
<CheckCircle className="w-4 h-4 text-accent absolute top-2.5 right-2.5" />
|
<CheckCircle className="w-4 h-4 text-accent absolute top-2.5 right-2.5" />
|
||||||
)}
|
)}
|
||||||
<Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-gray-400"}`} />
|
<Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-fg-secondary"}`} />
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-medium ${active ? "text-gray-100" : "text-gray-300"}`}
|
className={`text-sm font-medium ${active ? "text-fg-primary" : "text-fg-secondary"}`}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-gray-500 mt-0.5 leading-snug">{desc}</div>
|
<div className="text-[11px] text-fg-muted mt-0.5 leading-snug">{desc}</div>
|
||||||
{mode === "selected" && scope.mode === "selected" && (
|
{mode === "selected" && scope.mode === "selected" && (
|
||||||
<div className="text-[11px] text-accent mt-1">
|
<div className="text-[11px] text-accent mt-1">
|
||||||
{t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", {
|
{t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", {
|
||||||
@@ -420,7 +418,7 @@ export function RemoteSources() {
|
|||||||
</div>
|
</div>
|
||||||
{scope.mode === "selected" && (
|
{scope.mode === "selected" && (
|
||||||
<div className="mt-3 pt-3 border-t border-border">
|
<div className="mt-3 pt-3 border-t border-border">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
<div className="text-[11px] uppercase tracking-wider text-fg-muted mb-2">
|
||||||
{t("remoteSources.scopePickMachines", "Machines to include")}
|
{t("remoteSources.scopePickMachines", "Machines to include")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -434,7 +432,7 @@ export function RemoteSources() {
|
|||||||
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
||||||
on
|
on
|
||||||
? "bg-accent/15 border-accent/40 text-accent"
|
? "bg-accent/15 border-accent/40 text-accent"
|
||||||
: "bg-surface-2 border-border text-gray-400 hover:text-gray-200"
|
: "bg-surface-2 border-border text-fg-secondary hover:text-fg-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{on ? <Check className="w-3 h-3" /> : <Server className="w-3 h-3" />}
|
{on ? <Check className="w-3 h-3" /> : <Server className="w-3 h-3" />}
|
||||||
@@ -449,7 +447,7 @@ export function RemoteSources() {
|
|||||||
|
|
||||||
{/* Sources list header + add button */}
|
{/* Sources list header + add button */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
<span className="text-xs font-medium text-fg-secondary uppercase tracking-wider">
|
||||||
{t("remoteSources.listTitle", "Configured sources")}
|
{t("remoteSources.listTitle", "Configured sources")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -473,14 +471,14 @@ export function RemoteSources() {
|
|||||||
{/* Add/Edit form */}
|
{/* Add/Edit form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="card p-5 mb-4 space-y-3 border-accent/30">
|
<div className="card p-5 mb-4 space-y-3 border-accent/30">
|
||||||
<div className="text-sm font-medium text-gray-200">
|
<div className="text-sm font-medium text-fg-secondary">
|
||||||
{editingId
|
{editingId
|
||||||
? t("remoteSources.editTitle", "Edit source")
|
? t("remoteSources.editTitle", "Edit source")
|
||||||
: t("remoteSources.addTitle", "Add a remote source")}
|
: t("remoteSources.addTitle", "Add a remote source")}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">
|
<label className="block text-xs text-fg-muted mb-1">
|
||||||
{t("remoteSources.fieldLabel", "Label")} *
|
{t("remoteSources.fieldLabel", "Label")} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -491,7 +489,7 @@ export function RemoteSources() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">
|
<label className="block text-xs text-fg-muted mb-1">
|
||||||
{t("remoteSources.fieldHost", "SSH host")} *
|
{t("remoteSources.fieldHost", "SSH host")} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -502,7 +500,7 @@ export function RemoteSources() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">
|
<label className="block text-xs text-fg-muted mb-1">
|
||||||
{t("remoteSources.fieldPort", "Port (optional)")}
|
{t("remoteSources.fieldPort", "Port (optional)")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -519,7 +517,7 @@ export function RemoteSources() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">
|
<label className="block text-xs text-fg-muted mb-1">
|
||||||
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
|
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -530,7 +528,7 @@ export function RemoteSources() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-xs text-gray-500 mb-1">
|
<label className="block text-xs text-fg-muted mb-1">
|
||||||
{t("remoteSources.fieldRemoteHome", "Remote Claude home (optional)")}
|
{t("remoteSources.fieldRemoteHome", "Remote Claude home (optional)")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -539,7 +537,7 @@ export function RemoteSources() {
|
|||||||
value={form.remote_home ?? ""}
|
value={form.remote_home ?? ""}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, remote_home: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, remote_home: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[11px] text-gray-500 leading-snug">
|
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
|
||||||
{t(
|
{t(
|
||||||
"remoteSources.fieldRemoteHomeHint",
|
"remoteSources.fieldRemoteHomeHint",
|
||||||
"Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude."
|
"Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude."
|
||||||
@@ -554,12 +552,12 @@ export function RemoteSources() {
|
|||||||
checked={!!form.enabled}
|
checked={!!form.enabled}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-300">
|
<span className="text-sm text-fg-secondary">
|
||||||
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="text-xs text-red-300 bg-red-500/10 border border-red-500/25 rounded-lg px-3 py-2">
|
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
|
||||||
{formError}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -585,14 +583,14 @@ export function RemoteSources() {
|
|||||||
|
|
||||||
{/* Sources list */}
|
{/* Sources list */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-xs text-gray-500">{t("common:loading", "Loading…")}</div>
|
<div className="text-xs text-fg-muted">{t("common:loading", "Loading…")}</div>
|
||||||
) : sources.length === 0 ? (
|
) : sources.length === 0 ? (
|
||||||
<div className="card p-6 text-center">
|
<div className="card p-6 text-center">
|
||||||
<Server className="w-6 h-6 text-gray-600 mx-auto mb-2" />
|
<Server className="w-6 h-6 text-fg-muted mx-auto mb-2" />
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-fg-secondary">
|
||||||
{t("remoteSources.empty", "No remote sources yet.")}
|
{t("remoteSources.empty", "No remote sources yet.")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-600 mt-1">
|
<p className="text-xs text-fg-muted mt-1">
|
||||||
{t(
|
{t(
|
||||||
"remoteSources.emptyHint",
|
"remoteSources.emptyHint",
|
||||||
"Add a machine you reach over SSH to pull its Claude Code usage in."
|
"Add a machine you reach over SSH to pull its Claude Code usage in."
|
||||||
@@ -610,16 +608,16 @@ export function RemoteSources() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Server className="w-4 h-4 text-accent shrink-0" />
|
<Server className="w-4 h-4 text-accent shrink-0" />
|
||||||
<span className="text-sm font-medium text-gray-200">{s.label}</span>
|
<span className="text-sm font-medium text-fg-secondary">{s.label}</span>
|
||||||
<StatusPill status={s.status} />
|
<StatusPill status={s.status} />
|
||||||
{!s.enabled && (
|
{!s.enabled && (
|
||||||
<span className="text-[10px] text-gray-500 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full">
|
<span className="text-[10px] text-fg-muted bg-surface-2 border border-border px-1.5 py-0.5 rounded-full">
|
||||||
{t("remoteSources.paused", "Auto-sync off")}
|
{t("remoteSources.paused", "Auto-sync off")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{s.session_count != null && s.session_count > 0 && (
|
{s.session_count != null && s.session_count > 0 && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] text-gray-400 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"
|
className="text-[10px] text-fg-secondary bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"
|
||||||
title={t(
|
title={t(
|
||||||
"remoteSources.sessionCountHint",
|
"remoteSources.sessionCountHint",
|
||||||
"Sessions linked to this source (not every session visible when data scope is All)"
|
"Sessions linked to this source (not every session visible when data scope is All)"
|
||||||
@@ -631,12 +629,12 @@ export function RemoteSources() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-gray-500 font-mono mt-1 truncate">
|
<div className="text-[11px] text-fg-muted font-mono mt-1 truncate">
|
||||||
{s.host}
|
{s.host}
|
||||||
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
||||||
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-gray-600 mt-1">
|
<div className="text-[11px] text-fg-muted mt-1">
|
||||||
{s.last_sync_at
|
{s.last_sync_at
|
||||||
? t("remoteSources.lastSync", "Last sync: {{when}}", {
|
? t("remoteSources.lastSync", "Last sync: {{when}}", {
|
||||||
when: new Date(s.last_sync_at).toLocaleString(),
|
when: new Date(s.last_sync_at).toLocaleString(),
|
||||||
@@ -652,14 +650,14 @@ export function RemoteSources() {
|
|||||||
})}`}
|
})}`}
|
||||||
</div>
|
</div>
|
||||||
{s.status === "error" && s.last_error && (
|
{s.status === "error" && s.last_error && (
|
||||||
<div className="text-[11px] text-red-300 mt-1 break-words">
|
<div className="text-[11px] text-status-danger mt-1 break-words">
|
||||||
{s.last_error}
|
{s.last_error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{test && test.message && (
|
{test && test.message && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-start gap-1.5 text-[11px] mt-2 ${
|
className={`flex items-start gap-1.5 text-[11px] mt-2 ${
|
||||||
test.ok ? "text-emerald-300" : "text-red-300"
|
test.ok ? "text-status-success" : "text-status-danger"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{test.ok ? (
|
{test.ok ? (
|
||||||
@@ -712,7 +710,7 @@ export function RemoteSources() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmDelete({ id: s.id, purge: false })}
|
onClick={() => setConfirmDelete({ id: s.id, purge: false })}
|
||||||
className="btn-ghost text-xs text-red-300 hover:text-red-200"
|
className="btn-ghost text-xs text-status-danger hover:text-status-danger"
|
||||||
title={t("common:delete", "Delete")}
|
title={t("common:delete", "Delete")}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
@@ -723,7 +721,7 @@ export function RemoteSources() {
|
|||||||
{/* Inline delete confirmation */}
|
{/* Inline delete confirmation */}
|
||||||
{confirmDelete?.id === s.id && (
|
{confirmDelete?.id === s.id && (
|
||||||
<div className="mt-3 pt-3 border-t border-border">
|
<div className="mt-3 pt-3 border-t border-border">
|
||||||
<p className="text-xs text-gray-300 mb-2">
|
<p className="text-xs text-fg-secondary mb-2">
|
||||||
{t("remoteSources.confirmDelete", "Remove this source?")}
|
{t("remoteSources.confirmDelete", "Remove this source?")}
|
||||||
</p>
|
</p>
|
||||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
||||||
@@ -735,7 +733,7 @@ export function RemoteSources() {
|
|||||||
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-fg-secondary">
|
||||||
{t(
|
{t(
|
||||||
"remoteSources.purgeData",
|
"remoteSources.purgeData",
|
||||||
"Also delete the sessions imported from this source (cannot be undone)"
|
"Also delete the sessions imported from this source (cannot be undone)"
|
||||||
@@ -746,7 +744,7 @@ export function RemoteSources() {
|
|||||||
<button
|
<button
|
||||||
onClick={doDelete}
|
onClick={doDelete}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="btn-primary text-xs bg-red-600 hover:bg-red-500 disabled:opacity-40"
|
className="btn-primary text-xs bg-status-danger hover:bg-status-danger disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `SelectOption` — exported API; see TSDoc on the symbol for behavior.
|
* - `SelectOption` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `SelectProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `SelectProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -179,10 +174,10 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
onKeyDown={onKey}
|
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-gray-100 focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
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"
|
||||||
>
|
>
|
||||||
<span className="truncate">{current?.label ?? "-"}</span>
|
<span className="truncate">{current?.label ?? "-"}</span>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
@@ -207,7 +202,7 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] flex-1 truncate ${
|
className={`text-[11px] flex-1 truncate ${
|
||||||
isSelected ? "text-accent font-medium" : "text-gray-200"
|
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -215,7 +210,7 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
|||||||
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
|
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
{opt.hint && (
|
{opt.hint && (
|
||||||
<div className="text-[10px] text-gray-500 truncate mt-0.5">{opt.hint}</div>
|
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./StatusBadge`
|
* - `./StatusBadge`
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
@@ -113,8 +108,8 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
|
|||||||
<FolderOpen className="w-3.5 h-3.5" />
|
<FolderOpen className="w-3.5 h-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 overflow-hidden">
|
<div className="min-w-0 overflow-hidden">
|
||||||
<p className="text-sm font-medium text-gray-200 truncate">{title}</p>
|
<p className="text-sm font-medium text-fg-secondary truncate">{title}</p>
|
||||||
<p className="text-[11px] text-gray-500 font-mono truncate">
|
<p className="text-[11px] text-fg-muted font-mono truncate">
|
||||||
{session.id.slice(0, 12)}
|
{session.id.slice(0, 12)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,12 +120,12 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session.cwd && (
|
{session.cwd && (
|
||||||
<p className="text-xs text-gray-400 mb-3 truncate font-mono leading-relaxed">
|
<p className="text-xs text-fg-secondary mb-3 truncate font-mono leading-relaxed">
|
||||||
{session.cwd}
|
{session.cwd}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-[11px] text-gray-500 min-w-0 overflow-hidden flex-wrap">
|
<div className="flex items-center gap-3 text-[11px] text-fg-muted min-w-0 overflow-hidden flex-wrap">
|
||||||
<span className="flex items-center gap-1 flex-shrink-0">
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
<Bot className="w-3 h-3" />
|
<Bot className="w-3 h-3" />
|
||||||
{t("session.agentSummary", { count: agentCount })}
|
{t("session.agentSummary", { count: agentCount })}
|
||||||
@@ -153,7 +148,7 @@ export function SessionCard({ session, onClick }: SessionCardProps) {
|
|||||||
? `${t("ran")}${formatDuration(session.started_at, session.ended_at)}`
|
? `${t("ran")}${formatDuration(session.started_at, session.ended_at)}`
|
||||||
: `${t("running")}${formatDuration(session.started_at, new Date().toISOString())}`}
|
: `${t("running")}${formatDuration(session.started_at, new Date().toISOString())}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600 flex-shrink-0 ml-auto">
|
<span className="text-fg-muted flex-shrink-0 ml-auto">
|
||||||
{timeAgo(session.ended_at || lastActivity)}
|
{timeAgo(session.ended_at || lastActivity)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
@@ -100,35 +95,35 @@ function StatTile({
|
|||||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||||
}) {
|
}) {
|
||||||
const palette = {
|
const palette = {
|
||||||
default: "border-surface-3 bg-surface-2 text-gray-200",
|
default: "border-surface-3 bg-surface-2 text-fg-secondary",
|
||||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
|
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
|
||||||
emerald: "border-emerald-500/20 bg-emerald-500/5 text-emerald-200",
|
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
|
||||||
amber: "border-amber-500/20 bg-amber-500/5 text-amber-200",
|
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
|
||||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
|
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
|
||||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
|
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
|
||||||
blue: "border-blue-500/20 bg-blue-500/5 text-blue-200",
|
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
|
||||||
}[tone];
|
}[tone];
|
||||||
|
|
||||||
const iconTone = {
|
const iconTone = {
|
||||||
default: "text-gray-500",
|
default: "text-fg-muted",
|
||||||
violet: "text-violet-400",
|
violet: "text-violet-400",
|
||||||
emerald: "text-emerald-400",
|
emerald: "text-status-success",
|
||||||
amber: "text-amber-400",
|
amber: "text-status-warning",
|
||||||
rose: "text-rose-400",
|
rose: "text-rose-400",
|
||||||
cyan: "text-cyan-400",
|
cyan: "text-cyan-400",
|
||||||
blue: "text-blue-400",
|
blue: "text-blue-500",
|
||||||
}[tone];
|
}[tone];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-gray-500">
|
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
<span className={iconTone}>{icon}</span>
|
<span className={iconTone}>{icon}</span>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 font-mono text-lg font-semibold text-gray-100 leading-tight">
|
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{hint && <div className="text-[10px] text-gray-500 mt-0.5">{hint}</div>}
|
{hint && <div className="text-[10px] text-fg-muted mt-0.5">{hint}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,7 +151,7 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
|||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs text-gray-400 w-14 text-right flex-shrink-0">
|
<span className="font-mono text-xs text-fg-secondary w-14 text-right flex-shrink-0">
|
||||||
{count.toLocaleString()}
|
{count.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,23 +281,26 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
<div className="space-y-5 mb-6">
|
<div className="space-y-5 mb-6">
|
||||||
{/* Active-agent banner - only shows when session is running */}
|
{/* Active-agent banner - only shows when session is running */}
|
||||||
{activeAgent && (
|
{activeAgent && (
|
||||||
<div className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5">
|
<div className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-status-success/20 bg-status-success/5">
|
||||||
<span className="relative flex h-2 w-2 flex-shrink-0">
|
<span className="relative flex h-2 w-2 flex-shrink-0">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60" />
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-success opacity-60" />
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" />
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-status-success" />
|
||||||
</span>
|
</span>
|
||||||
<Bot className="w-3.5 h-3.5 text-emerald-300 flex-shrink-0" />
|
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
|
||||||
<span className="text-xs text-emerald-200 font-medium flex-shrink-0">
|
<span className="text-xs text-status-success font-medium flex-shrink-0">
|
||||||
{activeAgent.name || "Agent"}
|
{activeAgent.name || "Agent"}
|
||||||
</span>
|
</span>
|
||||||
{activeAgent.current_tool && (
|
{activeAgent.current_tool && (
|
||||||
<span className="text-[11px] text-gray-400 font-mono inline-flex items-center gap-1">
|
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
|
||||||
<span className="text-gray-600">running</span>
|
<span className="text-fg-muted">running</span>
|
||||||
<span className="text-emerald-300">{activeAgent.current_tool}</span>
|
<span className="text-status-success">{activeAgent.current_tool}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{activeAgent.task && (
|
{activeAgent.task && (
|
||||||
<span className="text-[11px] text-gray-400 truncate min-w-0" title={activeAgent.task}>
|
<span
|
||||||
|
className="text-[11px] text-fg-secondary truncate min-w-0"
|
||||||
|
title={activeAgent.task}
|
||||||
|
>
|
||||||
· {activeAgent.task}
|
· {activeAgent.task}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -361,16 +359,16 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
{/* Tool usage */}
|
{/* Tool usage */}
|
||||||
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
<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">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5">
|
<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" />
|
<Wrench className="w-3.5 h-3.5 text-violet-400" />
|
||||||
Top tools
|
Top tools
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-gray-500 font-mono">
|
<span className="text-[10px] text-fg-muted font-mono">
|
||||||
{stats.tools_used.length} total
|
{stats.tools_used.length} total
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{stats.tools_used.length === 0 ? (
|
{stats.tools_used.length === 0 ? (
|
||||||
<div className="text-center py-6 text-xs text-gray-500">No tool calls yet.</div>
|
<div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{stats.tools_used.slice(0, 8).map((t) => (
|
{stats.tools_used.slice(0, 8).map((t) => (
|
||||||
@@ -420,26 +418,26 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.5">
|
<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" />
|
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
Subagents
|
Subagents
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-gray-500 font-mono">{totalRuns} runs</span>
|
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="text-center py-6 text-xs text-gray-500">
|
<div className="text-center py-6 text-xs text-fg-muted">
|
||||||
No subagents in this session.
|
No subagents in this session.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{rows.slice(0, 8).map((r) => {
|
{rows.slice(0, 8).map((r) => {
|
||||||
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
|
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
|
||||||
const barClass = r.isCompaction ? "bg-amber-500/60" : "bg-cyan-500/60";
|
const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60";
|
||||||
return (
|
return (
|
||||||
<div key={r.key} className="flex items-center gap-2">
|
<div key={r.key} className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`font-mono text-xs truncate flex-1 min-w-0 ${
|
className={`font-mono text-xs truncate flex-1 min-w-0 ${
|
||||||
r.isCompaction ? "text-amber-300" : "text-gray-300"
|
r.isCompaction ? "text-status-warning" : "text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
title={r.label}
|
title={r.label}
|
||||||
>
|
>
|
||||||
@@ -451,7 +449,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs text-gray-400 w-8 text-right flex-shrink-0">
|
<span className="font-mono text-xs text-fg-secondary w-8 text-right flex-shrink-0">
|
||||||
{r.count}
|
{r.count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,11 +467,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
{totalTokens > 0 && (
|
{totalTokens > 0 && (
|
||||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-1.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-amber-400" />
|
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||||
Token flow
|
Token flow
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-gray-500 font-mono">{fmt(totalTokens)} total</span>
|
<span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
|
||||||
</div>
|
</div>
|
||||||
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
||||||
</div>
|
</div>
|
||||||
@@ -482,8 +480,8 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
{/* Event-type breakdown - secondary, only top 6 */}
|
{/* Event-type breakdown - secondary, only top 6 */}
|
||||||
{stats.events_by_type.length > 0 && (
|
{stats.events_by_type.length > 0 && (
|
||||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.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-gray-400" />
|
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
|
||||||
Event mix
|
Event mix
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -492,9 +490,9 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
key={e.event_type}
|
key={e.event_type}
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] font-mono bg-surface-3/60 border border-surface-3 rounded-md px-2 py-0.5"
|
className="inline-flex items-center gap-1.5 text-[11px] font-mono bg-surface-3/60 border border-surface-3 rounded-md px-2 py-0.5"
|
||||||
>
|
>
|
||||||
<span className="text-gray-400">{e.event_type}</span>
|
<span className="text-fg-secondary">{e.event_type}</span>
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-fg-muted">·</span>
|
||||||
<span className="text-gray-200">{e.count.toLocaleString()}</span>
|
<span className="text-fg-secondary">{e.count.toLocaleString()}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -524,8 +522,8 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
|||||||
key: "input",
|
key: "input",
|
||||||
label: "Input",
|
label: "Input",
|
||||||
value: tokens.input_tokens,
|
value: tokens.input_tokens,
|
||||||
cls: "bg-emerald-500",
|
cls: "bg-status-success",
|
||||||
text: "text-emerald-300",
|
text: "text-status-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "output",
|
key: "output",
|
||||||
@@ -558,11 +556,11 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
|||||||
return (
|
return (
|
||||||
<div key={s.key} className="flex items-center gap-2">
|
<div key={s.key} className="flex items-center gap-2">
|
||||||
<span className={`block w-2 h-2 rounded-full ${s.cls}`} />
|
<span className={`block w-2 h-2 rounded-full ${s.cls}`} />
|
||||||
<span className="text-gray-500 text-[11px]">{s.label}</span>
|
<span className="text-fg-muted text-[11px]">{s.label}</span>
|
||||||
<span className={`font-mono ml-auto ${s.text}`}>
|
<span className={`font-mono ml-auto ${s.text}`}>
|
||||||
{fmt(s.value)}
|
{fmt(s.value)}
|
||||||
{pct > 0 && (
|
{pct > 0 && (
|
||||||
<span className="text-gray-600 text-[10px] ml-1">
|
<span className="text-fg-muted text-[10px] ml-1">
|
||||||
{pct >= 1 ? Math.round(pct) : pct.toFixed(1)}%
|
{pct >= 1 ? Math.round(pct) : pct.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
@@ -79,10 +74,13 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
|
import { useTheme } from "../hooks/useTheme";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||||
@@ -180,6 +178,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { theme, setTheme, toggleTheme } = useTheme();
|
||||||
// Track whether nav items are clipped by overflow so we can render
|
// Track whether nav items are clipped by overflow so we can render
|
||||||
// chevron affordances pointing toward the hidden items. Recomputed on
|
// chevron affordances pointing toward the hidden items. Recomputed on
|
||||||
// scroll, resize, and any structural change (e.g. collapse toggle).
|
// scroll, resize, and any structural change (e.g. collapse toggle).
|
||||||
@@ -376,6 +375,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
const switchLanguageTitle = t("nav:switchLanguage", {
|
const switchLanguageTitle = t("nav:switchLanguage", {
|
||||||
language: t(`nav:languageNames.${nextLanguage}`),
|
language: t(`nav:languageNames.${nextLanguage}`),
|
||||||
});
|
});
|
||||||
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||||
|
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
|
||||||
|
|
||||||
const toggleLang = () => {
|
const toggleLang = () => {
|
||||||
i18n.changeLanguage(nextLanguage);
|
i18n.changeLanguage(nextLanguage);
|
||||||
@@ -401,8 +402,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-sm font-semibold text-gray-100 truncate">{t("nav:brand")}</h1>
|
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
|
||||||
<p className="text-[11px] text-gray-500">{t("nav:brandSub")}</p>
|
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -428,7 +429,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent/10 text-accent border border-accent/20"
|
? "bg-accent/10 text-accent border border-accent/20"
|
||||||
: "text-gray-400 hover:text-gray-200 hover:bg-surface-3 border border-transparent"
|
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -444,7 +445,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => scrollNavBy(-160)}
|
onClick={() => scrollNavBy(-160)}
|
||||||
aria-label={t("nav:scrollUp")}
|
aria-label={t("nav:scrollUp")}
|
||||||
title={t("nav:scrollUp")}
|
title={t("nav:scrollUp")}
|
||||||
className="absolute top-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-gray-300 hover:text-gray-50 hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
|
className="absolute top-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
|
||||||
>
|
>
|
||||||
<ChevronUp className="w-3.5 h-3.5" aria-hidden />
|
<ChevronUp className="w-3.5 h-3.5" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
@@ -455,19 +456,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => scrollNavBy(160)}
|
onClick={() => scrollNavBy(160)}
|
||||||
aria-label={t("nav:scrollDown")}
|
aria-label={t("nav:scrollDown")}
|
||||||
title={t("nav:scrollDown")}
|
title={t("nav:scrollDown")}
|
||||||
className="absolute bottom-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-gray-300 hover:text-gray-50 hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
|
className="absolute bottom-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-3.5 h-3.5" aria-hidden />
|
<ChevronDown className="w-3.5 h-3.5" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language controls */}
|
{/* 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">
|
<div className="px-2 pb-2 flex-shrink-0">
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={toggleLang}
|
onClick={toggleLang}
|
||||||
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-gray-300 hover:bg-surface-3 hover:text-gray-100 transition-colors flex flex-col items-center justify-center gap-0.5"
|
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}
|
title={switchLanguageTitle}
|
||||||
aria-label={switchLanguageTitle}
|
aria-label={switchLanguageTitle}
|
||||||
>
|
>
|
||||||
@@ -476,9 +481,27 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
{t(`nav:languageShort.${currentLanguage}`)}
|
{t(`nav:languageShort.${currentLanguage}`)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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" />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 p-2">
|
<Sun className="w-3.5 h-3.5" />
|
||||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
)}
|
||||||
|
<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")}
|
{t("nav:language")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||||
@@ -494,7 +517,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
|
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
|
||||||
active
|
active
|
||||||
? "bg-accent/20 text-accent border border-accent/30"
|
? "bg-accent/20 text-accent border border-accent/30"
|
||||||
: "bg-surface-1 text-gray-400 border border-border hover:bg-surface-3 hover:text-gray-200"
|
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`nav:languageShort.${language}`)}
|
{t(`nav:languageShort.${language}`)}
|
||||||
@@ -503,6 +526,38 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
@@ -512,8 +567,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
|
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
|
||||||
collapsed
|
collapsed
|
||||||
? "flex items-center justify-center text-gray-400 hover:text-gray-200 hover:bg-surface-3"
|
? "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-gray-300 hover:text-gray-100 hover:bg-surface-3"
|
: "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
title={collapsed ? t("nav:expand") : t("nav:collapse")}
|
title={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||||
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
|
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||||
@@ -551,7 +606,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-2 ${
|
className={`inline-flex items-center gap-2 ${
|
||||||
wsConnected ? "text-emerald-400" : "text-gray-500"
|
wsConnected ? "text-status-success" : "text-fg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{wsConnected ? (
|
{wsConnected ? (
|
||||||
@@ -566,7 +621,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="text-[11px] font-medium text-gray-600">{`v${__APP_VERSION__}`}</span>
|
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -579,15 +634,15 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
aria-label={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 ${
|
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
|
updateAvailable
|
||||||
? "border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10"
|
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||||||
: checkError
|
: checkError
|
||||||
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
||||||
: "border-border text-gray-400 hover:text-gray-200 hover:bg-surface-3"
|
: "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 />
|
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
||||||
{updateAvailable && !checking && (
|
{updateAvailable && !checking && (
|
||||||
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -598,10 +653,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
title={checkTitle}
|
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 ${
|
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
|
updateAvailable
|
||||||
? "border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/10"
|
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||||||
: checkError
|
: checkError
|
||||||
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
||||||
: "border-border text-gray-300 hover:text-gray-100 hover:bg-surface-3"
|
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 truncate">
|
<span className="inline-flex items-center gap-2 truncate">
|
||||||
@@ -612,7 +667,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
|||||||
<span className="font-medium truncate">{checkTitle}</span>
|
<span className="font-medium truncate">{checkTitle}</span>
|
||||||
</span>
|
</span>
|
||||||
{updateAvailable && !checking && (
|
{updateAvailable && !checking && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -741,8 +796,8 @@ function ConnectionStatusModal({
|
|||||||
<div
|
<div
|
||||||
className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0 ${
|
className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0 ${
|
||||||
wsConnected
|
wsConnected
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
|
? "bg-status-success/10 border-status-success/30 text-status-success"
|
||||||
: "bg-surface-3 border-border text-gray-400"
|
: "bg-surface-3 border-border text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{wsConnected ? (
|
{wsConnected ? (
|
||||||
@@ -754,19 +809,19 @@ function ConnectionStatusModal({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2
|
<h2
|
||||||
id="connection-status-title"
|
id="connection-status-title"
|
||||||
className="text-base font-semibold text-gray-50 truncate tracking-tight leading-tight"
|
className="text-base font-semibold text-fg-primary truncate tracking-tight leading-tight"
|
||||||
>
|
>
|
||||||
{t("nav:connectionDetails")}
|
{t("nav:connectionDetails")}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
className={`text-[11px] font-medium inline-flex items-center gap-1.5 leading-tight ${
|
className={`text-[11px] font-medium inline-flex items-center gap-1.5 leading-tight ${
|
||||||
wsConnected ? "text-emerald-400" : "text-gray-500"
|
wsConnected ? "text-status-success" : "text-fg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{wsConnected && (
|
{wsConnected && (
|
||||||
<span className="relative flex w-1.5 h-1.5">
|
<span className="relative flex w-1.5 h-1.5">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-success opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-400" />
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-status-success" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{wsConnected ? t("nav:live") : t("nav:disconnected")}
|
{wsConnected ? t("nav:live") : t("nav:disconnected")}
|
||||||
@@ -777,7 +832,7 @@ function ConnectionStatusModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
aria-label={t("nav:close")}
|
aria-label={t("nav:close")}
|
||||||
className="p-1.5 -m-1 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-surface-4 transition-colors flex-shrink-0"
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -835,7 +890,7 @@ function ConnectionStatusModal({
|
|||||||
{/* Top event types */}
|
{/* Top event types */}
|
||||||
<Section title={t("nav:topEventTypes")} icon={BarChart3}>
|
<Section title={t("nav:topEventTypes")} icon={BarChart3}>
|
||||||
{topTypes.length === 0 ? (
|
{topTypes.length === 0 ? (
|
||||||
<p className="text-xs text-gray-500 italic">{t("nav:noEventsYet")}</p>
|
<p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{topTypes.map(([type, count]) => (
|
{topTypes.map(([type, count]) => (
|
||||||
@@ -848,7 +903,7 @@ function ConnectionStatusModal({
|
|||||||
{/* Recent activity */}
|
{/* Recent activity */}
|
||||||
<Section title={t("nav:recentActivity")} icon={Clock}>
|
<Section title={t("nav:recentActivity")} icon={Clock}>
|
||||||
{recentEvents.length === 0 ? (
|
{recentEvents.length === 0 ? (
|
||||||
<p className="text-xs text-gray-500 italic">{t("nav:noEventsYet")}</p>
|
<p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{recentEvents.map((evt, i) => (
|
{recentEvents.map((evt, i) => (
|
||||||
@@ -856,8 +911,8 @@ function ConnectionStatusModal({
|
|||||||
key={`${evt.at}-${i}`}
|
key={`${evt.at}-${i}`}
|
||||||
className="flex items-center justify-between gap-3 text-[11px] font-mono px-2 py-1 rounded bg-surface-2/50"
|
className="flex items-center justify-between gap-3 text-[11px] font-mono px-2 py-1 rounded bg-surface-2/50"
|
||||||
>
|
>
|
||||||
<span className="text-gray-200 truncate">{evt.type}</span>
|
<span className="text-fg-secondary truncate">{evt.type}</span>
|
||||||
<span className="text-gray-500 flex-shrink-0">{formatRelative(evt.at, t)}</span>
|
<span className="text-fg-muted flex-shrink-0">{formatRelative(evt.at, t)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -866,11 +921,11 @@ function ConnectionStatusModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
|
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
|
||||||
<span className="text-[10px] text-gray-500">{t("nav:statsPersisted")}</span>
|
<span className="text-[10px] text-fg-muted">{t("nav:statsPersisted")}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onResetStats}
|
onClick={onResetStats}
|
||||||
className="text-[11px] font-medium text-gray-400 hover:text-gray-100 hover:bg-surface-3 px-2 py-1 rounded transition-colors"
|
className="text-[11px] font-medium text-fg-secondary hover:text-fg-primary hover:bg-surface-3 px-2 py-1 rounded transition-colors"
|
||||||
>
|
>
|
||||||
{t("nav:resetStats")}
|
{t("nav:resetStats")}
|
||||||
</button>
|
</button>
|
||||||
@@ -896,7 +951,7 @@ function Section({
|
|||||||
<span className="w-5 h-5 rounded-md bg-accent/15 border border-accent/25 flex items-center justify-center flex-shrink-0">
|
<span className="w-5 h-5 rounded-md bg-accent/15 border border-accent/25 flex items-center justify-center flex-shrink-0">
|
||||||
<Icon className="w-3 h-3 text-accent" aria-hidden />
|
<Icon className="w-3 h-3 text-accent" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-[13px] font-semibold text-gray-100 tracking-tight">{title}</h3>
|
<h3 className="text-[13px] font-semibold text-fg-primary tracking-tight">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
@@ -906,12 +961,12 @@ function Section({
|
|||||||
function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) {
|
function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
|
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
|
||||||
<div className="text-[9px] font-semibold uppercase tracking-wider text-gray-500 truncate">
|
<div className="text-[9px] font-semibold uppercase tracking-wider text-fg-muted truncate">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-baseline gap-1 truncate">
|
<div className="mt-0.5 flex items-baseline gap-1 truncate">
|
||||||
<span className="text-base font-semibold text-gray-100 font-mono">{value}</span>
|
<span className="text-base font-semibold text-fg-primary font-mono">{value}</span>
|
||||||
<span className="text-[10px] font-medium text-gray-500 truncate">{unit}</span>
|
<span className="text-[10px] font-medium text-fg-muted truncate">{unit}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -920,10 +975,10 @@ function KpiTile({ label, value, unit }: { label: string; value: string; unit: s
|
|||||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-3 text-xs">
|
<div className="flex items-start justify-between gap-3 text-xs">
|
||||||
<span className="text-gray-500 font-medium uppercase tracking-wider text-[10px] pt-0.5">
|
<span className="text-fg-muted font-medium uppercase tracking-wider text-[10px] pt-0.5">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-gray-200 text-right break-all min-w-0 ${mono ? "font-mono" : ""}`}>
|
<span className={`text-fg-secondary text-right break-all min-w-0 ${mono ? "font-mono" : ""}`}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -946,8 +1001,8 @@ function TypeBar({
|
|||||||
return (
|
return (
|
||||||
<div className="text-[11px]">
|
<div className="text-[11px]">
|
||||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||||
<span className="font-mono text-gray-200 truncate">{type}</span>
|
<span className="font-mono text-fg-secondary truncate">{type}</span>
|
||||||
<span className="text-gray-500 flex-shrink-0 font-mono">
|
<span className="text-fg-muted flex-shrink-0 font-mono">
|
||||||
{count} · {sharePct}%
|
{count} · {sharePct}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1005,7 +1060,7 @@ function Sparkline({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
<div className="flex items-center justify-between mt-1.5 text-[10px] text-gray-500 font-mono">
|
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
|
||||||
<span>−60s</span>
|
<span>−60s</span>
|
||||||
<span>{avgLabel}</span>
|
<span>{avgLabel}</span>
|
||||||
<span>{"now"}</span>
|
<span>{"now"}</span>
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -27,11 +27,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `SplashScreen` — exported API; see TSDoc on the symbol for behavior.
|
* - `SplashScreen` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -28,11 +28,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Tip`
|
* - `./Tip`
|
||||||
* - `./Skeleton`
|
* - `./Skeleton`
|
||||||
@@ -98,7 +93,7 @@ export function StatCard({
|
|||||||
return (
|
return (
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider truncate">
|
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||||
@@ -108,11 +103,11 @@ export function StatCard({
|
|||||||
<StatValueSkeleton />
|
<StatValueSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<Tip raw={raw}>
|
<Tip raw={raw}>
|
||||||
<span className="text-2xl font-semibold text-gray-100 truncate">{value}</span>
|
<span className="text-2xl font-semibold text-fg-primary truncate">{value}</span>
|
||||||
</Tip>
|
</Tip>
|
||||||
)}
|
)}
|
||||||
{!loading && trend && (
|
{!loading && trend && (
|
||||||
<span className="text-xs text-gray-500 mb-1 flex-shrink-0">{trend}</span>
|
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
* - `./Tip`
|
* - `./Tip`
|
||||||
@@ -96,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
||||||
cfg.urgent
|
cfg.urgent
|
||||||
? "bg-amber-500/15 border-amber-500/25 text-amber-300"
|
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
|
||||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
|
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,11 +26,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `SpeechBubble` — exported API; see TSDoc on the symbol for behavior.
|
* - `SpeechBubble` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./CatAvatar`
|
* - `./CatAvatar`
|
||||||
* - `./SpeechBubble`
|
* - `./SpeechBubble`
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
@@ -113,15 +108,17 @@ export function TabbyPanel({
|
|||||||
<span className="text-base leading-none" aria-hidden>
|
<span className="text-base leading-none" aria-hidden>
|
||||||
🐾
|
🐾
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-100">Tabby</span>
|
<span className="text-sm font-semibold text-fg-primary">Tabby</span>
|
||||||
<span
|
<span
|
||||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
status.connected ? "bg-emerald-500/15 text-emerald-300" : "bg-red-500/15 text-red-300"
|
status.connected
|
||||||
|
? "bg-status-success/15 text-status-success"
|
||||||
|
: "bg-status-danger/15 text-status-danger"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
status.connected ? "bg-emerald-400" : "bg-red-500"
|
status.connected ? "bg-status-success" : "bg-status-danger"
|
||||||
}`}
|
}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
@@ -129,7 +126,7 @@ export function TabbyPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-surface-4 hover:text-gray-200"
|
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close Tabby"
|
aria-label="Close Tabby"
|
||||||
>
|
>
|
||||||
@@ -191,7 +188,7 @@ export function TabbyPanel({
|
|||||||
{/* ask */}
|
{/* ask */}
|
||||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-gray-200 placeholder-gray-500 transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
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 Tabby… (e.g. any errors?)"
|
placeholder="Ask Tabby… (e.g. any errors?)"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
@@ -206,7 +203,7 @@ export function TabbyPanel({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{answer && (
|
{answer && (
|
||||||
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-gray-300">
|
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
|
||||||
{answer}
|
{answer}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -223,18 +220,22 @@ interface Tone {
|
|||||||
|
|
||||||
const TONE_MUTED: Tone = {
|
const TONE_MUTED: Tone = {
|
||||||
wrap: "border-border bg-surface-1",
|
wrap: "border-border bg-surface-1",
|
||||||
value: "text-gray-300",
|
value: "text-fg-secondary",
|
||||||
icon: "text-gray-500",
|
icon: "text-fg-muted",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TONES: Record<string, Tone> = {
|
const TONES: Record<string, Tone> = {
|
||||||
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-gray-100", icon: "text-accent" },
|
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
|
||||||
amber: {
|
amber: {
|
||||||
wrap: "border-amber-500/30 bg-amber-500/10",
|
wrap: "border-status-warning/30 bg-status-warning/10",
|
||||||
value: "text-amber-200",
|
value: "text-status-warning",
|
||||||
icon: "text-amber-400",
|
icon: "text-status-warning",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
wrap: "border-status-danger/30 bg-status-danger/10",
|
||||||
|
value: "text-status-danger",
|
||||||
|
icon: "text-status-danger",
|
||||||
},
|
},
|
||||||
red: { wrap: "border-red-500/30 bg-red-500/10", value: "text-red-200", icon: "text-red-400" },
|
|
||||||
muted: TONE_MUTED,
|
muted: TONE_MUTED,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ function StatChip({
|
|||||||
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
|
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] uppercase tracking-wider text-gray-500">{label}</span>
|
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -274,7 +275,7 @@ function ActionButton({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-gray-300 transition-colors hover:bg-surface-4 hover:text-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -167,26 +167,11 @@ describe("reduceTabby counts and pulses", () => {
|
|||||||
expect(ok.state.worriedUntil).toBe(0);
|
expect(ok.state.worriedUntil).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => {
|
it("run_status updates activity timestamp only (no exit code available)", () => {
|
||||||
const good = reduceTabby(
|
|
||||||
initialTabbyState(T0),
|
|
||||||
runStatusMsg({ status: "completed", exitCode: 0 }),
|
|
||||||
T0
|
|
||||||
);
|
|
||||||
expect(good.pulse).toBe("run_done");
|
|
||||||
expect(deriveMood(good.state, T0)).toBe("happy");
|
|
||||||
const bad = reduceTabby(
|
|
||||||
initialTabbyState(T0),
|
|
||||||
runStatusMsg({ status: "completed", exitCode: 1 }),
|
|
||||||
T0
|
|
||||||
);
|
|
||||||
expect(bad.pulse).toBe("error");
|
|
||||||
const err = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "error" }), T0);
|
|
||||||
expect(err.pulse).toBe("error");
|
|
||||||
const killed = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "killed" }), T0);
|
|
||||||
expect(killed.pulse).toBe("error");
|
|
||||||
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
|
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
|
||||||
expect(running.pulse).toBe(null);
|
expect(running.pulse).toBe(null);
|
||||||
|
const gone = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "gone" }), T0);
|
||||||
|
expect(gone.pulse).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("any handled message refreshes lastActivityAt", () => {
|
it("any handled message refreshes lastActivityAt", () => {
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -345,25 +340,7 @@ export function reduceTabby(
|
|||||||
case "run_status": {
|
case "run_status": {
|
||||||
const r = msg.data as RunStatusPayload;
|
const r = msg.data as RunStatusPayload;
|
||||||
if (!r) return { state, pulse: null };
|
if (!r) return { state, pulse: null };
|
||||||
// A run that finished cleanly (exit 0, or no exit code reported) → happy.
|
// running / gone → activity only (no exit code to distinguish success/failure).
|
||||||
if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) {
|
|
||||||
return {
|
|
||||||
state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now },
|
|
||||||
pulse: "run_done",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Errored, killed, or completed with a nonzero exit code → worried.
|
|
||||||
if (
|
|
||||||
r.status === "error" ||
|
|
||||||
r.status === "killed" ||
|
|
||||||
(r.status === "completed" && r.exitCode != null && r.exitCode !== 0)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
|
||||||
pulse: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// spawning / running → activity only.
|
|
||||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TabbyPos` — exported API; see TSDoc on the symbol for behavior.
|
* - `TabbyPos` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `tabbyPrefs` — exported API; see TSDoc on the symbol for behavior.
|
* - `tabbyPrefs` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/eventBus`
|
* - `../../lib/eventBus`
|
||||||
* - `../../lib/api`
|
* - `../../lib/api`
|
||||||
|
|||||||
@@ -25,11 +25,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./prefs`
|
* - `./prefs`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -32,11 +32,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Tip` — exported API; see TSDoc on the symbol for behavior.
|
* - `Tip` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
@@ -144,7 +139,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
|
|||||||
<div
|
<div
|
||||||
ref={tipRef}
|
ref={tipRef}
|
||||||
style={tipStyle}
|
style={tipStyle}
|
||||||
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-gray-100 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
|
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"
|
||||||
>
|
>
|
||||||
{raw}
|
{raw}
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -33,11 +33,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
@@ -213,11 +208,11 @@ export function UpdateNotifier() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2
|
<h2
|
||||||
id="update-notifier-title"
|
id="update-notifier-title"
|
||||||
className="text-sm font-semibold text-gray-100 truncate"
|
className="text-sm font-semibold text-fg-primary truncate"
|
||||||
>
|
>
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[11px] text-gray-500 mt-0.5 font-mono truncate">
|
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
||||||
{t("commitsBehind", { count: behind, ref: refLabel })}
|
{t("commitsBehind", { count: behind, ref: refLabel })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +221,7 @@ export function UpdateNotifier() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
aria-label={t("dismiss")}
|
aria-label={t("dismiss")}
|
||||||
className="p-1.5 -m-1 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-surface-4 transition-colors flex-shrink-0"
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -234,29 +229,29 @@ export function UpdateNotifier() {
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-5 py-4 space-y-3">
|
<div className="px-5 py-4 space-y-3">
|
||||||
<p className="text-sm text-gray-300 leading-relaxed">{t("lead")}</p>
|
<p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
|
||||||
|
|
||||||
{status.fetch_error ? (
|
{status.fetch_error ? (
|
||||||
<div className="text-xs text-amber-300/90 bg-amber-500/5 border border-amber-500/20 rounded-lg px-3 py-2">
|
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
|
||||||
{t("fetchError")}
|
{t("fetchError")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!status.git_repo ? (
|
{!status.git_repo ? (
|
||||||
<div className="text-xs text-gray-400 bg-surface-2 border border-border rounded-lg px-3 py-2">
|
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||||
{t("notGit")}
|
{t("notGit")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{status.situation_note ? (
|
{status.situation_note ? (
|
||||||
<div className="text-xs text-amber-200/90 bg-amber-500/5 border border-amber-500/20 rounded-lg px-3 py-2 leading-relaxed">
|
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2 leading-relaxed">
|
||||||
{status.situation_note}
|
{status.situation_note}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{status.manual_command ? (
|
{status.manual_command ? (
|
||||||
<pre
|
<pre
|
||||||
className="bg-surface-1 border border-border rounded-lg px-3 py-2.5 text-[11px] font-mono text-gray-200 whitespace-pre-wrap break-all leading-relaxed"
|
className="bg-surface-1 border border-border rounded-lg px-3 py-2.5 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-all leading-relaxed"
|
||||||
aria-label={t("commandLabel")}
|
aria-label={t("commandLabel")}
|
||||||
>
|
>
|
||||||
{status.manual_command}
|
{status.manual_command}
|
||||||
@@ -268,11 +263,11 @@ export function UpdateNotifier() {
|
|||||||
* are fetch-only - restarting the dashboard would change nothing. */}
|
* are fetch-only - restarting the dashboard would change nothing. */}
|
||||||
{status.situation === "tracking_canonical" ||
|
{status.situation === "tracking_canonical" ||
|
||||||
status.situation === "fork_or_diverged_tracking" ? (
|
status.situation === "fork_or_diverged_tracking" ? (
|
||||||
<p className="text-[11px] text-gray-500 leading-relaxed">{t("restartNote")}</p>
|
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-xs text-red-400" role="alert">
|
<p className="text-xs text-status-danger" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -27,11 +27,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `./Select`
|
* - `./Select`
|
||||||
@@ -110,7 +105,7 @@ const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
|
|||||||
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
|
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
|
||||||
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
|
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
|
||||||
};
|
};
|
||||||
const NEUTRAL_STYLE = "text-gray-300 bg-surface-2 border-border";
|
const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border";
|
||||||
|
|
||||||
interface HeaderRow {
|
interface HeaderRow {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -155,7 +150,7 @@ function Toggle({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||||
checked ? "bg-blue-500" : "bg-surface-4"
|
checked ? "bg-blue-600" : "bg-surface-4"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -390,7 +385,7 @@ export function WebhookSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="card p-5 space-y-4">
|
<div className="card p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||||
<Webhook className="w-3.5 h-3.5" />
|
<Webhook className="w-3.5 h-3.5" />
|
||||||
{t("webhooks.count", { count: targets.length })}
|
{t("webhooks.count", { count: targets.length })}
|
||||||
</div>
|
</div>
|
||||||
@@ -408,9 +403,9 @@ export function WebhookSettings() {
|
|||||||
|
|
||||||
{/* Target list */}
|
{/* Target list */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-xs text-gray-500">{t("webhooks.loading")}</p>
|
<p className="text-xs text-fg-muted">{t("webhooks.loading")}</p>
|
||||||
) : targets.length === 0 && !formOpen ? (
|
) : targets.length === 0 && !formOpen ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500 py-2">
|
<div className="flex items-center gap-2 text-xs text-fg-muted py-2">
|
||||||
<Webhook className="w-3.5 h-3.5" />
|
<Webhook className="w-3.5 h-3.5" />
|
||||||
{t("webhooks.empty")}
|
{t("webhooks.empty")}
|
||||||
</div>
|
</div>
|
||||||
@@ -431,12 +426,12 @@ export function WebhookSettings() {
|
|||||||
>
|
>
|
||||||
{labelOf(target.type)}
|
{labelOf(target.type)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-200 font-medium">{target.name}</span>
|
<span className="text-sm text-fg-secondary font-medium">{target.name}</span>
|
||||||
<code className="text-[11px] text-gray-500 font-mono truncate max-w-[220px]">
|
<code className="text-[11px] text-fg-muted font-mono truncate max-w-[220px]">
|
||||||
{target.url_preview}
|
{target.url_preview}
|
||||||
</code>
|
</code>
|
||||||
{target.rule_ids && target.rule_ids.length > 0 && (
|
{target.rule_ids && target.rule_ids.length > 0 && (
|
||||||
<span className="text-[10px] text-amber-400/80">
|
<span className="text-[10px] text-status-warning/80">
|
||||||
{t("webhooks.scopedTo", { count: target.rule_ids.length })}
|
{t("webhooks.scopedTo", { count: target.rule_ids.length })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -446,8 +441,8 @@ export function WebhookSettings() {
|
|||||||
title={target.last_delivery.error || undefined}
|
title={target.last_delivery.error || undefined}
|
||||||
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full ${
|
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||||
target.last_delivery.status === "success"
|
target.last_delivery.status === "success"
|
||||||
? "text-emerald-400 bg-emerald-500/10"
|
? "text-status-success bg-status-success/10"
|
||||||
: "text-red-400 bg-red-500/10"
|
: "text-status-danger bg-status-danger/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{target.last_delivery.status === "success" ? (
|
{target.last_delivery.status === "success" ? (
|
||||||
@@ -470,7 +465,7 @@ export function WebhookSettings() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => onTest(target.id)}
|
onClick={() => onTest(target.id)}
|
||||||
disabled={testing === target.id}
|
disabled={testing === target.id}
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{testing === target.id ? (
|
{testing === target.id ? (
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
@@ -481,14 +476,14 @@ export function WebhookSettings() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(target)}
|
onClick={() => openEdit(target)}
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors"
|
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
{t("webhooks.edit")}
|
{t("webhooks.edit")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmDelete(target.id)}
|
onClick={() => setConfirmDelete(target.id)}
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 border border-border transition-colors"
|
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 border border-border transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
{t("webhooks.delete")}
|
{t("webhooks.delete")}
|
||||||
@@ -496,7 +491,7 @@ export function WebhookSettings() {
|
|||||||
{result && (
|
{result && (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 text-[11px] ${
|
className={`inline-flex items-center gap-1 text-[11px] ${
|
||||||
result.ok ? "text-emerald-400" : "text-red-400"
|
result.ok ? "text-status-success" : "text-status-danger"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{result.ok ? (
|
{result.ok ? (
|
||||||
@@ -522,17 +517,17 @@ export function WebhookSettings() {
|
|||||||
{formOpen && form && provider && (
|
{formOpen && form && provider && (
|
||||||
<div className="border border-border rounded-lg p-4 space-y-3 bg-surface-1">
|
<div className="border border-border rounded-lg p-4 space-y-3 bg-surface-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
|
<h4 className="text-xs font-semibold text-fg-secondary uppercase tracking-wide">
|
||||||
{isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")}
|
{isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")}
|
||||||
</h4>
|
</h4>
|
||||||
<button onClick={closeForm} className="text-gray-500 hover:text-gray-300">
|
<button onClick={closeForm} className="text-fg-muted hover:text-fg-secondary">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldName")}</span>
|
<span className="text-[11px] text-fg-muted">{t("webhooks.fieldName")}</span>
|
||||||
<input
|
<input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => set({ name: e.target.value })}
|
onChange={(e) => set({ name: e.target.value })}
|
||||||
@@ -541,7 +536,7 @@ export function WebhookSettings() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldType")}</span>
|
<span className="text-[11px] text-fg-muted">{t("webhooks.fieldType")}</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Select<WebhookType>
|
<Select<WebhookType>
|
||||||
value={form.type}
|
value={form.type}
|
||||||
@@ -564,12 +559,12 @@ export function WebhookSettings() {
|
|||||||
{/* URL (hidden for providers that derive their own URL) */}
|
{/* URL (hidden for providers that derive their own URL) */}
|
||||||
{showUrl && (
|
{showUrl && (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[11px] text-gray-500">
|
<span className="text-[11px] text-fg-muted">
|
||||||
{t("webhooks.fieldUrl")}
|
{t("webhooks.fieldUrl")}
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
<span className="text-gray-600"> - {t("webhooks.urlKeepHint")}</span>
|
<span className="text-fg-muted"> - {t("webhooks.urlKeepHint")}</span>
|
||||||
) : urlOptional ? (
|
) : urlOptional ? (
|
||||||
<span className="text-gray-600"> - {t("webhooks.urlOptional")}</span>
|
<span className="text-fg-muted"> - {t("webhooks.urlOptional")}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -583,7 +578,7 @@ export function WebhookSettings() {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{!showUrl && (
|
{!showUrl && (
|
||||||
<p className="text-[11px] text-gray-600 flex items-center gap-1.5">
|
<p className="text-[11px] text-fg-muted flex items-center gap-1.5">
|
||||||
<Webhook className="w-3 h-3" />
|
<Webhook className="w-3 h-3" />
|
||||||
{t("webhooks.urlAuto")}
|
{t("webhooks.urlAuto")}
|
||||||
</p>
|
</p>
|
||||||
@@ -594,19 +589,19 @@ export function WebhookSettings() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setGuideOpen((o) => !o)}
|
onClick={() => setGuideOpen((o) => !o)}
|
||||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-gray-300 hover:bg-surface-3 transition-colors"
|
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<BookOpen className="w-3.5 h-3.5 text-gray-500" />
|
<BookOpen className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
{t("webhooks.guideToggle", { provider: provider.label })}
|
{t("webhooks.guideToggle", { provider: provider.label })}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-3.5 h-3.5 text-gray-500 transition-transform ${guideOpen ? "rotate-180" : ""}`}
|
className={`w-3.5 h-3.5 text-fg-muted transition-transform ${guideOpen ? "rotate-180" : ""}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{guideOpen && (
|
{guideOpen && (
|
||||||
<div className="px-3 pb-3 pt-2 space-y-2.5 border-t border-border">
|
<div className="px-3 pb-3 pt-2 space-y-2.5 border-t border-border">
|
||||||
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed text-gray-400 marker:text-gray-600">
|
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed text-fg-secondary marker:text-fg-muted">
|
||||||
{(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map(
|
{(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map(
|
||||||
(s, i) => (
|
(s, i) => (
|
||||||
<li key={i}>{s}</li>
|
<li key={i}>{s}</li>
|
||||||
@@ -624,7 +619,7 @@ export function WebhookSettings() {
|
|||||||
{t("webhooks.guideDocs", { provider: provider.label })}
|
{t("webhooks.guideDocs", { provider: provider.label })}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<p className="flex items-start gap-1.5 text-[10px] text-gray-500 leading-relaxed pt-2 border-t border-border">
|
<p className="flex items-start gap-1.5 text-[10px] text-fg-muted leading-relaxed pt-2 border-t border-border">
|
||||||
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||||
{t("webhooks.guideStaleNote")}
|
{t("webhooks.guideStaleNote")}
|
||||||
</p>
|
</p>
|
||||||
@@ -637,9 +632,9 @@ export function WebhookSettings() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
|
||||||
{provider.fields.map((f) => (
|
{provider.fields.map((f) => (
|
||||||
<label key={f.key} className="block">
|
<label key={f.key} className="block">
|
||||||
<span className="text-[11px] text-gray-500">
|
<span className="text-[11px] text-fg-muted">
|
||||||
{f.label}
|
{f.label}
|
||||||
{f.required && <span className="text-red-400"> *</span>}
|
{f.required && <span className="text-status-danger"> *</span>}
|
||||||
</span>
|
</span>
|
||||||
{f.type === "enum" && f.options ? (
|
{f.type === "enum" && f.options ? (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
@@ -667,7 +662,7 @@ export function WebhookSettings() {
|
|||||||
{provider.supports_secret && (
|
{provider.supports_secret && (
|
||||||
<div className="space-y-3 pt-1 border-t border-border">
|
<div className="space-y-3 pt-1 border-t border-border">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldSecret")}</span>
|
<span className="text-[11px] text-fg-muted">{t("webhooks.fieldSecret")}</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.secret}
|
value={form.secret}
|
||||||
@@ -677,18 +672,18 @@ export function WebhookSettings() {
|
|||||||
}
|
}
|
||||||
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
|
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-gray-600">{t("webhooks.secretHint")}</span>
|
<span className="text-[10px] text-fg-muted">{t("webhooks.secretHint")}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[11px] text-gray-500">{t("webhooks.fieldHeaders")}</span>
|
<span className="text-[11px] text-fg-muted">{t("webhooks.fieldHeaders")}</span>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={form.replaceHeaders}
|
checked={form.replaceHeaders}
|
||||||
onChange={(v) => set({ replaceHeaders: v })}
|
onChange={(v) => set({ replaceHeaders: v })}
|
||||||
label={t("webhooks.replaceHeaders")}
|
label={t("webhooks.replaceHeaders")}
|
||||||
labelClassName="text-[10px] text-gray-500 group-hover:text-gray-400"
|
labelClassName="text-[10px] text-fg-muted group-hover:text-fg-secondary"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -724,7 +719,7 @@ export function WebhookSettings() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
set({ headerRows: form.headerRows.filter((_, j) => j !== i) })
|
set({ headerRows: form.headerRows.filter((_, j) => j !== i) })
|
||||||
}
|
}
|
||||||
className="text-gray-600 hover:text-red-400 p-1"
|
className="text-fg-muted hover:text-status-danger p-1"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -734,7 +729,7 @@ export function WebhookSettings() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
set({ headerRows: [...form.headerRows, { key: "", value: "" }] })
|
set({ headerRows: [...form.headerRows, { key: "", value: "" }] })
|
||||||
}
|
}
|
||||||
className="inline-flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-300"
|
className="inline-flex items-center gap-1 text-[10px] text-fg-muted hover:text-fg-secondary"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
{t("webhooks.addHeader")}
|
{t("webhooks.addHeader")}
|
||||||
@@ -752,7 +747,7 @@ export function WebhookSettings() {
|
|||||||
checked={form.scopeAll}
|
checked={form.scopeAll}
|
||||||
onChange={(v) => set({ scopeAll: v })}
|
onChange={(v) => set({ scopeAll: v })}
|
||||||
label={t("webhooks.scopeAll")}
|
label={t("webhooks.scopeAll")}
|
||||||
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300"
|
labelClassName="text-[11px] text-fg-secondary group-hover:text-fg-secondary"
|
||||||
/>
|
/>
|
||||||
{!form.scopeAll && (
|
{!form.scopeAll && (
|
||||||
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
|
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
|
||||||
@@ -768,7 +763,7 @@ export function WebhookSettings() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
label={rule.name}
|
label={rule.name}
|
||||||
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300 truncate"
|
labelClassName="text-[11px] text-fg-secondary group-hover:text-fg-secondary truncate"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -777,14 +772,14 @@ export function WebhookSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<label className="inline-flex items-center gap-2 text-[11px] text-gray-400 cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-[11px] text-fg-secondary cursor-pointer">
|
||||||
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
||||||
{t("webhooks.enabledOnSave")}
|
{t("webhooks.enabledOnSave")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-red-400">
|
<div className="flex items-center gap-1.5 text-[11px] text-status-danger">
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
{formError}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ describe("AgentCard", () => {
|
|||||||
it("should not render subagent_type when null", () => {
|
it("should not render subagent_type when null", () => {
|
||||||
const { container } = renderCard(<AgentCard agent={makeAgent({ subagent_type: null })} />);
|
const { container } = renderCard(<AgentCard agent={makeAgent({ subagent_type: null })} />);
|
||||||
// Only the name should be in the name container, no subagent type
|
// Only the name should be in the name container, no subagent type
|
||||||
expect(container.querySelectorAll(".text-\\[11px\\].text-gray-500.truncate")).toHaveLength(0);
|
expect(container.querySelectorAll(".text-\\[11px\\].text-fg-muted.truncate")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render task when present", () => {
|
it("should render task when present", () => {
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ describe("StatCard", () => {
|
|||||||
|
|
||||||
it("should apply custom accent color", () => {
|
it("should apply custom accent color", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<StatCard label="Test" value={0} icon={Activity} accentColor="text-emerald-400" />
|
<StatCard label="Test" value={0} icon={Activity} accentColor="text-status-success" />
|
||||||
);
|
);
|
||||||
const svg = container.querySelector("svg");
|
const svg = container.querySelector("svg");
|
||||||
expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-emerald-400");
|
expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-status-success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply default accent color when not specified", () => {
|
it("should apply default accent color when not specified", () => {
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ describe("awaiting-reason suffix", () => {
|
|||||||
|
|
||||||
it("marks urgent reasons with the hotter amber tint", () => {
|
it("marks urgent reasons with the hotter amber tint", () => {
|
||||||
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
|
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
|
||||||
expect(container.querySelector(".text-amber-300")).toBeInTheDocument();
|
expect(container.querySelector(".text-status-warning")).toBeInTheDocument();
|
||||||
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
|
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
|
||||||
expect(calm.querySelector(".text-amber-300")).not.toBeInTheDocument();
|
expect(calm.querySelector(".text-status-warning")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
|
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/highlight`
|
* - `../../lib/highlight`
|
||||||
*
|
*
|
||||||
@@ -142,20 +137,20 @@ export function CodeBlock({
|
|||||||
const palette =
|
const palette =
|
||||||
tone === "danger"
|
tone === "danger"
|
||||||
? {
|
? {
|
||||||
wrapper: "border-red-500/30 bg-red-500/5",
|
wrapper: "border-status-danger/30 bg-status-danger/5",
|
||||||
chrome: "bg-red-500/10 border-b border-red-500/20",
|
chrome: "bg-status-danger/10 border-b border-status-danger/20",
|
||||||
label: "text-red-300",
|
label: "text-status-danger",
|
||||||
}
|
}
|
||||||
: tone === "success"
|
: tone === "success"
|
||||||
? {
|
? {
|
||||||
wrapper: "border-emerald-500/30 bg-emerald-500/5",
|
wrapper: "border-status-success/30 bg-status-success/5",
|
||||||
chrome: "bg-emerald-500/10 border-b border-emerald-500/20",
|
chrome: "bg-status-success/10 border-b border-status-success/20",
|
||||||
label: "text-emerald-300",
|
label: "text-status-success",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
wrapper: "border-surface-3 bg-surface-4/50",
|
wrapper: "border-surface-3 bg-surface-4/50",
|
||||||
chrome: "bg-surface-3/70 border-b border-surface-3",
|
chrome: "bg-surface-3/70 border-b border-surface-3",
|
||||||
label: "text-gray-400",
|
label: "text-fg-secondary",
|
||||||
};
|
};
|
||||||
|
|
||||||
const preStyle: React.CSSProperties = {};
|
const preStyle: React.CSSProperties = {};
|
||||||
@@ -177,7 +172,7 @@ export function CodeBlock({
|
|||||||
|
|
||||||
{/* Filename + lang together when both are set */}
|
{/* Filename + lang together when both are set */}
|
||||||
{filename && !label && (
|
{filename && !label && (
|
||||||
<span className="text-gray-600 font-mono lowercase">{langDisplay(lang)}</span>
|
<span className="text-fg-muted font-mono lowercase">{langDisplay(lang)}</span>
|
||||||
)}
|
)}
|
||||||
{filename && label && (
|
{filename && label && (
|
||||||
<span className={`font-mono uppercase tracking-wider ${palette.label}`}>· {label}</span>
|
<span className={`font-mono uppercase tracking-wider ${palette.label}`}>· {label}</span>
|
||||||
@@ -186,7 +181,7 @@ export function CodeBlock({
|
|||||||
{/* Right side: line count + copy */}
|
{/* Right side: line count + copy */}
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{totalLines > 1 && (
|
{totalLines > 1 && (
|
||||||
<span className="text-gray-600 font-mono">
|
<span className="text-fg-muted font-mono">
|
||||||
{totalLines} {totalLines === 1 ? "line" : "lines"}
|
{totalLines} {totalLines === 1 ? "line" : "lines"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -194,7 +189,7 @@ export function CodeBlock({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className={`inline-flex items-center gap-1 transition-colors ${
|
className={`inline-flex items-center gap-1 transition-colors ${
|
||||||
copied ? "text-emerald-300" : "text-gray-500 hover:text-gray-200"
|
copied ? "text-status-success" : "text-fg-muted hover:text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
aria-label="Copy code"
|
aria-label="Copy code"
|
||||||
>
|
>
|
||||||
@@ -221,7 +216,7 @@ export function CodeBlock({
|
|||||||
{lineTokens.map((line, i) => (
|
{lineTokens.map((line, i) => (
|
||||||
<tr key={i} className="align-top">
|
<tr key={i} className="align-top">
|
||||||
<td
|
<td
|
||||||
className="select-none text-right pl-3 pr-3 text-gray-600 font-mono text-[11px] leading-[1.6] sticky left-0 bg-inherit"
|
className="select-none text-right pl-3 pr-3 text-fg-muted font-mono text-[11px] leading-[1.6] sticky left-0 bg-inherit"
|
||||||
style={{ width: "1%", whiteSpace: "nowrap" }}
|
style={{ width: "1%", whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
|
|||||||
@@ -25,11 +25,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/api`
|
* - `../../lib/api`
|
||||||
* - `../../lib/eventBus`
|
* - `../../lib/eventBus`
|
||||||
@@ -399,7 +394,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
<select
|
<select
|
||||||
value={selectedTranscript || ""}
|
value={selectedTranscript || ""}
|
||||||
onChange={(e) => setSelectedTranscript(e.target.value || null)}
|
onChange={(e) => setSelectedTranscript(e.target.value || null)}
|
||||||
className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-gray-300 focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors"
|
className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-fg-secondary focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
{transcripts.map((t) => (
|
{transcripts.map((t) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option key={t.id} value={t.id}>
|
||||||
@@ -407,10 +402,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-gray-500 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
<ChevronDown className="w-3.5 h-3.5 text-fg-muted absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500 font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
|
<span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
|
||||||
<MessagesSquare className="w-3 h-3" />
|
<MessagesSquare className="w-3 h-3" />
|
||||||
{total} message{total !== 1 ? "s" : ""}
|
{total} message{total !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -420,7 +415,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
disabled={refreshing || loading}
|
disabled={refreshing || loading}
|
||||||
title="Refresh conversation"
|
title="Refresh conversation"
|
||||||
aria-label="Refresh conversation"
|
aria-label="Refresh conversation"
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] text-gray-400 hover:text-gray-200 bg-surface-2 border border-surface-3 hover:border-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="inline-flex items-center gap-1.5 text-[11px] text-fg-secondary hover:text-fg-primary bg-surface-2 border border-surface-3 hover:border-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
Refresh
|
||||||
@@ -430,7 +425,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
|
|
||||||
{/* Error alert */}
|
{/* Error alert */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 flex-shrink-0">
|
<div className="text-sm text-status-danger bg-status-danger/10 border border-status-danger/20 rounded-lg px-4 py-3 flex-shrink-0">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -445,31 +440,31 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
{/* History loading indicator */}
|
{/* History loading indicator */}
|
||||||
{loadingHistory && (
|
{loadingHistory && (
|
||||||
<div className="flex justify-center py-3">
|
<div className="flex justify-center py-3">
|
||||||
<Loader2 className="w-4 h-4 text-gray-500 animate-spin" />
|
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||||
<span className="text-xs text-gray-500 ml-2">Loading history...</span>
|
<span className="text-xs text-fg-muted ml-2">Loading history...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scroll-up for history hint */}
|
{/* Scroll-up for history hint */}
|
||||||
{hasMore && !loadingHistory && !loading && (
|
{hasMore && !loadingHistory && !loading && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
<span className="text-[11px] text-gray-600">↑ Scroll up for older messages</span>
|
<span className="text-[11px] text-fg-muted">↑ Scroll up for older messages</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-gray-500 text-sm">
|
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||||
Loading conversation...
|
Loading conversation...
|
||||||
</div>
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="mx-auto max-w-md py-12 text-center">
|
<div className="mx-auto max-w-md py-12 text-center">
|
||||||
<p className="text-sm text-gray-400">No conversation records found.</p>
|
<p className="text-sm text-fg-secondary">No conversation records found.</p>
|
||||||
<p className="mt-2 text-xs leading-relaxed text-gray-500">
|
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||||
This session's metadata was imported, but its transcript file is no longer on disk.
|
This session's metadata was imported, but its transcript file is no longer on disk.
|
||||||
Claude Code automatically deletes inactive session transcripts after a retention
|
Claude Code automatically deletes inactive session transcripts after a retention
|
||||||
period (<code className="text-gray-400">cleanupPeriodDays</code>, default 30 days), so
|
period (<code className="text-fg-secondary">cleanupPeriodDays</code>, default 30
|
||||||
older conversations may already be gone. Sessions imported from now on are snapshotted
|
days), so older conversations may already be gone. Sessions imported from now on are
|
||||||
and kept even after Claude Code prunes the originals.
|
snapshotted and kept even after Claude Code prunes the originals.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./CodeBlock`
|
* - `./CodeBlock`
|
||||||
*
|
*
|
||||||
@@ -274,7 +269,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
const codeM = rest.match(/^`([^`\n]+)`/);
|
const codeM = rest.match(/^`([^`\n]+)`/);
|
||||||
if (codeM) {
|
if (codeM) {
|
||||||
push(
|
push(
|
||||||
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-amber-200">
|
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
|
||||||
{codeM[1]}
|
{codeM[1]}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
@@ -286,7 +281,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||||
if (boldM) {
|
if (boldM) {
|
||||||
push(
|
push(
|
||||||
<strong className="font-semibold text-gray-50">
|
<strong className="font-semibold text-fg-primary">
|
||||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||||
</strong>
|
</strong>
|
||||||
);
|
);
|
||||||
@@ -298,7 +293,9 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
|
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
|
||||||
if (italicM) {
|
if (italicM) {
|
||||||
push(
|
push(
|
||||||
<em className="italic text-gray-200">{renderInline(italicM[2]!, `${baseKey}-i${n}`)}</em>
|
<em className="italic text-fg-secondary">
|
||||||
|
{renderInline(italicM[2]!, `${baseKey}-i${n}`)}
|
||||||
|
</em>
|
||||||
);
|
);
|
||||||
i += italicM[0].length;
|
i += italicM[0].length;
|
||||||
continue;
|
continue;
|
||||||
@@ -308,7 +305,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
const strikeM = rest.match(/^~~(.+?)~~/);
|
const strikeM = rest.match(/^~~(.+?)~~/);
|
||||||
if (strikeM) {
|
if (strikeM) {
|
||||||
push(
|
push(
|
||||||
<span className="line-through text-gray-500">
|
<span className="line-through text-fg-muted">
|
||||||
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
|
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -366,11 +363,13 @@ function renderListItem(item: string, key: string): React.ReactNode {
|
|||||||
<span className="inline-flex items-baseline gap-2">
|
<span className="inline-flex items-baseline gap-2">
|
||||||
<span
|
<span
|
||||||
className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${
|
className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${
|
||||||
checked ? "bg-emerald-500/40 border-emerald-400/60" : "bg-surface-4 border-surface-3"
|
checked
|
||||||
|
? "bg-status-success/40 border-status-success/60"
|
||||||
|
: "bg-surface-4 border-surface-3"
|
||||||
}`}
|
}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className={checked ? "text-gray-500 line-through" : ""}>
|
<span className={checked ? "text-fg-muted line-through" : ""}>
|
||||||
{renderInline(taskMatch[2]!, key)}
|
{renderInline(taskMatch[2]!, key)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -386,12 +385,12 @@ interface MarkdownContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HEADING_STYLES = [
|
const HEADING_STYLES = [
|
||||||
"text-[18px] font-semibold text-gray-50 mt-2 pb-1 border-b border-surface-3",
|
"text-[18px] font-semibold text-fg-primary mt-2 pb-1 border-b border-surface-3",
|
||||||
"text-[16px] font-semibold text-gray-50 mt-2",
|
"text-[16px] font-semibold text-fg-primary mt-2",
|
||||||
"text-[15px] font-semibold text-gray-100",
|
"text-[15px] font-semibold text-fg-primary",
|
||||||
"text-sm font-semibold text-gray-100",
|
"text-sm font-semibold text-fg-primary",
|
||||||
"text-sm font-medium text-gray-200",
|
"text-sm font-medium text-fg-secondary",
|
||||||
"text-xs font-medium text-gray-300 uppercase tracking-wider",
|
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||||
@@ -399,7 +398,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`text-sm text-gray-300 leading-relaxed ${gap}`}>
|
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
|
||||||
{blocks.map((b, idx) => {
|
{blocks.map((b, idx) => {
|
||||||
switch (b.kind) {
|
switch (b.kind) {
|
||||||
case "code":
|
case "code":
|
||||||
@@ -419,10 +418,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
return (
|
return (
|
||||||
<ol
|
<ol
|
||||||
key={idx}
|
key={idx}
|
||||||
className="list-decimal pl-5 space-y-1 marker:text-gray-500 marker:font-mono marker:text-xs"
|
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||||
>
|
>
|
||||||
{b.items.map((item, i) => (
|
{b.items.map((item, i) => (
|
||||||
<li key={i} className="text-sm text-gray-300">
|
<li key={i} className="text-sm text-fg-secondary">
|
||||||
{renderListItem(item, `li${idx}-${i}`)}
|
{renderListItem(item, `li${idx}-${i}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -432,7 +431,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
return (
|
return (
|
||||||
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
|
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
|
||||||
{b.items.map((item, i) => (
|
{b.items.map((item, i) => (
|
||||||
<li key={i} className="text-sm text-gray-300">
|
<li key={i} className="text-sm text-fg-secondary">
|
||||||
{renderListItem(item, `li${idx}-${i}`)}
|
{renderListItem(item, `li${idx}-${i}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -443,7 +442,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
return (
|
return (
|
||||||
<blockquote
|
<blockquote
|
||||||
key={idx}
|
key={idx}
|
||||||
className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-gray-400 italic bg-violet-500/[0.04] rounded-r"
|
className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-fg-secondary italic bg-violet-500/[0.04] rounded-r"
|
||||||
>
|
>
|
||||||
{renderInline(b.text, `q${idx}`)}
|
{renderInline(b.text, `q${idx}`)}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
@@ -471,7 +470,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
{b.header.map((cell, i) => (
|
{b.header.map((cell, i) => (
|
||||||
<th
|
<th
|
||||||
key={i}
|
key={i}
|
||||||
className={`px-3 py-1.5 font-semibold text-gray-200 border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`}
|
className={`px-3 py-1.5 font-semibold text-fg-secondary border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`}
|
||||||
>
|
>
|
||||||
{renderInline(cell, `th${idx}-${i}`)}
|
{renderInline(cell, `th${idx}-${i}`)}
|
||||||
</th>
|
</th>
|
||||||
@@ -487,7 +486,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
{row.map((cell, ci) => (
|
{row.map((cell, ci) => (
|
||||||
<td
|
<td
|
||||||
key={ci}
|
key={ci}
|
||||||
className={`px-3 py-1.5 text-gray-300 align-top ${alignClass(b.aligns[ci] ?? null)}`}
|
className={`px-3 py-1.5 text-fg-secondary align-top ${alignClass(b.aligns[ci] ?? null)}`}
|
||||||
>
|
>
|
||||||
{renderInline(cell, `td${idx}-${ri}-${ci}`)}
|
{renderInline(cell, `td${idx}-${ri}-${ci}`)}
|
||||||
</td>
|
</td>
|
||||||
@@ -502,7 +501,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
|
|
||||||
case "para":
|
case "para":
|
||||||
return (
|
return (
|
||||||
<p key={idx} className="text-sm text-gray-300 whitespace-pre-wrap break-words">
|
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
|
||||||
{renderInline(b.text, `p${idx}`)}
|
{renderInline(b.text, `p${idx}`)}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `./ToolCallBlock`
|
* - `./ToolCallBlock`
|
||||||
@@ -88,9 +83,9 @@ const SENDER_STYLES: Record<
|
|||||||
label: "User",
|
label: "User",
|
||||||
icon: User,
|
icon: User,
|
||||||
avatarRing:
|
avatarRing:
|
||||||
"bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30",
|
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
|
||||||
accentBar: "before:bg-blue-500/40",
|
accentBar: "before:bg-blue-600/40",
|
||||||
headerText: "text-blue-200",
|
headerText: "text-blue-300",
|
||||||
},
|
},
|
||||||
assistant: {
|
assistant: {
|
||||||
label: "Assistant",
|
label: "Assistant",
|
||||||
@@ -104,7 +99,7 @@ const SENDER_STYLES: Record<
|
|||||||
label: "Main agent",
|
label: "Main agent",
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
avatarRing:
|
avatarRing:
|
||||||
"bg-gradient-to-br from-teal-500/30 to-emerald-500/20 text-teal-200 ring-1 ring-teal-400/30",
|
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
|
||||||
accentBar: "before:bg-teal-500/40",
|
accentBar: "before:bg-teal-500/40",
|
||||||
headerText: "text-teal-200",
|
headerText: "text-teal-200",
|
||||||
},
|
},
|
||||||
@@ -112,17 +107,17 @@ const SENDER_STYLES: Record<
|
|||||||
label: "System",
|
label: "System",
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
avatarRing:
|
avatarRing:
|
||||||
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30",
|
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-fg-secondary ring-1 ring-border-light/30",
|
||||||
accentBar: "before:bg-slate-500/40",
|
accentBar: "before:bg-surface-4/40",
|
||||||
headerText: "text-gray-300",
|
headerText: "text-fg-secondary",
|
||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
label: "Tool",
|
label: "Tool",
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
avatarRing:
|
avatarRing:
|
||||||
"bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30",
|
"bg-gradient-to-br from-status-warning/30 to-orange-500/20 text-status-warning ring-1 ring-status-warning/30",
|
||||||
accentBar: "before:bg-amber-500/40",
|
accentBar: "before:bg-status-warning/40",
|
||||||
headerText: "text-amber-200",
|
headerText: "text-status-warning",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
import { ToolCallBlock } from "./ToolCallBlock";
|
import { ToolCallBlock } from "./ToolCallBlock";
|
||||||
@@ -174,12 +169,12 @@ function formatLocalTime(iso: string): string {
|
|||||||
function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) {
|
function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
<div className="inline-flex items-center gap-2 text-[11px] text-gray-400 bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
|
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
|
||||||
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
|
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
|
||||||
<span className="text-gray-500">Renamed session →</span>
|
<span className="text-fg-muted">Renamed session →</span>
|
||||||
<span className="text-gray-200 font-medium truncate">{title || "(untitled)"}</span>
|
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
|
||||||
{timestamp && (
|
{timestamp && (
|
||||||
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">
|
<span className="text-[10px] text-fg-muted font-mono flex-shrink-0">
|
||||||
{formatLocalTime(timestamp)}
|
{formatLocalTime(timestamp)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -191,8 +186,8 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
|
|||||||
/** Compact pill for /command invocations parsed out of TUI markup. */
|
/** Compact pill for /command invocations parsed out of TUI markup. */
|
||||||
function CommandPill({ display }: { display: string }) {
|
function CommandPill({ display }: { display: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-2 text-sm text-emerald-300 font-mono bg-emerald-500/10 border border-emerald-500/20 rounded-md px-3 py-1.5 max-w-full">
|
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
|
||||||
<span className="text-emerald-500/70">›</span>
|
<span className="text-status-success/70">›</span>
|
||||||
<span className="break-all">{display}</span>
|
<span className="break-all">{display}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -203,9 +198,9 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
|
|||||||
const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, "");
|
const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, "");
|
||||||
const isErr = stream === "stderr";
|
const isErr = stream === "stderr";
|
||||||
const accent = isErr
|
const accent = isErr
|
||||||
? "border-red-500/30 bg-red-950/30 text-red-200/90"
|
? "border-status-danger/30 bg-status-danger/30 text-status-danger/90"
|
||||||
: "border-surface-3 bg-surface-4/60 text-gray-200";
|
: "border-surface-3 bg-surface-4/60 text-fg-secondary";
|
||||||
const labelColor = isErr ? "text-red-300/80" : "text-gray-400";
|
const labelColor = isErr ? "text-status-danger/80" : "text-fg-secondary";
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border ${accent} overflow-hidden`}>
|
<div className={`rounded-lg border ${accent} overflow-hidden`}>
|
||||||
<div
|
<div
|
||||||
@@ -224,7 +219,7 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
|
|||||||
/** Subtle inline note for the local-command-caveat banner. */
|
/** Subtle inline note for the local-command-caveat banner. */
|
||||||
function CaveatBlock({ text }: { text: string }) {
|
function CaveatBlock({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-[11px] text-amber-200/70">
|
<div className="flex items-start gap-2 rounded-md border border-status-warning/15 bg-status-warning/[0.05] px-3 py-1.5 text-[11px] text-status-warning/70">
|
||||||
<Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" />
|
<Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" />
|
||||||
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
|
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,11 +242,11 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
|||||||
<CollapsibleBlock
|
<CollapsibleBlock
|
||||||
key={key}
|
key={key}
|
||||||
text={seg.text}
|
text={seg.text}
|
||||||
icon={<AlertTriangle className="w-3.5 h-3.5 text-amber-400/70 flex-shrink-0" />}
|
icon={<AlertTriangle className="w-3.5 h-3.5 text-status-warning/70 flex-shrink-0" />}
|
||||||
title="System reminder"
|
title="System reminder"
|
||||||
borderClass="border-amber-500/20"
|
borderClass="border-status-warning/20"
|
||||||
bgClass="bg-amber-500/5"
|
bgClass="bg-status-warning/5"
|
||||||
textClass="text-amber-300/80"
|
textClass="text-status-warning/80"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "persisted-output":
|
case "persisted-output":
|
||||||
@@ -326,7 +321,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-gray-500 text-sm">
|
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||||
Loading conversation...
|
Loading conversation...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -334,7 +329,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12 text-gray-500 text-sm">No conversation records found.</div>
|
<div className="text-center py-12 text-fg-muted text-sm">No conversation records found.</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,19 +387,19 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
{style.label}
|
{style.label}
|
||||||
</span>
|
</span>
|
||||||
{msg.model && (
|
{msg.model && (
|
||||||
<span className="text-[10px] text-gray-400 font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
|
<span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
|
||||||
{formatModelName(msg.model)}
|
{formatModelName(msg.model)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.usage && (
|
{msg.usage && (
|
||||||
<span className="text-[10px] text-gray-500 font-mono inline-flex items-center gap-1">
|
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||||
<span className="text-emerald-300/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||||
<span className="text-gray-700">·</span>
|
<span className="text-fg-muted">·</span>
|
||||||
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
|
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.timestamp && (
|
{msg.timestamp && (
|
||||||
<span className="text-[10px] text-gray-600 ml-auto font-mono">
|
<span className="text-[10px] text-fg-muted ml-auto font-mono">
|
||||||
{formatLocalTime(msg.timestamp)}
|
{formatLocalTime(msg.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -436,11 +431,11 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
<CollapsibleBlock
|
<CollapsibleBlock
|
||||||
key={bIdx}
|
key={bIdx}
|
||||||
text={block.text}
|
text={block.text}
|
||||||
icon={<ScrollText className="w-3.5 h-3.5 text-blue-400/60 flex-shrink-0" />}
|
icon={<ScrollText className="w-3.5 h-3.5 text-blue-500/60 flex-shrink-0" />}
|
||||||
title={skillPath}
|
title={skillPath}
|
||||||
borderClass="border-blue-500/20"
|
borderClass="border-blue-600/20"
|
||||||
bgClass="bg-blue-500/5"
|
bgClass="bg-blue-600/5"
|
||||||
textClass="text-blue-400/80"
|
textClass="text-blue-500/80"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -470,7 +465,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={bIdx}
|
key={bIdx}
|
||||||
className="rounded-lg border border-amber-500/20 bg-amber-500/5 overflow-hidden"
|
className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -481,23 +476,23 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
return next;
|
return next;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-amber-500/10 transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`w-3.5 h-3.5 text-amber-500/60 transition-transform duration-150 ${
|
className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
|
||||||
isExpanded ? "rotate-90" : ""
|
isExpanded ? "rotate-90" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<Brain className="w-3.5 h-3.5 text-amber-400/80" />
|
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
|
||||||
<span className="text-xs text-amber-200/90 font-medium">Thinking</span>
|
<span className="text-xs text-status-warning/90 font-medium">Thinking</span>
|
||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
<span className="text-[10px] text-amber-300/40 font-mono ml-auto">
|
<span className="text-[10px] text-status-warning/40 font-mono ml-auto">
|
||||||
{block.text.length.toLocaleString()} chars
|
{block.text.length.toLocaleString()} chars
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-amber-500/10 px-3 py-2 text-amber-100/80">
|
<div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
|
||||||
<MarkdownContent text={block.text} dense />
|
<MarkdownContent text={block.text} dense />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `./CodeBlock`
|
* - `./CodeBlock`
|
||||||
@@ -138,7 +133,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CodeBlock code={obj.command} lang="bash" label="Command" />
|
<CodeBlock code={obj.command} lang="bash" label="Command" />
|
||||||
{typeof obj.description === "string" && (
|
{typeof obj.description === "string" && (
|
||||||
<p className="text-xs text-gray-500 italic px-1">{obj.description}</p>
|
<p className="text-xs text-fg-muted italic px-1">{obj.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -161,11 +156,11 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
const lang = langFromPath(obj.file_path);
|
const lang = langFromPath(obj.file_path);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||||
<FileText className="w-3.5 h-3.5 text-violet-400" />
|
<FileText className="w-3.5 h-3.5 text-violet-400" />
|
||||||
<span className="font-mono">{obj.file_path}</span>
|
<span className="font-mono">{obj.file_path}</span>
|
||||||
{obj.replace_all === true && (
|
{obj.replace_all === true && (
|
||||||
<span className="text-[10px] uppercase tracking-wider text-amber-300/80 bg-amber-500/10 border border-amber-500/20 rounded px-1.5 py-0.5">
|
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
|
||||||
replace all
|
replace all
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -183,11 +178,11 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
// Read: just show the path with offset/limit
|
// Read: just show the path with offset/limit
|
||||||
if (tool === "read" && typeof obj.file_path === "string") {
|
if (tool === "read" && typeof obj.file_path === "string") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-300 bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
|
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
|
||||||
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
|
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
|
||||||
<span className="font-mono break-all">{obj.file_path}</span>
|
<span className="font-mono break-all">{obj.file_path}</span>
|
||||||
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
||||||
<span className="text-gray-500 font-mono ml-auto flex-shrink-0">
|
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
|
||||||
{typeof obj.offset === "number" ? `:${obj.offset}` : ""}
|
{typeof obj.offset === "number" ? `:${obj.offset}` : ""}
|
||||||
{typeof obj.limit === "number" ? `+${obj.limit}` : ""}
|
{typeof obj.limit === "number" ? `+${obj.limit}` : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -201,7 +196,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
|
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||||
Pattern
|
Pattern
|
||||||
</span>
|
</span>
|
||||||
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||||
@@ -210,18 +205,18 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
</div>
|
</div>
|
||||||
{typeof obj.path === "string" && (
|
{typeof obj.path === "string" && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
|
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||||
Path
|
Path
|
||||||
</span>
|
</span>
|
||||||
<code className="font-mono text-gray-300">{obj.path}</code>
|
<code className="font-mono text-fg-secondary">{obj.path}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof obj.glob === "string" && (
|
{typeof obj.glob === "string" && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-gray-500 font-mono uppercase tracking-wider text-[10px]">
|
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||||
Glob
|
Glob
|
||||||
</span>
|
</span>
|
||||||
<code className="font-mono text-gray-300">{obj.glob}</code>
|
<code className="font-mono text-fg-secondary">{obj.glob}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,7 +230,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
/** Render the result pane: detect diff/json/text. */
|
/** Render the result pane: detect diff/json/text. */
|
||||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||||
const text = toolResult.output ?? "";
|
const text = toolResult.output ?? "";
|
||||||
if (text.length === 0) return <div className="text-xs text-gray-500 italic px-1">(empty)</div>;
|
if (text.length === 0) return <div className="text-xs text-fg-muted italic px-1">(empty)</div>;
|
||||||
|
|
||||||
const isError = !!toolResult.is_error;
|
const isError = !!toolResult.is_error;
|
||||||
const tool = toolName.toLowerCase();
|
const tool = toolName.toLowerCase();
|
||||||
@@ -269,8 +264,8 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
|
|||||||
const style = styleForTool(toolUse.name);
|
const style = styleForTool(toolUse.name);
|
||||||
const Icon = style.Icon;
|
const Icon = style.Icon;
|
||||||
|
|
||||||
const wrapperBorder = isError ? "border-red-500/30" : style.border;
|
const wrapperBorder = isError ? "border-status-danger/30" : style.border;
|
||||||
const wrapperBg = isError ? "bg-red-500/5" : "bg-surface-2/60";
|
const wrapperBg = isError ? "bg-status-danger/5" : "bg-surface-2/60";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -282,7 +277,7 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
|
|||||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`w-3.5 h-3.5 text-gray-500 flex-shrink-0 transition-transform duration-150 ${
|
className={`w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform duration-150 ${
|
||||||
expanded ? "rotate-90" : ""
|
expanded ? "rotate-90" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -295,23 +290,23 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
|
|||||||
{toolUse.name}
|
{toolUse.name}
|
||||||
</span>
|
</span>
|
||||||
{summary && (
|
{summary && (
|
||||||
<span className="text-gray-500 text-xs font-mono truncate min-w-0" title={summary}>
|
<span className="text-fg-muted text-xs font-mono truncate min-w-0" title={summary}>
|
||||||
{summary}
|
{summary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto flex-shrink-0">
|
<span className="ml-auto flex-shrink-0">
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-red-300 bg-red-500/15 border border-red-500/20 rounded px-1.5 py-0.5">
|
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-danger bg-status-danger/15 border border-status-danger/20 rounded px-1.5 py-0.5">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
error
|
error
|
||||||
</span>
|
</span>
|
||||||
) : hasResult ? (
|
) : hasResult ? (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-emerald-300/80 bg-emerald-500/10 border border-emerald-500/20 rounded px-1.5 py-0.5">
|
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-success/80 bg-status-success/10 border border-status-success/20 rounded px-1.5 py-0.5">
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
ok
|
ok
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600 text-[10px] uppercase tracking-wider font-mono">
|
<span className="text-fg-muted text-[10px] uppercase tracking-wider font-mono">
|
||||||
pending
|
pending
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `ToolStyle` — exported API; see TSDoc on the symbol for behavior.
|
* - `ToolStyle` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `styleForTool` — exported API; see TSDoc on the symbol for behavior.
|
* - `styleForTool` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TuiSegment` — exported API; see TSDoc on the symbol for behavior.
|
* - `TuiSegment` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
|
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `CopyButton` — exported API; see TSDoc on the symbol for behavior.
|
* - `CopyButton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `Terminal` — exported API; see TSDoc on the symbol for behavior.
|
* - `Terminal` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -130,7 +125,7 @@ export function CopyButton({ text }: { text: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer"
|
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-fg-secondary hover:text-fg-primary hover:bg-surface-2 cursor-pointer"
|
||||||
aria-label={t("eventDetail.copy")}
|
aria-label={t("eventDetail.copy")}
|
||||||
>
|
>
|
||||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
@@ -145,13 +140,13 @@ export function Terminal({ command, description }: { command: string; descriptio
|
|||||||
return (
|
return (
|
||||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
<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 border-b border-border bg-black/40">
|
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">terminal</span>
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">terminal</span>
|
||||||
<CopyButton text={command} />
|
<CopyButton text={command} />
|
||||||
</div>
|
</div>
|
||||||
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words">
|
<pre className="px-3 py-2 text-fg-secondary whitespace-pre-wrap break-words">
|
||||||
{description && <div className="text-gray-500 mb-1"># {description}</div>}
|
{description && <div className="text-fg-muted mb-1"># {description}</div>}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-emerald-400 select-none">$ </span>
|
<span className="text-status-success select-none">$ </span>
|
||||||
{command}
|
{command}
|
||||||
</div>
|
</div>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -176,11 +171,14 @@ export function TerminalOutput({
|
|||||||
const hasStderr = typeof stderr === "string" && stderr.length > 0;
|
const hasStderr = typeof stderr === "string" && stderr.length > 0;
|
||||||
const flag =
|
const flag =
|
||||||
interrupted === true
|
interrupted === true
|
||||||
? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" }
|
? {
|
||||||
|
label: "interrupted",
|
||||||
|
color: "text-status-danger border-status-danger/40 bg-status-danger/10",
|
||||||
|
}
|
||||||
: typeof exitCode === "number" && exitCode !== 0
|
: typeof exitCode === "number" && exitCode !== 0
|
||||||
? {
|
? {
|
||||||
label: `exit ${exitCode}`,
|
label: `exit ${exitCode}`,
|
||||||
color: "text-red-400 border-red-500/40 bg-red-500/10",
|
color: "text-status-danger border-status-danger/40 bg-status-danger/10",
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -208,11 +206,11 @@ function OutputBlock({
|
|||||||
text: string;
|
text: string;
|
||||||
variant: "out" | "err";
|
variant: "out" | "err";
|
||||||
}) {
|
}) {
|
||||||
const color = variant === "err" ? "text-red-300" : "text-gray-200";
|
const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
<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 border-b border-border bg-black/40">
|
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
|
||||||
<CopyButton text={text} />
|
<CopyButton text={text} />
|
||||||
</div>
|
</div>
|
||||||
<pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}>
|
<pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}>
|
||||||
@@ -240,7 +238,7 @@ export function LineNumberedCode({
|
|||||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||||
{label && (
|
{label && (
|
||||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||||
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
|
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
|
||||||
<CopyButton text={text} />
|
<CopyButton text={text} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -249,10 +247,10 @@ export function LineNumberedCode({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{lines.map((line, i) => (
|
{lines.map((line, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td className="px-2 text-right text-gray-600 select-none bg-black/40 border-r border-border align-top">
|
<td className="px-2 text-right text-fg-muted select-none bg-black/40 border-r border-border align-top">
|
||||||
{i + startLine}
|
{i + startLine}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 text-gray-200 whitespace-pre-wrap break-words">{line}</td>
|
<td className="px-3 text-fg-secondary whitespace-pre-wrap break-words">{line}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -274,7 +272,7 @@ export type DiffHunk = {
|
|||||||
|
|
||||||
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
|
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
|
||||||
if (hunks.length === 0) {
|
if (hunks.length === 0) {
|
||||||
return <p className="text-[11px] text-gray-500 italic">no diff</p>;
|
return <p className="text-[11px] text-fg-muted italic">no diff</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||||
@@ -306,17 +304,17 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
|
|||||||
kind === "add"
|
kind === "add"
|
||||||
? "bg-green-500/10 text-green-200"
|
? "bg-green-500/10 text-green-200"
|
||||||
: kind === "remove"
|
: kind === "remove"
|
||||||
? "bg-red-500/10 text-red-200"
|
? "bg-status-danger/10 text-status-danger"
|
||||||
: "text-gray-300";
|
: "text-fg-secondary";
|
||||||
const oldCell = showOld ? oldLine++ : "";
|
const oldCell = showOld ? oldLine++ : "";
|
||||||
const newCell = showNew ? newLine++ : "";
|
const newCell = showNew ? newLine++ : "";
|
||||||
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
||||||
return (
|
return (
|
||||||
<tr key={i} className={rowBg}>
|
<tr key={i} className={rowBg}>
|
||||||
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
|
<td className="px-2 text-right text-fg-muted select-none border-r border-border/40 w-[36px]">
|
||||||
{oldCell}
|
{oldCell}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
|
<td className="px-2 text-right text-fg-muted select-none border-r border-border/40 w-[36px]">
|
||||||
{newCell}
|
{newCell}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 text-center select-none w-[16px]">{sign}</td>
|
<td className="px-1 text-center select-none w-[16px]">{sign}</td>
|
||||||
@@ -347,7 +345,7 @@ export function KeyValueCard({
|
|||||||
const ordered = [...priorityEntries, ...restEntries];
|
const ordered = [...priorityEntries, ...restEntries];
|
||||||
|
|
||||||
if (ordered.length === 0) {
|
if (ordered.length === 0) {
|
||||||
return <p className="text-[11px] text-gray-500 italic">empty</p>;
|
return <p className="text-[11px] text-fg-muted italic">empty</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -355,10 +353,10 @@ export function KeyValueCard({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{ordered.map(([k, v], i) => (
|
{ordered.map(([k, v], i) => (
|
||||||
<tr key={k} className={i > 0 ? "border-t border-border" : ""}>
|
<tr key={k} className={i > 0 ? "border-t border-border" : ""}>
|
||||||
<td className="text-gray-500 align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
|
<td className="text-fg-muted align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
|
||||||
{k}
|
{k}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-gray-300 align-top py-1.5 px-2">
|
<td className="text-fg-secondary align-top py-1.5 px-2">
|
||||||
<ValueCell value={v} />
|
<ValueCell value={v} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -369,32 +367,33 @@ export function KeyValueCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ValueCell({ value }: { value: unknown }) {
|
function ValueCell({ value }: { value: unknown }) {
|
||||||
if (value == null) return <span className="text-gray-500 italic">null</span>;
|
if (value == null) return <span className="text-fg-muted italic">null</span>;
|
||||||
if (typeof value === "boolean")
|
if (typeof value === "boolean")
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
|
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
|
||||||
value
|
value
|
||||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||||
: "text-gray-400 border-gray-500/30 bg-gray-500/10"
|
: "text-fg-secondary border-border-light/30 bg-surface-4/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{String(value)}
|
{String(value)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
if (typeof value === "number") return <span className="font-mono text-gray-300">{value}</span>;
|
if (typeof value === "number")
|
||||||
|
return <span className="font-mono text-fg-secondary">{value}</span>;
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
if (value.length > 120 || value.includes("\n")) {
|
if (value.length > 120 || value.includes("\n")) {
|
||||||
return (
|
return (
|
||||||
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
<pre className="bg-surface-3 text-fg-secondary text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
||||||
{value}
|
{value}
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="font-mono text-gray-300 break-all">{value}</span>;
|
return <span className="font-mono text-fg-secondary break-all">{value}</span>;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (value.length === 0) return <span className="text-gray-500 italic">[]</span>;
|
if (value.length === 0) return <span className="text-fg-muted italic">[]</span>;
|
||||||
return (
|
return (
|
||||||
<ol className="list-decimal pl-4 space-y-1">
|
<ol className="list-decimal pl-4 space-y-1">
|
||||||
{value.map((item, i) => (
|
{value.map((item, i) => (
|
||||||
@@ -406,7 +405,7 @@ function ValueCell({ value }: { value: unknown }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
<pre className="bg-surface-3 text-fg-secondary text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
||||||
{safeStringify(value)}
|
{safeStringify(value)}
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
@@ -423,11 +422,11 @@ function safeStringify(value: unknown): string {
|
|||||||
// ───────────────────────── File list / match list ─────────────────────────
|
// ───────────────────────── File list / match list ─────────────────────────
|
||||||
|
|
||||||
export function FileList({ paths }: { paths: string[] }) {
|
export function FileList({ paths }: { paths: string[] }) {
|
||||||
if (paths.length === 0) return <p className="text-[11px] text-gray-500 italic">no files</p>;
|
if (paths.length === 0) return <p className="text-[11px] text-fg-muted italic">no files</p>;
|
||||||
return (
|
return (
|
||||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
|
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
|
||||||
{paths.map((p, i) => (
|
{paths.map((p, i) => (
|
||||||
<li key={i} className="px-3 py-1 font-mono text-gray-300 break-all">
|
<li key={i} className="px-3 py-1 font-mono text-fg-secondary break-all">
|
||||||
{p}
|
{p}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -442,14 +441,14 @@ export type GrepMatch = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MatchList({ matches }: { matches: GrepMatch[] }) {
|
export function MatchList({ matches }: { matches: GrepMatch[] }) {
|
||||||
if (matches.length === 0) return <p className="text-[11px] text-gray-500 italic">no matches</p>;
|
if (matches.length === 0) return <p className="text-[11px] text-fg-muted italic">no matches</p>;
|
||||||
return (
|
return (
|
||||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
|
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
|
||||||
{matches.map((m, i) => (
|
{matches.map((m, i) => (
|
||||||
<li key={i} className="px-3 py-1 text-gray-300 break-all">
|
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
|
||||||
{m.file && <span className="text-cyan-300">{m.file}</span>}
|
{m.file && <span className="text-cyan-300">{m.file}</span>}
|
||||||
{m.line != null && <span className="text-gray-500">:{m.line}</span>}
|
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
|
||||||
{m.text && <span className="text-gray-400">: {m.text}</span>}
|
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -36,11 +36,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./primitives`
|
* - `./primitives`
|
||||||
*
|
*
|
||||||
@@ -397,9 +392,9 @@ export function ToolResponseView({
|
|||||||
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
|
{hunks.length > 0 && <UnifiedDiff hunks={hunks} />}
|
||||||
{originalFile && (
|
{originalFile && (
|
||||||
<details className="bg-surface-2/40 border border-border rounded overflow-hidden">
|
<details className="bg-surface-2/40 border border-border rounded overflow-hidden">
|
||||||
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-gray-400 hover:text-gray-200 hover:bg-surface-2">
|
<summary className="cursor-pointer select-none px-3 py-1.5 text-[11px] text-fg-secondary hover:text-fg-primary hover:bg-surface-2">
|
||||||
<span className="font-semibold uppercase tracking-wide">original file</span>
|
<span className="font-semibold uppercase tracking-wide">original file</span>
|
||||||
<span className="text-gray-500 font-normal ml-2">
|
<span className="text-fg-muted font-normal ml-2">
|
||||||
({originalFile.split(/\r?\n/).length} lines)
|
({originalFile.split(/\r?\n/).length} lines)
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|||||||
@@ -1,24 +1,76 @@
|
|||||||
/**
|
/**
|
||||||
* @file AddLaneModal.tsx
|
* @file AddLaneModal.tsx
|
||||||
* @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to
|
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||||
* adopt), pick which of its branches to fork from, name the feature, and the
|
* segmented toggle: "Repo" adopts an existing directory as-is via
|
||||||
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
|
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||||
* — the dashboard invents the lane's own directory and branch name, the same
|
* main repo you want stage detection on); "Worktree" provisions a
|
||||||
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane
|
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||||
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket
|
* manually-typed branch name. Either mode's path field can be filled by
|
||||||
* subscription in the Workspace page flips it to idle when the worktree is
|
* typing, by the CwdAutocomplete suggestions, or by browsing
|
||||||
* actually ready, so this component does not poll.
|
* (`FolderBrowseModal`) — a native folder picker cannot hand a web page an
|
||||||
|
* absolute filesystem path, so browsing is server-backed instead. The
|
||||||
|
* worktree lane returned is `status: "provisioning"`: the route answers
|
||||||
|
* before the actual `git worktree add` runs, so this component polls
|
||||||
|
* `GET /api/lanes/:id` until that finishes before running the auto-setup
|
||||||
|
* calls against a cwd that must actually exist on disk first.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { CwdAutocomplete } from "../run/RunSetup";
|
import { CwdAutocomplete } from "../run/RunSetup";
|
||||||
|
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { CwdSuggestion } from "../../lib/api";
|
import type { CwdSuggestion } from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
|
||||||
|
/** Polls the lane until the background `git worktree add` finishes (status
|
||||||
|
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||||
|
* lane record, or `null` on timeout. */
|
||||||
|
async function waitForProvisioned(
|
||||||
|
laneId: number,
|
||||||
|
{ intervalMs = 500, timeoutMs = 30000 } = {}
|
||||||
|
): Promise<Lane | null> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
const { lane } = await api.lanes.get(laneId);
|
||||||
|
if (lane.status !== "provisioning") return lane;
|
||||||
|
if (Date.now() >= deadline) return null;
|
||||||
|
await new Promise((r) => window.setTimeout(r, intervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||||
|
function Seg({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-accent text-white shadow-sm"
|
||||||
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AddLaneModal({
|
export function AddLaneModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -27,36 +79,74 @@ export function AddLaneModal({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called with the newly provisioned (still-provisioning) lane. */
|
/** Called with the newly created (possibly still-provisioning) lane. */
|
||||||
onAdded: (lane: Lane) => void;
|
onAdded: (lane: Lane) => void;
|
||||||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||||||
* home, recently-used paths) — reused rather than fetched a second time. */
|
* home, recently-used paths) — reused rather than fetched a second time. */
|
||||||
cwdSuggestions: CwdSuggestion[];
|
cwdSuggestions: CwdSuggestion[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
||||||
const [sourceRepo, setSourceRepo] = useState("");
|
const [sourceRepo, setSourceRepo] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
|
const [branch, setBranch] = useState("");
|
||||||
const [branches, setBranches] = useState<string[] | null>(null);
|
const [branches, setBranches] = useState<string[] | null>(null);
|
||||||
const [base, setBase] = useState("");
|
const [base, setBase] = useState("");
|
||||||
|
const [pipeline, setPipeline] = useState("default");
|
||||||
|
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [browseOpen, setBrowseOpen] = useState(false);
|
||||||
|
const [setupResult, setSetupResult] = useState<{
|
||||||
|
profile: "scaffolded" | "skipped" | "failed";
|
||||||
|
agents: "ok" | "failed";
|
||||||
|
mcp: "ok" | "failed";
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
|
setMode("worktree");
|
||||||
setSourceRepo("");
|
setSourceRepo("");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
|
setBranch("");
|
||||||
setBranches(null);
|
setBranches(null);
|
||||||
setBase("");
|
setBase("");
|
||||||
|
setPipeline("default");
|
||||||
setBranchesError(null);
|
setBranchesError(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setSetupResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The template a lane is created with is the ONLY chance to get it right
|
||||||
|
// from here: nothing else in the UI can change it afterwards, so a lane
|
||||||
|
// silently born on `default` renders an 8-node map for a 16-node workflow.
|
||||||
|
// Fetched on open (templates are file-backed and can change between opens).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api.lanes
|
||||||
|
.pipelines()
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setPipelines(r.pipelines);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Quiet: the select just falls back to the single `default` option
|
||||||
|
// below, and the lane still gets created.
|
||||||
|
if (!cancelled) setPipelines([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// Look up the repo's branches once the path settles - debounced so every
|
// Look up the repo's branches once the path settles - debounced so every
|
||||||
// keystroke while typing a path doesn't fire a request against a path that
|
// keystroke while typing a path doesn't fire a request against a path that
|
||||||
// isn't finished yet.
|
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
|
||||||
|
// never forks a branch.
|
||||||
const lookedUpFor = useRef<string>("");
|
const lookedUpFor = useRef<string>("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mode !== "worktree") return;
|
||||||
const path = sourceRepo.trim();
|
const path = sourceRepo.trim();
|
||||||
if (!path) {
|
if (!path) {
|
||||||
setBranches(null);
|
setBranches(null);
|
||||||
@@ -82,23 +172,84 @@ export function AddLaneModal({
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [sourceRepo, t]);
|
}, [mode, sourceRepo, t]);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const repo = sourceRepo.trim();
|
const repo = sourceRepo.trim();
|
||||||
const name = title.trim();
|
const name = title.trim();
|
||||||
if (!repo || !branches || !name) return;
|
if (!repo) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (mode === "repo") {
|
||||||
|
try {
|
||||||
|
const result = await api.lanes.ensure({
|
||||||
|
cwd: repo,
|
||||||
|
title: name || undefined,
|
||||||
|
pipeline,
|
||||||
|
});
|
||||||
|
onAdded(result.lane);
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!branches || !name || !branch.trim()) return;
|
||||||
try {
|
try {
|
||||||
const result = await api.lanes.worktree({
|
const result = await api.lanes.worktree({
|
||||||
sourceRepo: repo,
|
sourceRepo: repo,
|
||||||
title: name,
|
title: name,
|
||||||
base: base || undefined,
|
base: base || undefined,
|
||||||
|
branch: branch.trim(),
|
||||||
|
pipeline,
|
||||||
});
|
});
|
||||||
reset();
|
|
||||||
onAdded(result.lane);
|
onAdded(result.lane);
|
||||||
onClose();
|
|
||||||
|
// POST /worktree returns as soon as the DB row exists (202) — the
|
||||||
|
// actual `git worktree add` runs afterward, in the background, on the
|
||||||
|
// server. Firing setup against the lane's cwd before that finishes
|
||||||
|
// means agents/mcp write into a directory that doesn't exist yet, so
|
||||||
|
// wait for provisioning to leave the "provisioning" status first.
|
||||||
|
const provisionedLane = await waitForProvisioned(result.lane.id);
|
||||||
|
if (!provisionedLane || provisionedLane.status === "failed") {
|
||||||
|
setBusy(false);
|
||||||
|
setError(t("addLaneProvisionFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||||
|
api.lanes.profileInit(result.lane.id),
|
||||||
|
api.lanes.agentsInstall(result.lane.id),
|
||||||
|
api.lanes.mcpSync(result.lane.id),
|
||||||
|
]);
|
||||||
|
setSetupResult({
|
||||||
|
profile:
|
||||||
|
profileOutcome.status === "fulfilled"
|
||||||
|
? profileOutcome.value.scaffolded
|
||||||
|
? "scaffolded"
|
||||||
|
: "skipped"
|
||||||
|
: "failed",
|
||||||
|
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||||
|
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info("[add-lane] auto-setup result:", {
|
||||||
|
profile:
|
||||||
|
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||||
|
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||||
|
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the modal open so the setup summary below stays on screen; the
|
||||||
|
// user dismisses it themselves (Cancel/X) once they've seen it, rather
|
||||||
|
// than racing a timer that can close before they've looked at it.
|
||||||
|
setBusy(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -112,9 +263,21 @@ export function AddLaneModal({
|
|||||||
// into after the very first character. useCallback keeps the identity stable
|
// into after the very first character. useCallback keeps the identity stable
|
||||||
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
|
||||||
|
// While the folder browser is open on top of it, that dismissal should
|
||||||
|
// close only the browser, not both modals at once.
|
||||||
|
if (browseOpen) {
|
||||||
|
setBrowseOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose, browseOpen]);
|
||||||
|
|
||||||
|
const disabled =
|
||||||
|
!!setupResult ||
|
||||||
|
!sourceRepo.trim() ||
|
||||||
|
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -124,26 +287,56 @@ export function AddLaneModal({
|
|||||||
cancelLabel={t("destructive.cancel")}
|
cancelLabel={t("destructive.cancel")}
|
||||||
destructive={false}
|
destructive={false}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
disabled={!sourceRepo.trim() || !title.trim() || !branches}
|
disabled={disabled}
|
||||||
onConfirm={submit}
|
onConfirm={submit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||||
|
<Seg
|
||||||
|
active={mode === "repo"}
|
||||||
|
label={t("mode.repo")}
|
||||||
|
title={t("mode.repoHint")}
|
||||||
|
onClick={() => setMode("repo")}
|
||||||
|
/>
|
||||||
|
<Seg
|
||||||
|
active={mode === "worktree"}
|
||||||
|
label={t("mode.worktree")}
|
||||||
|
title={t("mode.worktreeHint")}
|
||||||
|
onClick={() => setMode("worktree")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-repo">
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||||
{t("addLaneRepoLabel")}
|
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<CwdAutocomplete
|
<CwdAutocomplete
|
||||||
inputId="add-lane-repo"
|
inputId="add-lane-repo"
|
||||||
value={sourceRepo}
|
value={sourceRepo}
|
||||||
onChange={setSourceRepo}
|
onChange={setSourceRepo}
|
||||||
suggestions={cwdSuggestions}
|
suggestions={cwdSuggestions}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[10px] text-neutral-500">{t("addLaneRepoHint")}</p>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrowseOpen(true)}
|
||||||
|
title={t("browse.title")}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
{t("browse.button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-fg-muted">
|
||||||
|
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-title">
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||||||
{t("addLaneTitleLabel")}
|
{t("addLaneTitleLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -151,23 +344,44 @@ export function AddLaneModal({
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder={t("addLaneTitlePlaceholder")}
|
placeholder={t("addLaneTitlePlaceholder")}
|
||||||
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-600 focus:border-blue-400 focus:outline-none"
|
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{branches && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-base">
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
|
||||||
|
{t("addLanePipelineLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="add-lane-pipeline"
|
||||||
|
value={pipeline}
|
||||||
|
onChange={(e) => setPipeline(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
|
||||||
|
(p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "worktree" && branches && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||||
{t("addLaneBaseLabel")}
|
{t("addLaneBaseLabel")}
|
||||||
</label>
|
</label>
|
||||||
{branches.length === 0 ? (
|
{branches.length === 0 ? (
|
||||||
<p className="text-[10px] text-neutral-500">{t("addLaneNoBranches")}</p>
|
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
id="add-lane-base"
|
id="add-lane-base"
|
||||||
value={base}
|
value={base}
|
||||||
onChange={(e) => setBase(e.target.value)}
|
onChange={(e) => setBase(e.target.value)}
|
||||||
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 focus:border-blue-400 focus:outline-none"
|
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
<option key={b} value={b}>
|
<option key={b} value={b}>
|
||||||
@@ -178,16 +392,71 @@ export function AddLaneModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{branchesError && !branches && (
|
{mode === "worktree" && branchesError && !branches && (
|
||||||
<p className="text-[10px] text-amber-400">{branchesError}</p>
|
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "worktree" && branches && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||||
|
{t("addLaneBranchLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="add-lane-branch"
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
placeholder={t("addLaneBranchPlaceholder")}
|
||||||
|
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupResult && (
|
||||||
|
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||||
|
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: t("addLaneSetupProfile"),
|
||||||
|
ok: setupResult.profile !== "failed",
|
||||||
|
skipped: setupResult.profile === "skipped",
|
||||||
|
},
|
||||||
|
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||||
|
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||||
|
] as const
|
||||||
|
).map((row) => (
|
||||||
|
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"skipped" in row && row.skipped
|
||||||
|
? "text-fg-muted"
|
||||||
|
: row.ok
|
||||||
|
? "text-status-success"
|
||||||
|
: "text-status-danger"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
{row.label}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-red-400">
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FolderBrowseModal
|
||||||
|
open={browseOpen}
|
||||||
|
initialPath={sourceRepo.trim() || undefined}
|
||||||
|
onSelect={setSourceRepo}
|
||||||
|
onClose={() => setBrowseOpen(false)}
|
||||||
|
/>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,18 +153,18 @@ export function DestructiveLaneModal({
|
|||||||
onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) });
|
onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading && <p className="mt-3 text-xs text-neutral-400">{t("destructive.loading")}</p>}
|
{loading && <p className="mt-3 text-xs text-fg-secondary">{t("destructive.loading")}</p>}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-3 text-xs text-red-300">
|
<p className="mt-3 text-xs text-status-danger">
|
||||||
{t("preflightErrorWithMessage", { message: error })}
|
{t("preflightErrorWithMessage", { message: error })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{preflight && (
|
{preflight && (
|
||||||
<table className="mt-3 w-full text-left text-xs text-neutral-300">
|
<table className="mt-3 w-full text-left text-xs text-fg-secondary">
|
||||||
<tbody>
|
<tbody>
|
||||||
{facts.map(([name, value]) => (
|
{facts.map(([name, value]) => (
|
||||||
<tr key={name} className="border-t border-neutral-800">
|
<tr key={name} className="border-t border-border">
|
||||||
<th scope="row" className="py-1.5 font-medium text-neutral-400">
|
<th scope="row" className="py-1.5 font-medium text-fg-secondary">
|
||||||
{t(`destructive.count.${name}`)}
|
{t(`destructive.count.${name}`)}
|
||||||
</th>
|
</th>
|
||||||
<td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td>
|
<td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td>
|
||||||
@@ -172,8 +172,8 @@ export function DestructiveLaneModal({
|
|||||||
))}
|
))}
|
||||||
{/* Not part of `expect`: an estimate the server never verifies. */}
|
{/* Not part of `expect`: an estimate the server never verifies. */}
|
||||||
{purge && (
|
{purge && (
|
||||||
<tr className="border-t border-neutral-800">
|
<tr className="border-t border-border">
|
||||||
<th scope="row" className="py-1.5 font-medium text-neutral-400">
|
<th scope="row" className="py-1.5 font-medium text-fg-secondary">
|
||||||
{t("destructive.count.bytesEstimate")}
|
{t("destructive.count.bytesEstimate")}
|
||||||
</th>
|
</th>
|
||||||
<td className="py-1.5 text-right tabular-nums">
|
<td className="py-1.5 text-right tabular-nums">
|
||||||
@@ -185,19 +185,19 @@ export function DestructiveLaneModal({
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
{blocked && (
|
{blocked && (
|
||||||
<p className="mt-3 rounded border border-amber-700/60 bg-amber-950/30 px-2 py-1.5 text-xs text-amber-200">
|
<p className="mt-3 rounded border border-status-warning/60 bg-status-warning/30 px-2 py-1.5 text-xs text-status-warning">
|
||||||
{t(`destructive.blocked.${blocked}`)}
|
{t(`destructive.blocked.${blocked}`)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{purge?.activeSessionSkipped && (
|
{purge?.activeSessionSkipped && (
|
||||||
<p className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300">
|
<p className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary">
|
||||||
{t("destructive.notice.activeSessionSkipped")}
|
{t("destructive.notice.activeSessionSkipped")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{noticesFor(preflight).map((notice) => (
|
{noticesFor(preflight).map((notice) => (
|
||||||
<p
|
<p
|
||||||
key={notice}
|
key={notice}
|
||||||
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
|
className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary"
|
||||||
>
|
>
|
||||||
{t(`destructive.notice.${notice}`)}
|
{t(`destructive.notice.${notice}`)}
|
||||||
</p>
|
</p>
|
||||||
@@ -205,13 +205,13 @@ export function DestructiveLaneModal({
|
|||||||
{warningsFor(preflight).map((warning) => (
|
{warningsFor(preflight).map((warning) => (
|
||||||
<p
|
<p
|
||||||
key={warning}
|
key={warning}
|
||||||
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
|
className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary"
|
||||||
>
|
>
|
||||||
{t(`destructive.warning.${warning}`)}
|
{t(`destructive.warning.${warning}`)}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{requiresForce && (
|
{requiresForce && (
|
||||||
<label className="mt-3 flex items-start gap-2 text-xs text-amber-200">
|
<label className="mt-3 flex items-start gap-2 text-xs text-status-warning">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={force}
|
checked={force}
|
||||||
@@ -222,7 +222,7 @@ export function DestructiveLaneModal({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{action === "reset" && (
|
{action === "reset" && (
|
||||||
<p className="mt-3 text-xs text-neutral-400">{t("destructive.reset.survives")}</p>
|
<p className="mt-3 text-xs text-fg-secondary">{t("destructive.reset.survives")}</p>
|
||||||
)}
|
)}
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* @file A server-backed folder browser for the Add Lane modal's path inputs.
|
||||||
|
* Browsers refuse to expose an absolute filesystem path from a native folder
|
||||||
|
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||||
|
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
|
||||||
|
* up-one-level button and a click-to-descend list, since this tool is
|
||||||
|
* local-first and the server already trusts arbitrary local paths.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
|
export function FolderBrowseModal({
|
||||||
|
open,
|
||||||
|
initialPath,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||||
|
initialPath?: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async (path?: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setListing(await api.lanes.browse(path));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void load(initialPath);
|
||||||
|
// Only re-run when the modal actually opens - not on every initialPath
|
||||||
|
// keystroke in the field behind it.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("browse.title")}
|
||||||
|
>
|
||||||
|
<div className="border-b border-border p-3">
|
||||||
|
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||||
|
{listing?.path || "…"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-1">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{listing?.parent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(listing.parent!)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
|
..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{listing?.entries.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(entry.path)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{entry.isGitRepo ? (
|
||||||
|
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||||
|
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-ghost border border-border text-xs"
|
||||||
|
>
|
||||||
|
{t("destructive.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!listing}
|
||||||
|
onClick={() => {
|
||||||
|
if (listing) onSelect(listing.path);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("browse.select")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file One lane's card: title, stage badge with time-on-phase, progress bar,
|
* @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
|
||||||
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's
|
* facts, the "needs you" banner sourced from Claude Code's Notification hook,
|
||||||
* Notification hook, and the control row. A dead lane (its driving session went
|
* and the control row — which ends in the two deletions (lane, history) as
|
||||||
* silent while it should have been working) is called out loudly — that is the
|
* plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
|
||||||
* failure this view exists to catch. An "auto: <stage>" chip appears only when
|
* session went silent while it should have been working) is called out
|
||||||
* the server's detected stage is ahead of the agent's own declaration — when the
|
* loudly — that is the failure this view exists to catch. Stage, kind, and
|
||||||
* declaration leads or matches, it stays the sole headline.
|
* the "auto: <stage>" chip are NOT repeated here — this card only ever
|
||||||
|
* renders inside the Workspace lane-detail section, whose header above it
|
||||||
|
* already shows them.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,13 +15,23 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { Lane, LaneGitFacts } from "../../lib/types";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
|
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||||
|
|
||||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||||
* each call is three git subprocesses server-side, and a branch name does not
|
* each call is three git subprocesses server-side, and a branch name does not
|
||||||
* change on the timescale the lane list is polled at. */
|
* change on the timescale the lane list is polled at. */
|
||||||
const GIT_REFRESH_MS = 30_000;
|
const GIT_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
||||||
|
* because a stack dying is exactly what the user needs to see, and slower than
|
||||||
|
* the lane poll because each call opens a socket per declared port. */
|
||||||
|
const RUNTIME_REFRESH_MS = 10_000;
|
||||||
|
|
||||||
|
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
||||||
|
* as the git facts (slow, since lock state changes infrequently). */
|
||||||
|
const LOCKS_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lane's own working copy, fetched per card rather than folded into the
|
* The lane's own working copy, fetched per card rather than folded into the
|
||||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||||
@@ -53,10 +65,109 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
|||||||
return facts;
|
return facts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lane's own application stack. Same shape and same silence as the git
|
||||||
|
* facts above: a lane without a `.ccam/profile` simply has no runtime row, which
|
||||||
|
* is the common case and not an error worth a banner.
|
||||||
|
*
|
||||||
|
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
||||||
|
* poll interval.
|
||||||
|
*/
|
||||||
|
function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
|
||||||
|
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const read = () => {
|
||||||
|
api.lanes
|
||||||
|
.runtime(laneId)
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) setRuntime(r);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setRuntime({ available: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [laneId, bump]);
|
||||||
|
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks held by THIS lane, polled the same way runtime/git facts are.
|
||||||
|
*/
|
||||||
|
function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||||
|
const [locks, setLocks] = useState<NamedLock[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!laneSlot) return;
|
||||||
|
let alive = true;
|
||||||
|
const holder = `lane${laneSlot}`;
|
||||||
|
const read = () => {
|
||||||
|
api.locks
|
||||||
|
.list()
|
||||||
|
.then((data) => {
|
||||||
|
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* fails silently, same contract as the git/runtime pollers */
|
||||||
|
});
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [laneSlot]);
|
||||||
|
|
||||||
|
return locks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
||||||
|
|
||||||
|
function useLaneIntegrations(
|
||||||
|
laneId: number,
|
||||||
|
available: boolean
|
||||||
|
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
||||||
|
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!available) {
|
||||||
|
setState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
||||||
|
.then((results) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
INTEGRATION_NAMES.forEach((name, i) => {
|
||||||
|
next[name] = results[i]?.enabled ?? false;
|
||||||
|
});
|
||||||
|
setState(next);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setState(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [laneId, available]);
|
||||||
|
|
||||||
|
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
||||||
|
}
|
||||||
|
|
||||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||||
active: "bg-emerald-400",
|
active: "bg-status-success",
|
||||||
idle: "bg-neutral-500",
|
idle: "bg-surface-4",
|
||||||
dead: "bg-red-500",
|
dead: "bg-status-danger",
|
||||||
};
|
};
|
||||||
|
|
||||||
function since(sec: number | null): string {
|
function since(sec: number | null): string {
|
||||||
@@ -66,42 +177,141 @@ function since(sec: number | null): string {
|
|||||||
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the
|
|
||||||
* pipeline's node order. Unknown ids sort as "not found" (-1), so an unmatched
|
|
||||||
* detected stage never outranks a matched declaration. */
|
|
||||||
function detectionLeadsDeclaration(lane: Lane): boolean {
|
|
||||||
if (!lane.detected_stage) return false;
|
|
||||||
const ids = lane.pipeline_nodes.map((n) => n.id);
|
|
||||||
return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LaneCard({
|
export default function LaneCard({
|
||||||
lane,
|
lane,
|
||||||
onAction,
|
onAction,
|
||||||
|
childWorktrees,
|
||||||
|
onSelectLane,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||||
|
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
|
||||||
|
* when this lane is itself a source repo (typically an adopted one) that
|
||||||
|
* other lanes were provisioned as worktrees from. */
|
||||||
|
childWorktrees?: Lane[];
|
||||||
|
/** Jumps the Workspace page's selection to another lane's card. */
|
||||||
|
onSelectLane?: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||||
|
const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
|
||||||
|
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||||
|
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||||
const git = useLaneGitFacts(lane.id);
|
const git = useLaneGitFacts(lane.id);
|
||||||
|
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||||
|
const locks = useLaneLocks(lane.slot);
|
||||||
|
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
|
||||||
|
* that prop drives the lane's Claude run, and folding a second lifecycle into
|
||||||
|
* it would make "stop" ambiguous about which thing it stops.
|
||||||
|
*
|
||||||
|
* `up` answers 202 and keeps booting in the background, so the button stays
|
||||||
|
* busy until the server's `lane_runtime` message says the attempt finished —
|
||||||
|
* resolving the request is not the same as the stack being up.
|
||||||
|
*/
|
||||||
|
const runtimeAction = async (which: "up" | "down") => {
|
||||||
|
setRuntimeBusy(which);
|
||||||
|
setBootLine(null);
|
||||||
|
try {
|
||||||
|
if (which === "down") {
|
||||||
|
await api.lanes.down(lane.id);
|
||||||
|
setRuntimeBusy(null);
|
||||||
|
} else {
|
||||||
|
await api.lanes.up(lane.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The failure surfaces as the runtime row's lastError on the next read; a
|
||||||
|
// toast here would say the same thing twice.
|
||||||
|
setRuntimeBusy(null);
|
||||||
|
} finally {
|
||||||
|
setRuntimeBump((n) => n + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runLaneAction = async (
|
||||||
|
which: "agents" | "mcp" | "sync-check",
|
||||||
|
fn: () => Promise<string>
|
||||||
|
) => {
|
||||||
|
setLaneActionBusy(which);
|
||||||
|
setLaneActionResult(null);
|
||||||
|
try {
|
||||||
|
setLaneActionResult(await fn());
|
||||||
|
} catch (err) {
|
||||||
|
setLaneActionResult(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLaneActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentsInstall = () =>
|
||||||
|
runLaneAction("agents", async () => {
|
||||||
|
const result = await api.lanes.agentsInstall(lane.id);
|
||||||
|
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMcpSync = () =>
|
||||||
|
runLaneAction("mcp", async () => {
|
||||||
|
const result = await api.lanes.mcpSync(lane.id);
|
||||||
|
return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSyncCheck = () =>
|
||||||
|
runLaneAction("sync-check", async () => {
|
||||||
|
const result = await api.lanes.syncBaseCheck(lane.id);
|
||||||
|
if (result.code === 5 && result.collisions && result.collisions.length > 0) {
|
||||||
|
const c = result.collisions[0]!;
|
||||||
|
return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion });
|
||||||
|
}
|
||||||
|
return t("actions.syncCheckClean", {
|
||||||
|
count: result.devDelta?.length ?? 0,
|
||||||
|
overlap: result.overlap?.length ? result.overlap.join(", ") : "none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live boot feedback. A build can run for minutes, and a card showing only a
|
||||||
|
* disabled button through all of it reads as a hang. The hook's own output
|
||||||
|
* lines are the honest progress indicator.
|
||||||
|
*/
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
eventBus.subscribe((msg) => {
|
||||||
|
const data = msg.data as { laneId?: number; line?: string } | undefined;
|
||||||
|
if (!data || data.laneId !== lane.id) return;
|
||||||
|
if (msg.type === "lane_hook_output" && typeof data.line === "string") {
|
||||||
|
setBootLine(data.line);
|
||||||
|
}
|
||||||
|
if (msg.type === "lane_runtime") {
|
||||||
|
setRuntimeBusy(null);
|
||||||
|
setBootLine(null);
|
||||||
|
setRuntimeBump((n) => n + 1);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[lane.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
data-testid={`lane-card-${lane.id}`}
|
data-testid={`lane-card-${lane.id}`}
|
||||||
className="flex flex-col rounded-xl border border-neutral-800 bg-neutral-900/70 p-4 transition-colors hover:border-neutral-700"
|
className="flex flex-col rounded-xl border border-border bg-surface-4 p-4 transition-colors hover:border-border-light"
|
||||||
>
|
>
|
||||||
{/* Identity strip: which lane, and is it alive. Kept on one line and in
|
{/* Identity strip: which lane, and is it alive. Kept on one line and in
|
||||||
uppercase so a wall of cards can be scanned vertically. */}
|
uppercase so a wall of cards can be scanned vertically. */}
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500">
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||||
{t("cardId", { id: lane.id })}
|
{t("cardId", { id: lane.id })}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-neutral-300">
|
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||||
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||||
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
|
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
|
||||||
non-standard status rendered as the literal "status.foo".
|
non-standard status rendered as the literal "status.foo".
|
||||||
@@ -112,81 +322,131 @@ export default function LaneCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-neutral-50">
|
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-fg-primary">
|
||||||
{lane.title || lane.cwd}
|
{lane.title || lane.cwd}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Stage line: the declared stage, how far through, and how long it has
|
{/* Progress line: how far through the pipeline and how long the
|
||||||
been sitting there — the three facts that say whether a lane is
|
current stage has been sitting there. The stage's own name is
|
||||||
moving. The inferred chip sits beside them, never instead of them. */}
|
already in the Workspace header above; not repeated here. */}
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs">
|
<div className="mb-2 flex items-center gap-2 text-xs">
|
||||||
<span
|
|
||||||
data-testid="lane-stage"
|
|
||||||
className="rounded bg-neutral-800 px-2 py-0.5 font-medium text-neutral-200"
|
|
||||||
>
|
|
||||||
{lane.stage}
|
|
||||||
</span>
|
|
||||||
<div
|
<div
|
||||||
className={`h-1 flex-1 overflow-hidden rounded-full ${
|
className={`h-1 flex-1 overflow-hidden rounded-full ${
|
||||||
lane.progress > 0 ? "bg-neutral-800" : "bg-transparent"
|
lane.progress > 0 ? "bg-surface-2" : "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="lane-progress-fill"
|
data-testid="lane-progress-fill"
|
||||||
className="h-full rounded-full bg-blue-500 transition-[width]"
|
className="h-full rounded-full bg-blue-600 transition-[width]"
|
||||||
style={{ width: `${lane.progress}%` }}
|
style={{ width: `${lane.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{lane.progress > 0 && (
|
{lane.progress > 0 && (
|
||||||
<span className="tabular-nums text-neutral-400">{lane.progress}%</span>
|
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
||||||
)}
|
)}
|
||||||
<span className="tabular-nums text-neutral-600">{since(lane.stage_seconds)}</span>
|
<span className="tabular-nums text-fg-muted">{since(lane.stage_seconds)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
|
|
||||||
<span
|
|
||||||
className={`rounded px-1.5 py-0.5 ${
|
|
||||||
lane.kind === "managed"
|
|
||||||
? "bg-blue-500/15 text-blue-300"
|
|
||||||
: "bg-violet-500/15 text-violet-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(`kind.${lane.kind}`)}
|
|
||||||
</span>
|
|
||||||
{detectionLeadsDeclaration(lane) && (
|
|
||||||
<span
|
|
||||||
data-testid="lane-auto-stage"
|
|
||||||
title={lane.detected_signal || undefined}
|
|
||||||
className="rounded border border-dashed border-amber-500 px-1.5 py-0.5 text-amber-300"
|
|
||||||
>
|
|
||||||
{t("autoStage", { stage: lane.detected_stage })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{lane.ci_status && (
|
{lane.ci_status && (
|
||||||
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
|
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||||
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
||||||
CI {lane.ci_status}
|
CI {lane.ci_status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{lane.needs_action && (
|
{lane.needs_action && (
|
||||||
<div className="mb-3 rounded border border-amber-600/50 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
<div className="mb-3 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||||
⚠ {lane.needs_action}
|
⚠ {lane.needs_action}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<dl className="mb-3 space-y-1 font-mono text-[11px] text-neutral-400">
|
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
||||||
|
declare a profile. A port that drifted from `base + slot` is called
|
||||||
|
out — the number is otherwise predictable from the slot, and silently
|
||||||
|
serving on a different one is exactly the surprise worth flagging. */}
|
||||||
|
{runtime?.available && runtime.provisioned && (
|
||||||
|
<div
|
||||||
|
data-testid={`lane-runtime-${lane.id}`}
|
||||||
|
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
|
||||||
|
<span
|
||||||
|
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
|
||||||
|
data-testid="lane-runtime-state"
|
||||||
|
>
|
||||||
|
{runtime.healthy
|
||||||
|
? t("runtime.healthy")
|
||||||
|
: runtime.up
|
||||||
|
? t("runtime.partial")
|
||||||
|
: t("runtime.down")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Object.entries(runtime.ports).map(([name, info]) => (
|
||||||
|
<div key={name} className="flex items-center gap-1.5 truncate">
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||||
|
info.listening ? "bg-status-success" : "bg-surface-4"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-fg-secondary">{name}</span>
|
||||||
|
<span className="text-fg-muted">:{info.port ?? "—"}</span>
|
||||||
|
{info.port !== null && info.port !== info.expected && (
|
||||||
|
<span
|
||||||
|
className="truncate text-status-warning/80"
|
||||||
|
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
|
||||||
|
>
|
||||||
|
⚠ {info.expected}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* While a boot is in flight the hook's own latest line IS the
|
||||||
|
progress bar — a build can take minutes, and a disabled button
|
||||||
|
with nothing moving reads as a hang. */}
|
||||||
|
{bootLine !== null && (
|
||||||
|
<div
|
||||||
|
data-testid="lane-runtime-bootline"
|
||||||
|
className="mt-1 truncate text-fg-muted"
|
||||||
|
title={bootLine}
|
||||||
|
>
|
||||||
|
{bootLine}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bootLine === null && runtime.lastError && (
|
||||||
|
<div
|
||||||
|
className="mt-1 truncate text-status-danger/90"
|
||||||
|
title={runtime.lastError.message}
|
||||||
|
>
|
||||||
|
{runtime.lastError.code || "error"}: {runtime.lastError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locks.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
|
||||||
|
data-testid={`lane-locks-${lane.id}`}
|
||||||
|
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
||||||
|
>
|
||||||
|
🔒 {t("locks.held", { count: locks.length })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
|
||||||
{git?.available && (
|
{git?.available && (
|
||||||
<div data-testid="lane-git" className="space-y-1">
|
<div data-testid="lane-git" className="space-y-1">
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
⑂ {git.branch}
|
⑂ {git.branch}
|
||||||
<span className="ml-2 text-neutral-500">{git.head}</span>
|
<span className="ml-2 text-fg-muted">{git.head}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-neutral-500" title={git.subject}>
|
<div className="truncate text-fg-muted" title={git.subject}>
|
||||||
{git.subject}
|
{git.subject}
|
||||||
</div>
|
</div>
|
||||||
{(git.dirty > 0 || git.untracked > 0) && (
|
{(git.dirty > 0 || git.untracked > 0) && (
|
||||||
<div className="text-amber-400/80">
|
<div className="text-status-warning/80">
|
||||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -195,14 +455,57 @@ export default function LaneCard({
|
|||||||
{/* The lane's own recorded branch, shown only when git could not be
|
{/* The lane's own recorded branch, shown only when git could not be
|
||||||
read — otherwise it duplicates the live branch above. */}
|
read — otherwise it duplicates the live branch above. */}
|
||||||
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
||||||
<div className="truncate text-neutral-500" title={lane.cwd}>
|
<div className="truncate text-fg-muted" title={lane.cwd}>
|
||||||
{lane.cwd}
|
{lane.cwd}
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{childWorktrees && childWorktrees.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="lane-child-worktrees"
|
||||||
|
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
<div className="text-fg-muted">
|
||||||
|
{t("worktrees.heading", { count: childWorktrees.length })}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{childWorktrees.map((w) => (
|
||||||
|
<li key={w.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectLane?.(w.id)}
|
||||||
|
className="truncate text-left text-blue-400 hover:underline"
|
||||||
|
title={w.cwd}
|
||||||
|
>
|
||||||
|
#{w.id} {w.title || w.cwd} · {w.status}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{integrations && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
|
||||||
|
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
data-testid={`lane-integration-${name}`}
|
||||||
|
className={`rounded-full px-2 py-0.5 ${
|
||||||
|
integrations[name]
|
||||||
|
? "bg-status-success/10 text-status-success"
|
||||||
|
: "bg-surface-2 text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`integrations.${name}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||||
in one grid row still line their buttons up. */}
|
in one grid row still line their buttons up. */}
|
||||||
<div className="mt-auto flex items-center gap-1 border-t border-neutral-800 pt-2 text-xs">
|
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
|
||||||
{(["start", "stop", "clear"] as const).map((a) => (
|
{(["start", "stop", "clear"] as const).map((a) => (
|
||||||
<button
|
<button
|
||||||
key={a}
|
key={a}
|
||||||
@@ -214,17 +517,110 @@ export default function LaneCard({
|
|||||||
}}
|
}}
|
||||||
className={`rounded px-2 py-1 transition-colors ${
|
className={`rounded px-2 py-1 transition-colors ${
|
||||||
a === "start"
|
a === "start"
|
||||||
? "bg-blue-500/15 text-blue-300 hover:bg-blue-500/25"
|
? "bg-blue-600/15 text-blue-400 hover:bg-blue-600/25"
|
||||||
: "text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
|
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
title={a === "start" ? t("tooltipStart") : undefined}
|
title={a === "start" ? t("tooltipStart") : undefined}
|
||||||
>
|
>
|
||||||
{t(`action.${a}`)}
|
{t(`action.${a}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* Destructive verbs sit behind a menu so the card is not a wall of
|
{/* Stack controls, only for a lane whose repo declares a profile.
|
||||||
red. Red is kept for the items inside, where it means something. */}
|
Separate from start/stop above: those drive the lane's Claude run,
|
||||||
<div className="relative ml-auto">
|
these drive the application it is working on. */}
|
||||||
|
{runtime?.available && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-runtime-toggle"
|
||||||
|
disabled={runtimeBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
title={t("runtime.toggleTitle")}
|
||||||
|
>
|
||||||
|
{runtimeBusy
|
||||||
|
? t(`runtime.busy.${runtimeBusy}`)
|
||||||
|
: runtime.provisioned && runtime.up
|
||||||
|
? t("runtime.stop")
|
||||||
|
: t("runtime.boot")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{runtime?.available && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-agents-install"
|
||||||
|
disabled={laneActionBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleAgentsInstall();
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{laneActionBusy === "agents" ? t("actions.busy") : t("actions.agentsInstall")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-mcp-sync"
|
||||||
|
disabled={laneActionBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleMcpSync();
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{laneActionBusy === "mcp" ? t("actions.busy") : t("actions.mcpSync")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-sync-check"
|
||||||
|
disabled={laneActionBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleSyncCheck();
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{laneActionBusy === "sync-check" ? t("actions.busy") : t("actions.syncCheck")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Deleting the lane and deleting its history are each their own
|
||||||
|
button, by request: hiding "delete" behind a ⋯ made it unfindable,
|
||||||
|
and the one label that read as "delete" was `clear`. Neither fires
|
||||||
|
directly — both open DestructiveLaneModal, which shows the exact
|
||||||
|
counts and demands a confirmation. `reset` stays in the menu; it
|
||||||
|
is the rare one and only exists for managed lanes. */}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-action-purge"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDestructiveAction("purge");
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
|
||||||
|
>
|
||||||
|
{t("action.purge")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-action-remove"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDestructiveAction("remove");
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
|
||||||
|
>
|
||||||
|
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
|
||||||
|
</button>
|
||||||
|
{/* Adopted lanes have no worktree to reset, so the menu would be
|
||||||
|
empty — it is not rendered at all rather than opening onto
|
||||||
|
nothing. */}
|
||||||
|
{lane.kind === "managed" && (
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-more"
|
data-testid="lane-more"
|
||||||
@@ -234,7 +630,7 @@ export default function LaneCard({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setMenuOpen((v) => !v);
|
setMenuOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
className="rounded px-2 py-1 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
|
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
|
||||||
title={t("moreActions")}
|
title={t("moreActions")}
|
||||||
>
|
>
|
||||||
⋯
|
⋯
|
||||||
@@ -242,9 +638,8 @@ export default function LaneCard({
|
|||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 py-1 shadow-lg shadow-black/40"
|
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
|
||||||
>
|
>
|
||||||
{lane.kind === "managed" && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -254,41 +649,25 @@ export default function LaneCard({
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setDestructiveAction("reset");
|
setDestructiveAction("reset");
|
||||||
}}
|
}}
|
||||||
className="block w-full px-3 py-1.5 text-left text-amber-300 hover:bg-amber-500/10"
|
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
|
||||||
>
|
>
|
||||||
{t("action.reset")}
|
{t("action.reset")}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-testid="lane-action-remove"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuOpen(false);
|
|
||||||
setDestructiveAction("remove");
|
|
||||||
}}
|
|
||||||
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-testid="lane-action-purge"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuOpen(false);
|
|
||||||
setDestructiveAction("purge");
|
|
||||||
}}
|
|
||||||
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
{t("action.purge")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{laneActionResult && (
|
||||||
|
<p
|
||||||
|
data-testid="lane-action-result"
|
||||||
|
className="mt-1 truncate text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
{laneActionResult}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{destructiveAction && (
|
{destructiveAction && (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file The compact lane tile used in the Workspace carousel. It carries only
|
* @file The compact lane tile used in the Workspace's vertical lane list. It
|
||||||
* what you need to pick a lane — which lane, is it alive, what stage, how far —
|
* carries only what you need to pick a lane — which lane, is it alive, what
|
||||||
* because the full card, its controls and its working-copy facts live in the
|
* stage, how far — because the full card, its controls and its working-copy
|
||||||
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
|
* facts live in the detail panel beside it. Keeping the tile small and full
|
||||||
* scannable in one horizontal row.
|
* width is what lets many lanes stay scannable in one scrolling column.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
|
||||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||||
active: "bg-emerald-400",
|
active: "bg-status-success",
|
||||||
idle: "bg-neutral-500",
|
idle: "bg-surface-4",
|
||||||
dead: "bg-red-500",
|
dead: "bg-status-danger",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Whether the inferred stage sits ahead of the declared one in node order. */
|
/** Whether the inferred stage sits ahead of the declared one in node order. */
|
||||||
@@ -42,49 +42,49 @@ export default function LaneStripCard({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
title={lane.cwd}
|
title={lane.cwd}
|
||||||
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left transition-colors ${
|
className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||||
selected
|
selected
|
||||||
? "border-blue-400/70 bg-blue-500/[0.07]"
|
? "border-accent bg-accent/10"
|
||||||
: "border-neutral-800 bg-neutral-900/60 hover:border-neutral-700"
|
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-1.5 flex items-center gap-1.5">
|
<div className="mb-1.5 flex items-center gap-1.5">
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||||
{t("cardId", { id: lane.id })}
|
{t("cardId", { id: lane.id })}
|
||||||
</span>
|
</span>
|
||||||
{lane.needs_action && (
|
{lane.needs_action && (
|
||||||
<span className="ml-auto text-amber-400" title={lane.needs_action}>
|
<span className="ml-auto text-status-warning" title={lane.needs_action}>
|
||||||
⚠
|
⚠
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2 truncate text-[13px] font-medium text-neutral-100">
|
<div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
|
||||||
{lane.title || lane.cwd}
|
{lane.title || lane.cwd}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px]">
|
<div className="flex items-center gap-1.5 text-[11px]">
|
||||||
<span className="truncate rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
|
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||||
{lane.stage}
|
{lane.stage}
|
||||||
</span>
|
</span>
|
||||||
{detectionLeads(lane) && (
|
{detectionLeads(lane) && (
|
||||||
<span
|
<span
|
||||||
data-testid={`lane-tile-auto-${lane.id}`}
|
data-testid={`lane-tile-auto-${lane.id}`}
|
||||||
title={lane.detected_signal || undefined}
|
title={lane.detected_signal || undefined}
|
||||||
className="shrink-0 rounded border border-dashed border-amber-500 px-1 text-amber-300"
|
className="shrink-0 rounded border border-dashed border-status-warning/50 px-1 text-status-warning"
|
||||||
>
|
>
|
||||||
{lane.detected_stage}
|
{lane.detected_stage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{lane.progress > 0 && (
|
{lane.progress > 0 && (
|
||||||
<span className="ml-auto shrink-0 tabular-nums text-neutral-500">{lane.progress}%</span>
|
<span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lane.progress > 0 && (
|
{lane.progress > 0 && (
|
||||||
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-neutral-800">
|
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
|
||||||
<div className="h-full rounded-full bg-blue-500" style={{ width: `${lane.progress}%` }} />
|
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,28 +2,33 @@
|
|||||||
* @file The lane pipeline map: a horizontal chain of stage nodes coloured by
|
* @file The lane pipeline map: a horizontal chain of stage nodes coloured by
|
||||||
* state. Layout is computed from the node list (flex + connectors), never from
|
* state. Layout is computed from the node list (flex + connectors), never from
|
||||||
* hardcoded coordinates, so a lane can use a longer or shorter template without
|
* hardcoded coordinates, so a lane can use a longer or shorter template without
|
||||||
* touching this component. "passed without evidence" is deliberately its own
|
* touching this component. Every state but `current` shares one visual
|
||||||
* colour: a stage the agent claimed but left no artifact for is not the same as
|
* language — coloured border, coloured text, a translucent wash of the same
|
||||||
* a stage that is genuinely done. A `detected` node (the server's heuristic saw
|
* colour — so status colour means the same thing everywhere in the app, not
|
||||||
* tool-event evidence but the agent never declared it) gets a FOURTH treatment —
|
* a different shade per component. `current` is the sole solid fill, in the
|
||||||
* dashed amber, overriding whatever `state` it carries — because it must never
|
* app's own accent colour: the one state that gets to look bolder than the
|
||||||
* be mistaken for the solid green of a real "done".
|
* rest, because it answers "where am I right now". "passed without
|
||||||
|
* evidence" is its own colour: a stage the agent claimed but left no
|
||||||
|
* artifact for is not the same as a stage that is genuinely done. A
|
||||||
|
* `detected` node (the server's heuristic saw tool-event evidence but the
|
||||||
|
* agent never declared it) gets a FOURTH treatment — thin dashed warning,
|
||||||
|
* no fill — because it must never be mistaken for a real declaration.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { LaneNode } from "../../lib/types";
|
import type { LaneNode } from "../../lib/types";
|
||||||
|
|
||||||
const STATE_CLASS: Record<LaneNode["state"], string> = {
|
const STATE_CLASS: Record<LaneNode["state"], string> = {
|
||||||
done: "border-emerald-500 text-emerald-400 bg-emerald-500/10",
|
done: "border-status-success/60 text-status-success bg-status-success/10",
|
||||||
current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40",
|
current: "border-accent bg-accent text-white ring-2 ring-accent/30 shadow-sm",
|
||||||
"passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10",
|
"passed-no-evidence": "border-status-warning/60 text-status-warning bg-status-warning/10",
|
||||||
failed: "border-red-500 text-red-400 bg-red-500/10",
|
failed: "border-status-danger/60 text-status-danger bg-status-danger/10",
|
||||||
pending: "border-neutral-700 text-neutral-500 bg-transparent",
|
pending: "border-border-light text-fg-muted bg-transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dashed border distinguishes an inferred stage from every other class above,
|
// Thin dashed border and no fill keep an inference visually lighter than
|
||||||
// including the solid amber of "passed-no-evidence" — never let it read as done.
|
// every outlined state above — so it never reads as more certain than a claim.
|
||||||
const DETECTED_CLASS = "border-dashed border-amber-400 text-amber-300 bg-amber-500/5";
|
const DETECTED_CLASS = "border-dashed border-status-warning/50 text-status-warning bg-transparent";
|
||||||
|
|
||||||
export default function PipelineMap({
|
export default function PipelineMap({
|
||||||
nodes,
|
nodes,
|
||||||
@@ -32,7 +37,7 @@ export default function PipelineMap({
|
|||||||
nodes: LaneNode[];
|
nodes: LaneNode[];
|
||||||
detectedSignal?: string | null;
|
detectedSignal?: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (!nodes.length) return <div className="text-xs text-neutral-500">no pipeline</div>;
|
if (!nodes.length) return <div className="text-xs text-fg-muted">no pipeline</div>;
|
||||||
return (
|
return (
|
||||||
// The map spans the panel: every node takes an equal share and the
|
// The map spans the panel: every node takes an equal share and the
|
||||||
// connectors absorb the slack, so the pipeline reads as one track across
|
// connectors absorb the slack, so the pipeline reads as one track across
|
||||||
@@ -54,7 +59,7 @@ export default function PipelineMap({
|
|||||||
<span className="shrink-0 text-[13px] leading-none">{n.icon}</span>
|
<span className="shrink-0 text-[13px] leading-none">{n.icon}</span>
|
||||||
<span className="truncate">{n.label}</span>
|
<span className="truncate">{n.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{i < nodes.length - 1 && <div className="h-px w-2 shrink-0 bg-neutral-700 sm:w-3" />}
|
{i < nodes.length - 1 && <div className="h-px w-2 shrink-0 bg-surface-3 sm:w-3" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file AddLaneModal.test.tsx
|
* @file AddLaneModal.test.tsx
|
||||||
* @description Pins the "+ Add lane" flow after it was rebuilt around a source
|
* @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
|
||||||
* repo instead of an existing folder: picking or typing a repo path triggers a
|
* pick a source repo, fork a branch, type a new branch name, submit through
|
||||||
* branch lookup, the base-branch picker only appears once that lookup resolves,
|
* the provisioning endpoint) and "Repo" (adopt a directory as-is through
|
||||||
* confirm submits through the provisioning endpoint (not the adopt/ensure one),
|
* `ensure`, no branch fields). Also covers the branch-lookup debounce, the
|
||||||
* an unresolvable path degrades to a quiet hint instead of blocking the form,
|
* unresolvable-path degrade, server-error handling, the auto-setup summary,
|
||||||
* and a server error surfaces instead of closing the modal.
|
* the provisioning-wait race, and the folder-browse modal.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,16 +18,33 @@ import type { CwdSuggestion } from "../../../lib/api";
|
|||||||
import type { Lane } from "../../../lib/types";
|
import type { Lane } from "../../../lib/types";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { lanes: { branches: vi.fn(), worktree: vi.fn() } },
|
api: {
|
||||||
|
lanes: {
|
||||||
|
branches: vi.fn(),
|
||||||
|
pipelines: vi.fn(),
|
||||||
|
worktree: vi.fn(),
|
||||||
|
ensure: vi.fn(),
|
||||||
|
browse: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
profileInit: vi.fn(),
|
||||||
|
agentsInstall: vi.fn(),
|
||||||
|
mcpSync: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function laneFixture(over: Partial<Lane> = {}): Lane {
|
function laneFixture(over: Partial<Lane> = {}): Lane {
|
||||||
|
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||||
|
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||||
|
// base object below still lists all of them, so the cast asserts what the
|
||||||
|
// literal already proves.
|
||||||
return {
|
return {
|
||||||
id: 9,
|
id: 9,
|
||||||
title: "",
|
title: "",
|
||||||
cwd: "/lanes/repo__feature",
|
cwd: "/lanes/repo__feature",
|
||||||
branch: "feat/feature",
|
branch: "feat/feature",
|
||||||
kind: "managed",
|
kind: "managed",
|
||||||
|
source_repo: null,
|
||||||
pipeline: "default",
|
pipeline: "default",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
@@ -48,8 +65,10 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...over,
|
...over,
|
||||||
};
|
} as Lane;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUGGESTIONS: CwdSuggestion[] = [
|
const SUGGESTIONS: CwdSuggestion[] = [
|
||||||
@@ -59,13 +78,7 @@ const SUGGESTIONS: CwdSuggestion[] = [
|
|||||||
|
|
||||||
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||||
return render(
|
return render(
|
||||||
<AddLaneModal
|
<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
|
||||||
open
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onAdded={vi.fn()}
|
|
||||||
cwdSuggestions={SUGGESTIONS}
|
|
||||||
{...over}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +90,51 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
|
|||||||
await user.click(el);
|
await user.click(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fills the default "Worktree" mode's form up through a resolved branch
|
||||||
|
* list, title, and new-branch name - everything Add lane needs to enable. */
|
||||||
|
async function fillWorktreeForm(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
{ repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
|
||||||
|
) {
|
||||||
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
|
await focusField(user, repoField);
|
||||||
|
await user.type(repoField, repo);
|
||||||
|
await screen.findByLabelText("Branch to fork from");
|
||||||
|
await user.type(screen.getByLabelText("Title"), title);
|
||||||
|
await user.type(screen.getByLabelText("New branch name"), branch);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.lanes.branches).mockReset();
|
vi.mocked(api.lanes.branches).mockReset();
|
||||||
|
vi.mocked(api.lanes.pipelines)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({
|
||||||
|
pipelines: [
|
||||||
|
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||||
|
{
|
||||||
|
id: "ship-feature",
|
||||||
|
name: "Ship feature (lane pipeline)",
|
||||||
|
nodes: new Array(16).fill({ id: "n" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
vi.mocked(api.lanes.worktree).mockReset();
|
vi.mocked(api.lanes.worktree).mockReset();
|
||||||
|
vi.mocked(api.lanes.ensure).mockReset();
|
||||||
|
vi.mocked(api.lanes.browse).mockReset();
|
||||||
|
// Provisioning finishes instantly by default - tests that care about the
|
||||||
|
// provisioning-in-progress race override this per-test.
|
||||||
|
vi.mocked(api.lanes.get)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
|
||||||
|
vi.mocked(api.lanes.profileInit)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||||
|
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||||
|
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal", () => {
|
describe("AddLaneModal — worktree mode (default)", () => {
|
||||||
it("disables confirm until a repo, a title, and a resolved branch list are all present", () => {
|
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
@@ -100,7 +151,9 @@ describe("AddLaneModal", () => {
|
|||||||
await focusField(user, repoField);
|
await focusField(user, repoField);
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
await user.type(repoField, "/Users/tester/projects/repo");
|
||||||
|
|
||||||
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo"));
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")
|
||||||
|
);
|
||||||
const base = await screen.findByLabelText("Branch to fork from");
|
const base = await screen.findByLabelText("Branch to fork from");
|
||||||
expect(base).toHaveValue("main"); // the repo's current branch is preselected
|
expect(base).toHaveValue("main"); // the repo's current branch is preselected
|
||||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
|
||||||
@@ -121,7 +174,7 @@ describe("AddLaneModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits through the worktree provisioning endpoint, not ensure", async () => {
|
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||||
const onAdded = vi.fn();
|
const onAdded = vi.fn();
|
||||||
@@ -129,11 +182,7 @@ describe("AddLaneModal", () => {
|
|||||||
renderModal({ onClose, onAdded });
|
renderModal({ onClose, onAdded });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
|
||||||
await focusField(user, repoField);
|
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
|
||||||
await screen.findByLabelText("Branch to fork from");
|
|
||||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -141,10 +190,16 @@ describe("AddLaneModal", () => {
|
|||||||
sourceRepo: "/Users/tester/projects/repo",
|
sourceRepo: "/Users/tester/projects/repo",
|
||||||
title: "New feature",
|
title: "New feature",
|
||||||
base: "main",
|
base: "main",
|
||||||
|
branch: "feat/new-feature",
|
||||||
|
pipeline: "default",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" }));
|
expect(onAdded).toHaveBeenCalledWith(
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||||
|
);
|
||||||
|
// The modal stays open showing the setup summary until dismissed - it
|
||||||
|
// does not close itself just because the lane was added.
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||||
@@ -154,11 +209,7 @@ describe("AddLaneModal", () => {
|
|||||||
renderModal({ onClose });
|
renderModal({ onClose });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
await fillWorktreeForm(user, { title: "New feature" });
|
||||||
await focusField(user, repoField);
|
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
|
||||||
await screen.findByLabelText("Branch to fork from");
|
|
||||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
|
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
|
||||||
@@ -169,4 +220,222 @@ describe("AddLaneModal", () => {
|
|||||||
renderModal({ open: false });
|
renderModal({ open: false });
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
renderModal({ onAdded });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
|
||||||
|
expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
|
||||||
|
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still calls onAdded even when every setup call fails", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
|
||||||
|
vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
|
||||||
|
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onAdded, onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user, { title: "demo2" });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the setup summary and lets the user dismiss it manually", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user, { title: "demo3" });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Setup")).toBeInTheDocument();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
|
||||||
|
if (!dismissButton) throw new Error("Cancel button not found");
|
||||||
|
await user.click(dismissButton);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
// First poll still provisioning, second poll reports the background
|
||||||
|
// `git worktree add` failed.
|
||||||
|
vi.mocked(api.lanes.get)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
|
||||||
|
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
renderModal({ onAdded });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user, { title: "demo4" });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
|
||||||
|
expect(api.lanes.profileInit).not.toHaveBeenCalled();
|
||||||
|
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
|
||||||
|
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AddLaneModal — repo mode (adopt)", () => {
|
||||||
|
it("hides the branch fields and enables confirm on a path alone", async () => {
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||||
|
const dirField = screen.getByLabelText("Directory");
|
||||||
|
await focusField(user, dirField);
|
||||||
|
await user.type(dirField, "/Users/tester/projects/repo");
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||||
|
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText("New branch name")).toBeNull();
|
||||||
|
expect(api.lanes.branches).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits through ensure, not worktree, and closes immediately", async () => {
|
||||||
|
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||||
|
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
|
||||||
|
created: true,
|
||||||
|
});
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onAdded, onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||||
|
const dirField = screen.getByLabelText("Directory");
|
||||||
|
await focusField(user, dirField);
|
||||||
|
await user.type(dirField, "/Users/tester/projects/repo");
|
||||||
|
await user.type(screen.getByLabelText("Title"), "main repo");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||||
|
cwd: "/Users/tester/projects/repo",
|
||||||
|
title: "main repo",
|
||||||
|
pipeline: "default",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(api.lanes.worktree).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
|
||||||
|
// Repo mode never runs the profile/agents/mcp setup summary - it should
|
||||||
|
// close right away like the old adopt flow did.
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AddLaneModal — folder browse", () => {
|
||||||
|
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||||
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
|
path: "/Users/tester",
|
||||||
|
parent: "/Users",
|
||||||
|
entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
|
||||||
|
});
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle("Browse for a folder"));
|
||||||
|
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("projects"));
|
||||||
|
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||||
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
|
path: "/Users/tester",
|
||||||
|
parent: null,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle("Browse for a folder"));
|
||||||
|
await screen.findByRole("dialog", { name: "Browse for a folder" });
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AddLaneModal — pipeline template", () => {
|
||||||
|
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||||
|
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||||
|
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||||
|
// any screen.
|
||||||
|
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||||
|
lane: laneFixture({ status: "idle" }),
|
||||||
|
created: true,
|
||||||
|
});
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||||
|
const select = await screen.findByLabelText("Pipeline template");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const repoField = screen.getByLabelText("Directory");
|
||||||
|
await focusField(user, repoField);
|
||||||
|
await user.type(repoField, "/Users/tester/projects/repo");
|
||||||
|
await user.selectOptions(select, "ship-feature");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ pipeline: "ship-feature" })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||||
|
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const select = await screen.findByLabelText("Pipeline template");
|
||||||
|
await waitFor(() => expect(select).toHaveValue("default"));
|
||||||
|
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { DestructiveLaneModal } from "../DestructiveLaneModal";
|
|||||||
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
|
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
|
||||||
|
|
||||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||||
|
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||||
|
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||||
|
// base object below still lists all of them, so the cast asserts what the
|
||||||
|
// literal already proves.
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "demo",
|
title: "demo",
|
||||||
@@ -47,8 +51,10 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
} as Lane;
|
||||||
}
|
}
|
||||||
|
|
||||||
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
|
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
|
||||||
|
|||||||
@@ -9,30 +9,56 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import LaneCard from "../LaneCard";
|
import LaneCard from "../LaneCard";
|
||||||
import type { Lane } from "../../../lib/types";
|
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) },
|
lanes: {
|
||||||
|
git: vi.fn(),
|
||||||
|
runtime: vi.fn(),
|
||||||
|
up: vi.fn(),
|
||||||
|
down: vi.fn(),
|
||||||
|
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||||
|
integration: vi.fn(),
|
||||||
|
agentsInstall: vi.fn(),
|
||||||
|
mcpSync: vi.fn(),
|
||||||
|
syncBaseCheck: vi.fn(),
|
||||||
|
},
|
||||||
|
locks: {
|
||||||
|
list: vi.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.lanes.git).mockReset();
|
vi.mocked(api.lanes.git).mockReset();
|
||||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||||
|
// A lane with no profile is the default fixture: most lanes never run a
|
||||||
|
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||||
|
vi.mocked(api.lanes.runtime).mockReset();
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
|
vi.mocked(api.lanes.integration).mockReset();
|
||||||
|
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
|
||||||
});
|
});
|
||||||
|
vi.mocked(api.locks.list).mockReset();
|
||||||
|
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||||
|
|
||||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||||
|
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||||
|
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||||
|
// base object below still lists all of them, so the cast asserts what the
|
||||||
|
// literal already proves.
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "demo",
|
title: "demo",
|
||||||
cwd: "/work/demo",
|
cwd: "/work/demo",
|
||||||
branch: "lane/demo",
|
branch: "lane/demo",
|
||||||
kind: "adopted",
|
kind: "adopted",
|
||||||
|
source_repo: null,
|
||||||
pipeline: "default",
|
pipeline: "default",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
@@ -53,8 +79,10 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
} as Lane;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("LaneCard status badge", () => {
|
describe("LaneCard status badge", () => {
|
||||||
@@ -67,6 +95,31 @@ describe("LaneCard status badge", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LaneCard child worktrees", () => {
|
||||||
|
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
||||||
|
const onSelectLane = vi.fn();
|
||||||
|
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
|
||||||
|
render(
|
||||||
|
<LaneCard
|
||||||
|
lane={makeLane({ id: 1 })}
|
||||||
|
onAction={vi.fn()}
|
||||||
|
childWorktrees={[worktree]}
|
||||||
|
onSelectLane={onSelectLane}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
|
||||||
|
expect(onSelectLane).toHaveBeenCalledWith(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when there are no child worktrees", () => {
|
||||||
|
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
|
||||||
|
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const pipelineNodes: Lane["pipeline_nodes"] = [
|
const pipelineNodes: Lane["pipeline_nodes"] = [
|
||||||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
||||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||||
@@ -74,52 +127,6 @@ const pipelineNodes: Lane["pipeline_nodes"] = [
|
|||||||
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
|
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("LaneCard auto: chip", () => {
|
|
||||||
it("shows the auto chip when the detected stage is ahead of the declared stage", () => {
|
|
||||||
render(
|
|
||||||
<LaneCard
|
|
||||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "tests" })}
|
|
||||||
onAction={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.getByText("auto: tests")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the auto chip when the detected stage matches the declared stage", () => {
|
|
||||||
render(
|
|
||||||
<LaneCard
|
|
||||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "plan" })}
|
|
||||||
onAction={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("auto: plan")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the auto chip when the detected stage trails the declared stage", () => {
|
|
||||||
render(
|
|
||||||
<LaneCard
|
|
||||||
lane={makeLane({
|
|
||||||
stage: "implement",
|
|
||||||
pipeline_nodes: pipelineNodes,
|
|
||||||
detected_stage: "intake",
|
|
||||||
})}
|
|
||||||
onAction={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("auto: intake")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the auto chip when nothing is detected", () => {
|
|
||||||
render(
|
|
||||||
<LaneCard
|
|
||||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: null })}
|
|
||||||
onAction={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.queryByText(/^auto:/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("LaneCard rebuilt layout", () => {
|
describe("LaneCard rebuilt layout", () => {
|
||||||
const full = (over: Partial<Lane> = {}) =>
|
const full = (over: Partial<Lane> = {}) =>
|
||||||
makeLane({
|
makeLane({
|
||||||
@@ -141,9 +148,8 @@ describe("LaneCard rebuilt layout", () => {
|
|||||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the declared stage, the progress percentage and the time on stage", () => {
|
it("shows the progress percentage and the time on stage", () => {
|
||||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||||
expect(screen.getByTestId("lane-stage").textContent).toBe("plan");
|
|
||||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||||
expect(screen.getByText("2m 32s")).toBeInTheDocument();
|
expect(screen.getByText("2m 32s")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -165,16 +171,26 @@ describe("LaneCard rebuilt layout", () => {
|
|||||||
expect(onAction).toHaveBeenCalledWith("stop");
|
expect(onAction).toHaveBeenCalledWith("stop");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the destructive verbs out of the card until the menu is opened", async () => {
|
it("shows both deletions as buttons, and keeps reset behind the menu", async () => {
|
||||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||||
// A wall of red buttons makes none of them read as the dangerous one, so
|
// Deleting the lane and deleting its history are the two the user goes
|
||||||
// reset/remove/purge live behind the ⋯ menu.
|
// looking for, so they are visible without opening anything. Reset is not.
|
||||||
|
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("lane-action-purge")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||||
expect(screen.queryByTestId("lane-action-remove")).toBeNull();
|
|
||||||
|
|
||||||
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
||||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
it("routes both deletions through the modal rather than firing them", async () => {
|
||||||
|
const onAction = vi.fn();
|
||||||
|
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByTestId("lane-action-remove"));
|
||||||
|
expect(onAction).not.toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByTestId("lane-action-purge"));
|
||||||
|
expect(onAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes reset through the confirmation modal rather than firing it", async () => {
|
it("routes reset through the confirmation modal rather than firing it", async () => {
|
||||||
@@ -186,16 +202,18 @@ describe("LaneCard rebuilt layout", () => {
|
|||||||
expect(onAction).not.toHaveBeenCalled();
|
expect(onAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers reset only for a managed lane", async () => {
|
it("offers reset only for a managed lane, and drops the menu entirely without it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||||
await user.click(screen.getByTestId("lane-more"));
|
await user.click(screen.getByTestId("lane-more"));
|
||||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
|
// An adopted lane has nothing left in the menu, so there is no ⋯ to open.
|
||||||
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
||||||
await user.click(screen.getByTestId("lane-more"));
|
expect(screen.queryByTestId("lane-more")).toBeNull();
|
||||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||||
|
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,3 +278,206 @@ describe("LaneCard git block", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LaneCard — the lane's own application stack", () => {
|
||||||
|
const upRuntime = {
|
||||||
|
available: true as const,
|
||||||
|
provisioned: true as const,
|
||||||
|
slot: 3,
|
||||||
|
kind: "managed" as const,
|
||||||
|
hooks: ["boot", "health"],
|
||||||
|
profileDir: "/work/demo/.ccam/profile",
|
||||||
|
services: [{ name: "web", pid: 4242, alive: true }],
|
||||||
|
ports: { api: { port: 8003, expected: 8003, listening: true } },
|
||||||
|
steppedAside: false,
|
||||||
|
up: true,
|
||||||
|
healthy: true,
|
||||||
|
logs: ["boot.log"],
|
||||||
|
logDir: "/lanes/.state/lane3/logs",
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows nothing at all for a lane whose repo declares no profile", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
|
render(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("no profile")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists each declared port with its listening state", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(":8003")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a port that stepped aside from its base, showing the expected number", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
...upRuntime,
|
||||||
|
steppedAside: true,
|
||||||
|
ports: { api: { port: 8103, expected: 8003, listening: true } },
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(":8103")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/8003/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the stack through /down, never through the run's onAction prop", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
||||||
|
vi.mocked(api.lanes.down).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
killed: [4242],
|
||||||
|
runtime: { available: false },
|
||||||
|
});
|
||||||
|
const onAction = vi.fn();
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={onAction} />);
|
||||||
|
|
||||||
|
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1));
|
||||||
|
expect(api.lanes.up).not.toHaveBeenCalled();
|
||||||
|
expect(onAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boots a provisioned-but-down lane and stays busy past the 202", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
...upRuntime,
|
||||||
|
services: [{ name: "web", pid: 4242, alive: false }],
|
||||||
|
ports: { api: { port: 8003, expected: 8003, listening: false } },
|
||||||
|
up: false,
|
||||||
|
healthy: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 });
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1));
|
||||||
|
// The request resolving is not the stack being up: the server answered 202
|
||||||
|
// and is still booting, so the button must not go idle yet.
|
||||||
|
await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the last boot error when nothing is streaming", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
...upRuntime,
|
||||||
|
up: false,
|
||||||
|
healthy: false,
|
||||||
|
lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LaneCard — named locks", () => {
|
||||||
|
it("shows a lock badge when this lane holds a named lock", async () => {
|
||||||
|
vi.mocked(api.locks.list).mockResolvedValue({
|
||||||
|
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/lock/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no lock badge when locks belong to a different lane", async () => {
|
||||||
|
vi.mocked(api.locks.list).mockResolvedValue({
|
||||||
|
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||||
|
// keeps the double from having to restate a shape the component never
|
||||||
|
// touches here.
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
available: true as const,
|
||||||
|
provisioned: true as const,
|
||||||
|
up: false,
|
||||||
|
slot: 1,
|
||||||
|
profileDir: "/work/demo/.ccam/profile",
|
||||||
|
ports: {},
|
||||||
|
} as unknown as LaneRuntime);
|
||||||
|
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
||||||
|
Promise.resolve({ enabled: name === "tracker" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows integration badges reflecting each toggle's state", async () => {
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const tracker = await screen.findByTestId("lane-integration-tracker");
|
||||||
|
const devQc = await screen.findByTestId("lane-integration-dev_qc");
|
||||||
|
expect(tracker.className).toContain("status-success");
|
||||||
|
expect(devQc.className).not.toContain("status-success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Install agents calls the API and shows the result", async () => {
|
||||||
|
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
||||||
|
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-agents-install");
|
||||||
|
await userEvent.click(button);
|
||||||
|
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Sync MCP calls the API and shows the result", async () => {
|
||||||
|
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
|
||||||
|
servers: ["playwright"],
|
||||||
|
profilesSeeded: [],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-mcp-sync");
|
||||||
|
await userEvent.click(button);
|
||||||
|
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Check dev sync reports a clean result", async () => {
|
||||||
|
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
devDelta: ["a.txt", "b.txt"],
|
||||||
|
overlap: [],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-sync-check");
|
||||||
|
await userEvent.click(button);
|
||||||
|
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Check dev sync reports a migration collision", async () => {
|
||||||
|
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
||||||
|
code: 5,
|
||||||
|
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-sync-check");
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides all four additions when the lane has no profile", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ describe("PipelineMap", () => {
|
|||||||
render(<PipelineMap nodes={nodes} />);
|
render(<PipelineMap nodes={nodes} />);
|
||||||
const done = screen.getByTestId("pipeline-node-plan").className;
|
const done = screen.getByTestId("pipeline-node-plan").className;
|
||||||
const amber = screen.getByTestId("pipeline-node-implement").className;
|
const amber = screen.getByTestId("pipeline-node-implement").className;
|
||||||
// The done node must contain emerald colour token and the amber node must contain amber token.
|
// The done node must contain the success status token and the amber node
|
||||||
expect(done).toContain("emerald");
|
// must contain the warning status token.
|
||||||
expect(amber).toContain("amber");
|
expect(done).toContain("status-success");
|
||||||
|
expect(amber).toContain("status-warning");
|
||||||
expect(done).not.toEqual(amber);
|
expect(done).not.toEqual(amber);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,8 +95,8 @@ describe("PipelineMap", () => {
|
|||||||
render(<PipelineMap nodes={impossible} detectedSignal="npm run test:server" />);
|
render(<PipelineMap nodes={impossible} detectedSignal="npm run test:server" />);
|
||||||
const node = screen.getByTestId("pipeline-node-tests");
|
const node = screen.getByTestId("pipeline-node-tests");
|
||||||
expect(node.className).toContain("border-dashed");
|
expect(node.className).toContain("border-dashed");
|
||||||
expect(node.className).toContain("amber");
|
expect(node.className).toContain("status-warning");
|
||||||
expect(node.className).not.toContain("emerald");
|
expect(node.className).not.toContain("status-success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("non-detected nodes carry no data-detected attribute", () => {
|
it("non-detected nodes carry no data-detected attribute", () => {
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* @file LaneConsolePane.tsx
|
||||||
|
* @description One lane's run console: the RunSetup ↔ TerminalView switcher,
|
||||||
|
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of
|
||||||
|
* these side by side (split terminal view). Owns its own prompt/cwd/model/
|
||||||
|
* permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing
|
||||||
|
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`,
|
||||||
|
* `activeRuns`, and `externalSessions` are supplied as props because they are global, not
|
||||||
|
* lane-specific, and fetching them per pane would mean N redundant identical
|
||||||
|
* requests for an N-pane layout.
|
||||||
|
*
|
||||||
|
* That state is bound to the lane the pane currently shows: switching `laneId`
|
||||||
|
* swaps the whole pane over to the new lane — its cwd, its history, and its
|
||||||
|
* live tmux session — instead of leaving the previous lane's terminal on
|
||||||
|
* screen under a new lane's header.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Play, AlertCircle } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type {
|
||||||
|
CwdSuggestion,
|
||||||
|
DashboardRunHistoryItem,
|
||||||
|
EffortLevel,
|
||||||
|
PermissionMode,
|
||||||
|
RunHandle,
|
||||||
|
RunListResponse,
|
||||||
|
RunStartArgs,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import type { Session, Lane } from "../../lib/types";
|
||||||
|
import { TerminalView } from "./TerminalView";
|
||||||
|
import { RunSetup } from "./RunSetup";
|
||||||
|
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||||
|
|
||||||
|
export interface LaneConsolePaneProps {
|
||||||
|
lanes: Lane[];
|
||||||
|
laneId: number | null;
|
||||||
|
showLaneSelector: boolean;
|
||||||
|
onLaneIdChange: (id: number) => void;
|
||||||
|
onLaneCreated: (lane: Lane) => void;
|
||||||
|
binaryStatus: { found: boolean; path: string | null } | null;
|
||||||
|
cwdSuggestions: CwdSuggestion[];
|
||||||
|
activeRuns: RunListResponse | null;
|
||||||
|
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||||
|
* active-runs switcher alongside dashboard runs. */
|
||||||
|
externalSessions?: Session[];
|
||||||
|
wsConnected: boolean;
|
||||||
|
defaultCwd?: string;
|
||||||
|
onHasActiveRunChange?: (active: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LaneConsolePane({
|
||||||
|
lanes,
|
||||||
|
laneId,
|
||||||
|
showLaneSelector,
|
||||||
|
onLaneIdChange,
|
||||||
|
onLaneCreated,
|
||||||
|
binaryStatus,
|
||||||
|
cwdSuggestions,
|
||||||
|
activeRuns,
|
||||||
|
externalSessions,
|
||||||
|
wsConnected,
|
||||||
|
defaultCwd,
|
||||||
|
onHasActiveRunChange,
|
||||||
|
}: LaneConsolePaneProps) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
|
const { t: tCommon } = useTranslation("common");
|
||||||
|
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
|
||||||
|
const [effort, setEffort] = useState<EffortLevel>("");
|
||||||
|
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
|
||||||
|
const [resumeSession, setResumeSession] = useState<Session | null>(null);
|
||||||
|
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||||
|
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
|
||||||
|
|
||||||
|
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onHasActiveRunChange?.(handle !== null);
|
||||||
|
}, [handle, onHasActiveRunChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLane && defaultCwd && cwd === "") {
|
||||||
|
setCwd(defaultCwd);
|
||||||
|
}
|
||||||
|
}, [defaultCwd, currentLane, cwd]);
|
||||||
|
|
||||||
|
const refreshList = useCallback(() => {
|
||||||
|
if (laneId !== null) {
|
||||||
|
api.run
|
||||||
|
.history(50, { laneId })
|
||||||
|
.then((r) => setRunHistory(r.items))
|
||||||
|
.catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
api.run
|
||||||
|
.history(50)
|
||||||
|
.then((r) => setRunHistory(r.items))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [laneId]);
|
||||||
|
|
||||||
|
// `lanes` and `activeRuns` are re-fetched every few seconds by the page, so
|
||||||
|
// reading them through a ref keeps the lane-switch effect below off their
|
||||||
|
// identity — a background poll must not wipe a half-typed prompt.
|
||||||
|
const latest = useRef({ lanes, activeRuns, defaultCwd });
|
||||||
|
latest.current = { lanes, activeRuns, defaultCwd };
|
||||||
|
|
||||||
|
// A pane's prompt, cwd, history and terminal all belong to the lane it
|
||||||
|
// shows, so switching lanes has to swap every one of them. Re-attach right
|
||||||
|
// away when the new lane already has a live run: each lane sticks to its own
|
||||||
|
// tmux session, and the switch should land on that session rather than on an
|
||||||
|
// empty setup form the user then has to Start out of.
|
||||||
|
useEffect(() => {
|
||||||
|
const { activeRuns: runs } = latest.current;
|
||||||
|
setPrompt("");
|
||||||
|
setResumeSession(null);
|
||||||
|
setError(null);
|
||||||
|
setBusy(null);
|
||||||
|
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
||||||
|
refreshList();
|
||||||
|
}, [laneId, refreshList]);
|
||||||
|
|
||||||
|
// The cwd tracks the lane's own folder separately, keyed on the resolved
|
||||||
|
// path rather than on `laneId` alone: the pane can mount before the lane
|
||||||
|
// list has loaded (split view restores its pane lanes from localStorage),
|
||||||
|
// and `laneId` never changes afterwards, so a laneId-only effect would leave
|
||||||
|
// the console pointing at the default directory. RunSetup submits this
|
||||||
|
// string verbatim, so a stale one starts the run in the wrong folder.
|
||||||
|
const laneCwd = currentLane?.cwd ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (laneCwd) setCwd(laneCwd);
|
||||||
|
else if (laneId === null) setCwd(latest.current.defaultCwd ?? "");
|
||||||
|
}, [laneId, laneCwd]);
|
||||||
|
|
||||||
|
const attachToRun = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("attach");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const fetched = await api.run.get(id);
|
||||||
|
setHandle(fetched);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const m = err instanceof Error ? err.message : "unknown";
|
||||||
|
setError(t("errors.attachFailed", { message: m }));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onStartFromSetup = useCallback(
|
||||||
|
async (args: RunStartArgs) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("start");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const effectiveCwd = args.cwd || undefined;
|
||||||
|
|
||||||
|
if (!effectiveCwd) {
|
||||||
|
throw new Error(t("errors.cwdRequired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the lane from the cwd the user actually typed, not from
|
||||||
|
// args.laneId — RunSetup always supplies this pane's laneId (a
|
||||||
|
// required prop), which would otherwise silently start a run in the
|
||||||
|
// wrong lane whenever the user types a cwd different from the one
|
||||||
|
// this pane currently shows.
|
||||||
|
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
|
||||||
|
let targetLaneId: number;
|
||||||
|
if (ownedLane) {
|
||||||
|
targetLaneId = ownedLane.id;
|
||||||
|
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
||||||
|
targetLaneId = ensureResult.lane.id;
|
||||||
|
onLaneIdChange(ensureResult.lane.id);
|
||||||
|
onLaneCreated(ensureResult.lane);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
t("errors.laneCreateFailed", {
|
||||||
|
message: err instanceof Error ? err.message : "unknown",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let laneStartResult;
|
||||||
|
try {
|
||||||
|
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||||
|
prompt: args.initialPrompt || "",
|
||||||
|
model: args.model || undefined,
|
||||||
|
permissionMode: args.permissionMode,
|
||||||
|
resumeSessionId: args.resumeSessionId,
|
||||||
|
effort: args.effort || undefined,
|
||||||
|
});
|
||||||
|
} catch (laneErr: unknown) {
|
||||||
|
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
||||||
|
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
||||||
|
const fresh = await api.lanes.list().catch(() => null);
|
||||||
|
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||||
|
if (updatedLane?.run_id) {
|
||||||
|
await attachToRun(updatedLane.run_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw laneErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!laneStartResult.lane?.run_id) {
|
||||||
|
throw new Error(t("errors.noRunIdReturned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||||
|
setHandle(fetched);
|
||||||
|
refreshList();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await attachToRun(laneStartResult.lane.run_id);
|
||||||
|
refreshList();
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
|
||||||
|
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const m = err instanceof Error ? err.message : "unknown";
|
||||||
|
setError(t("errors.startFailed", { message: m }));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onResumeFromHistory = useCallback(
|
||||||
|
async (item: DashboardRunHistoryItem) => {
|
||||||
|
if (!item.session_id) return;
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("start");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let fetched: RunHandle;
|
||||||
|
|
||||||
|
if (item.cwd) {
|
||||||
|
const effectiveCwd = item.cwd;
|
||||||
|
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
|
||||||
|
|
||||||
|
if (!targetLaneId) {
|
||||||
|
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
||||||
|
targetLaneId = ensureResult.lane.id;
|
||||||
|
onLaneCreated(ensureResult.lane);
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||||
|
prompt: "",
|
||||||
|
model: item.model || undefined,
|
||||||
|
permissionMode: item.permission_mode || undefined,
|
||||||
|
effort: item.effort || undefined,
|
||||||
|
resumeSessionId: item.session_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!laneStartResult.lane?.run_id) {
|
||||||
|
throw new Error("No run_id returned from lane start");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||||
|
onLaneIdChange(targetLaneId);
|
||||||
|
} else {
|
||||||
|
fetched = await api.run.start({
|
||||||
|
laneId: 0,
|
||||||
|
initialPrompt: "",
|
||||||
|
cwd: undefined,
|
||||||
|
model: item.model || undefined,
|
||||||
|
permissionMode: item.permission_mode || undefined,
|
||||||
|
effort: item.effort || undefined,
|
||||||
|
resumeSessionId: item.session_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHandle(fetched);
|
||||||
|
setResumeSession(null);
|
||||||
|
refreshList();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "unknown";
|
||||||
|
setError(t("errors.startFailed", { message: msg }));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onViewFromHistory = useCallback(
|
||||||
|
(item: DashboardRunHistoryItem) => {
|
||||||
|
if (item.session_id) void onResumeFromHistory(item);
|
||||||
|
},
|
||||||
|
[onResumeFromHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRun = useCallback(() => {
|
||||||
|
setHandle(null);
|
||||||
|
setPrompt("");
|
||||||
|
setResumeSession(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (laneId === null && showLaneSelector) {
|
||||||
|
return (
|
||||||
|
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||||
|
<select
|
||||||
|
data-testid="pane-lane-select"
|
||||||
|
aria-label={tLanes("splitView.paneLaneLabel")}
|
||||||
|
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">{tLanes("splitView.pickLane")}</option>
|
||||||
|
{lanes.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.title || l.cwd}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-fg-muted">{tLanes("splitView.emptyPane")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
|
||||||
|
{showLaneSelector && (
|
||||||
|
<select
|
||||||
|
data-testid="pane-lane-select"
|
||||||
|
aria-label={tLanes("splitView.paneLaneLabel")}
|
||||||
|
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
||||||
|
value={laneId ?? ""}
|
||||||
|
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">{tLanes("splitView.pickLane")}</option>
|
||||||
|
{lanes.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.title || l.cwd}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Play className="w-4.5 h-4.5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-lg font-semibold text-fg-primary">{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" />
|
||||||
|
{tCommon("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" />
|
||||||
|
{tCommon("offline")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<ActiveRunsSwitcher
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
currentHandleId={handle?.id || null}
|
||||||
|
onAttach={attachToRun}
|
||||||
|
runHistory={runHistory}
|
||||||
|
externalSessions={externalSessions}
|
||||||
|
onResumeFromHistory={onResumeFromHistory}
|
||||||
|
onViewFromHistory={onViewFromHistory}
|
||||||
|
onRefresh={refreshList}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{binaryStatus && !binaryStatus.found && (
|
||||||
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{t("binary.missing")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1 break-all">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!handle ? (
|
||||||
|
<RunSetup
|
||||||
|
laneId={laneId ?? 0}
|
||||||
|
prompt={prompt}
|
||||||
|
onPromptChange={setPrompt}
|
||||||
|
cwd={cwd}
|
||||||
|
onCwdChange={setCwd}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
model={model}
|
||||||
|
onModelChange={setModel}
|
||||||
|
permissionMode={permissionMode}
|
||||||
|
onPermissionModeChange={setPermissionMode}
|
||||||
|
effort={effort}
|
||||||
|
onEffortChange={setEffort}
|
||||||
|
binaryFound={binaryStatus?.found ?? true}
|
||||||
|
busy={busy === "start"}
|
||||||
|
onStart={onStartFromSetup}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
laneCwd={currentLane?.cwd}
|
||||||
|
resumeSession={resumeSession}
|
||||||
|
onResumeSessionChange={setResumeSession}
|
||||||
|
runHistory={runHistory}
|
||||||
|
onResumeFromHistory={onResumeFromHistory}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<TerminalView
|
||||||
|
runId={handle!.id}
|
||||||
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={newRun}
|
||||||
|
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("actions.newRun")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,16 @@
|
|||||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||||
* Resume / View actions.
|
* Resume / View actions.
|
||||||
*
|
*
|
||||||
* Props only: no API call of its own. The page passes `activeRuns` and
|
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||||
* `runHistory` in and gets attach / resume / view / refresh back out through
|
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||||
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
|
* "Active runs" counts everything actually running. They carry no tmux session
|
||||||
|
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||||
|
* fresh tmux-backed `claude --resume <session>` in that cwd.
|
||||||
|
*
|
||||||
|
* Props only: no API call of its own. The page passes `activeRuns`,
|
||||||
|
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||||
|
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||||
|
* just calls `onRefresh`.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -33,8 +40,28 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Eye,
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { DashboardRunHistoryItem, RunListResponse, RunMode, RunStatus } from "../../lib/api";
|
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||||
import { ModeBadge, StatusPill } from "./RunConsole";
|
import type { Session } from "../../lib/types";
|
||||||
|
|
||||||
|
// Minimal StatusPill component (from deleted RunConsole)
|
||||||
|
function StatusPill({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
|
||||||
|
}) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
running: "bg-status-success/10 text-status-success border-status-success/30",
|
||||||
|
gone: "bg-surface-3 text-fg-secondary border-border",
|
||||||
|
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
|
||||||
|
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
|
||||||
|
killed: "bg-surface-3 text-fg-secondary border-border",
|
||||||
|
abandoned: "bg-surface-3 text-fg-secondary border-border",
|
||||||
|
};
|
||||||
|
const color = colors[status] || colors.abandoned;
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{status}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type RunStatusFilter =
|
type RunStatusFilter =
|
||||||
| "all"
|
| "all"
|
||||||
@@ -44,19 +71,21 @@ type RunStatusFilter =
|
|||||||
| "error"
|
| "error"
|
||||||
| "killed"
|
| "killed"
|
||||||
| "abandoned";
|
| "abandoned";
|
||||||
type RunModeFilter = "all" | "conversation" | "headless";
|
|
||||||
|
|
||||||
export interface UnifiedRunRow {
|
export interface UnifiedRunRow {
|
||||||
id: string;
|
id: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
mode: RunMode;
|
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
status: RunStatus;
|
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
|
||||||
promptPreview: string;
|
promptPreview: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
endedAt: number | null;
|
endedAt: number | null;
|
||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
|
/** Live Claude Code session this dashboard did not spawn — no tmux session to
|
||||||
|
* attach to, so Resume (a fresh `claude --resume` in its cwd) is the only
|
||||||
|
* action. */
|
||||||
|
external?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveRunsSwitcher({
|
export function ActiveRunsSwitcher({
|
||||||
@@ -64,6 +93,7 @@ export function ActiveRunsSwitcher({
|
|||||||
currentHandleId,
|
currentHandleId,
|
||||||
onAttach,
|
onAttach,
|
||||||
runHistory,
|
runHistory,
|
||||||
|
externalSessions = [],
|
||||||
onResumeFromHistory,
|
onResumeFromHistory,
|
||||||
onViewFromHistory,
|
onViewFromHistory,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -72,6 +102,9 @@ export function ActiveRunsSwitcher({
|
|||||||
currentHandleId: string | null;
|
currentHandleId: string | null;
|
||||||
onAttach: (id: string) => void;
|
onAttach: (id: string) => void;
|
||||||
runHistory: DashboardRunHistoryItem[];
|
runHistory: DashboardRunHistoryItem[];
|
||||||
|
/** Sessions with `status: "active"` from GET /api/sessions. Remote-source and
|
||||||
|
* cwd-less sessions are ignored — neither can be resumed on this machine. */
|
||||||
|
externalSessions?: Session[];
|
||||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -94,37 +127,40 @@ export function ActiveRunsSwitcher({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Merge live in-memory handles + persistent history into one row list.
|
// Merge live in-memory handles + persistent history + externally started
|
||||||
// Live entries dedupe past-history entries with the same id.
|
// sessions into one row list. Live entries dedupe past-history entries with
|
||||||
const rows: UnifiedRunRow[] = useMemo(() => {
|
// the same id; a session id already covered by a run row is never repeated as
|
||||||
|
// an external row.
|
||||||
|
const { rows, historyItems } = useMemo(() => {
|
||||||
const out: UnifiedRunRow[] = [];
|
const out: UnifiedRunRow[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const seenSessions = new Set<string>();
|
||||||
if (activeRuns) {
|
if (activeRuns) {
|
||||||
for (const r of activeRuns.items) {
|
for (const r of activeRuns.items) {
|
||||||
seen.add(r.id);
|
seen.add(r.id);
|
||||||
|
if (r.sessionId) seenSessions.add(r.sessionId);
|
||||||
out.push({
|
out.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
sessionId: r.sessionId,
|
sessionId: r.sessionId,
|
||||||
mode: r.mode,
|
cwd: r.cwd || "",
|
||||||
cwd: r.cwd,
|
|
||||||
model: r.model,
|
model: r.model,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
promptPreview: r.prompt || "",
|
promptPreview: r.promptPreview || "",
|
||||||
startedAt: r.startedAt,
|
startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0,
|
||||||
endedAt: r.endedAt,
|
endedAt: null,
|
||||||
isLive: r.status === "running" || r.status === "spawning",
|
isLive: r.status === "running",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const h of runHistory) {
|
for (const h of runHistory) {
|
||||||
if (seen.has(h.id)) continue;
|
if (seen.has(h.id)) continue;
|
||||||
seen.add(h.id);
|
seen.add(h.id);
|
||||||
|
if (h.session_id) seenSessions.add(h.session_id);
|
||||||
const startedTs = new Date(h.started_at).getTime() || 0;
|
const startedTs = new Date(h.started_at).getTime() || 0;
|
||||||
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
||||||
out.push({
|
out.push({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
sessionId: h.session_id,
|
sessionId: h.session_id,
|
||||||
mode: h.mode,
|
|
||||||
cwd: h.cwd,
|
cwd: h.cwd,
|
||||||
model: h.model,
|
model: h.model,
|
||||||
status: h.status,
|
status: h.status,
|
||||||
@@ -134,11 +170,51 @@ export function ActiveRunsSwitcher({
|
|||||||
isLive: h.isLive,
|
isLive: h.isLive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Externally started sessions: shown as live rows, and mirrored as
|
||||||
|
// synthetic history items so the existing resume path (which only reads
|
||||||
|
// session_id / cwd / model) works on them unchanged.
|
||||||
|
const synthetic: DashboardRunHistoryItem[] = [];
|
||||||
|
for (const s of externalSessions) {
|
||||||
|
if (!s.cwd) continue;
|
||||||
|
if (s.source && s.source !== "local") continue;
|
||||||
|
if (seenSessions.has(s.id)) continue;
|
||||||
|
seenSessions.add(s.id);
|
||||||
|
synthetic.push({
|
||||||
|
id: `session:${s.id}`,
|
||||||
|
session_id: s.id,
|
||||||
|
cwd: s.cwd,
|
||||||
|
model: s.model,
|
||||||
|
permission_mode: null,
|
||||||
|
effort: null,
|
||||||
|
resume_session_id: null,
|
||||||
|
prompt_preview: s.name,
|
||||||
|
status: "running",
|
||||||
|
exit_code: null,
|
||||||
|
started_at: s.started_at,
|
||||||
|
ended_at: null,
|
||||||
|
isLive: true,
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
id: `session:${s.id}`,
|
||||||
|
sessionId: s.id,
|
||||||
|
cwd: s.cwd,
|
||||||
|
model: s.model,
|
||||||
|
status: "running",
|
||||||
|
promptPreview: s.name || "",
|
||||||
|
startedAt: new Date(s.started_at).getTime() || 0,
|
||||||
|
endedAt: null,
|
||||||
|
isLive: true,
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
out.sort((a, b) => b.startedAt - a.startedAt);
|
out.sort((a, b) => b.startedAt - a.startedAt);
|
||||||
return out;
|
return {
|
||||||
}, [activeRuns, runHistory]);
|
rows: out,
|
||||||
|
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
|
||||||
|
};
|
||||||
|
}, [activeRuns, runHistory, externalSessions]);
|
||||||
|
|
||||||
const liveCount = activeRuns?.activeCount ?? 0;
|
const liveCount = rows.filter((r) => r.isLive).length;
|
||||||
const totalCount = rows.length;
|
const totalCount = rows.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -148,20 +224,20 @@ export function ActiveRunsSwitcher({
|
|||||||
disabled={totalCount === 0}
|
disabled={totalCount === 0}
|
||||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
liveCount > 0
|
liveCount > 0
|
||||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/15"
|
? "border-status-success/40 bg-status-success/10 text-status-success hover:bg-status-success/15"
|
||||||
: "border-border bg-surface-2 text-gray-300 hover:bg-surface-3"
|
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-3.5 h-3.5" />
|
<ListOrdered className="w-3.5 h-3.5" />
|
||||||
{liveCount > 0 ? (
|
{liveCount > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
|
||||||
{t("runs.viewActive_other", { count: liveCount })}
|
{t("runs.viewActive_other", { count: liveCount })}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{t("runs.switcher")}
|
{t("runs.switcher")}
|
||||||
{totalCount > 0 && <span className="text-gray-500 font-mono">{totalCount}</span>}
|
{totalCount > 0 && <span className="text-fg-muted font-mono">{totalCount}</span>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -181,7 +257,7 @@ export function ActiveRunsSwitcher({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
onViewFromHistory(item);
|
onViewFromHistory(item);
|
||||||
}}
|
}}
|
||||||
runHistory={runHistory}
|
runHistory={historyItems}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
@@ -211,7 +287,6 @@ export function RunsModal({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
|
||||||
const [modeFilter, setModeFilter] = useState<RunModeFilter>("all");
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
// Snappy refresh while the modal is the foreground UI: pull immediately
|
// Snappy refresh while the modal is the foreground UI: pull immediately
|
||||||
@@ -227,25 +302,22 @@ export function RunsModal({
|
|||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const byStatus: Record<string, number> = { all: rows.length };
|
const byStatus: Record<string, number> = { all: rows.length };
|
||||||
const byMode: Record<string, number> = { all: rows.length };
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
||||||
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
return { byStatus, byMode };
|
return { byStatus };
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
return rows.filter((r) => {
|
return rows.filter((r) => {
|
||||||
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
||||||
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
|
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
const hay =
|
const hay =
|
||||||
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
|
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
|
||||||
return hay.toLowerCase().includes(q);
|
return hay.toLowerCase().includes(q);
|
||||||
});
|
});
|
||||||
}, [rows, statusFilter, modeFilter, search]);
|
}, [rows, statusFilter, search]);
|
||||||
|
|
||||||
const historyById = useMemo(() => {
|
const historyById = useMemo(() => {
|
||||||
const m = new Map<string, DashboardRunHistoryItem>();
|
const m = new Map<string, DashboardRunHistoryItem>();
|
||||||
@@ -261,7 +333,6 @@ export function RunsModal({
|
|||||||
"killed",
|
"killed",
|
||||||
"abandoned",
|
"abandoned",
|
||||||
];
|
];
|
||||||
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -277,10 +348,10 @@ export function RunsModal({
|
|||||||
<ListOrdered className="w-4 h-4 text-accent" />
|
<ListOrdered className="w-4 h-4 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-sm font-semibold text-gray-100">
|
<h2 className="text-sm font-semibold text-fg-primary">
|
||||||
{t("runs.modalTitle", "Dashboard runs")}
|
{t("runs.modalTitle", "Dashboard runs")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-fg-muted">
|
||||||
{t(
|
{t(
|
||||||
"runs.modalSubtitle",
|
"runs.modalSubtitle",
|
||||||
"Every run started from this dashboard, regardless of status"
|
"Every run started from this dashboard, regardless of status"
|
||||||
@@ -289,7 +360,7 @@ export function RunsModal({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="w-7 h-7 rounded-md text-gray-500 hover:text-gray-200 hover:bg-surface-3 inline-flex items-center justify-center"
|
className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
|
||||||
aria-label={t("runs.refresh", "Refresh")}
|
aria-label={t("runs.refresh", "Refresh")}
|
||||||
title={t("runs.refresh", "Refresh")}
|
title={t("runs.refresh", "Refresh")}
|
||||||
>
|
>
|
||||||
@@ -304,7 +375,7 @@ export function RunsModal({
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-7 h-7 rounded-md text-gray-500 hover:text-gray-200 hover:bg-surface-3 inline-flex items-center justify-center"
|
className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
|
||||||
aria-label={t("limitations.dismiss")}
|
aria-label={t("limitations.dismiss")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -314,18 +385,18 @@ export function RunsModal({
|
|||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="px-5 py-3 border-b border-border flex flex-col gap-2.5 flex-shrink-0">
|
<div className="px-5 py-3 border-b border-border flex flex-col gap-2.5 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 bg-surface-2 border border-border rounded-md px-2.5 py-1.5">
|
<div className="flex items-center gap-2 bg-surface-2 border border-border rounded-md px-2.5 py-1.5">
|
||||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t("runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")}
|
placeholder={t("runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")}
|
||||||
className="flex-1 bg-transparent text-[12px] text-gray-100 placeholder:text-gray-600 focus:outline-none"
|
className="flex-1 bg-transparent text-[12px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearch("")}
|
onClick={() => setSearch("")}
|
||||||
className="text-gray-500 hover:text-gray-200 text-[10px]"
|
className="text-fg-muted hover:text-fg-secondary text-[10px]"
|
||||||
aria-label="Clear"
|
aria-label="Clear"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
@@ -343,23 +414,13 @@ export function RunsModal({
|
|||||||
}))}
|
}))}
|
||||||
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
|
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
|
||||||
/>
|
/>
|
||||||
<FilterChipGroup
|
|
||||||
label={t("runs.filterMode", "Mode")}
|
|
||||||
value={modeFilter}
|
|
||||||
options={MODES.map((m) => ({
|
|
||||||
value: m,
|
|
||||||
label: m === "all" ? t("runs.allLabel", "All") : t(`mode.${m}`),
|
|
||||||
count: counts.byMode[m] || 0,
|
|
||||||
}))}
|
|
||||||
onChange={(v) => setModeFilter(v as RunModeFilter)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex-1 min-h-0 overflow-auto divide-y divide-border">
|
<div className="flex-1 min-h-0 overflow-auto divide-y divide-border">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="px-5 py-12 text-center text-[12px] text-gray-500">
|
<div className="px-5 py-12 text-center text-[12px] text-fg-muted">
|
||||||
{rows.length === 0
|
{rows.length === 0
|
||||||
? t(
|
? t(
|
||||||
"runs.modalEmpty",
|
"runs.modalEmpty",
|
||||||
@@ -392,11 +453,11 @@ export function RunsModal({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-5 py-2.5 border-t border-border bg-surface-2/40 flex items-center gap-2 flex-shrink-0">
|
<div className="px-5 py-2.5 border-t border-border bg-surface-2/40 flex items-center gap-2 flex-shrink-0">
|
||||||
<Info className="w-3 h-3 text-gray-500 flex-shrink-0" />
|
<Info className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||||
<span className="text-[10.5px] text-gray-500 leading-relaxed flex-1">
|
<span className="text-[10.5px] text-fg-muted leading-relaxed flex-1">
|
||||||
{t("runs.scopeNote")}
|
{t("runs.scopeNote")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10.5px] text-gray-500 font-mono">
|
<span className="text-[10.5px] text-fg-muted font-mono">
|
||||||
{filtered.length} / {rows.length}
|
{filtered.length} / {rows.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +479,7 @@ function FilterChipGroup<T extends string>({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="text-[10px] uppercase tracking-wider font-semibold text-gray-500 mr-1">
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-fg-muted mr-1">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
@@ -432,11 +493,11 @@ function FilterChipGroup<T extends string>({
|
|||||||
className={`text-[10.5px] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 ${
|
className={`text-[10.5px] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 ${
|
||||||
active
|
active
|
||||||
? "bg-accent/15 border-accent/50 text-accent"
|
? "bg-accent/15 border-accent/50 text-accent"
|
||||||
: "bg-surface-2 border-border text-gray-300 hover:bg-surface-3 hover:border-border-strong"
|
: "bg-surface-2 border-border text-fg-secondary hover:bg-surface-3 hover:border-border-strong"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
<span className="ml-1 text-gray-500 font-mono">{opt.count}</span>
|
<span className="ml-1 text-fg-muted font-mono">{opt.count}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -467,10 +528,11 @@ function UnifiedRunRowView({
|
|||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive;
|
// Without mode distinction, offer resume for any finished run with a session.
|
||||||
// Headless runs are single-shot, so resume doesn't apply - but the captured
|
// An external session is live but has no attachable tmux session, so Resume
|
||||||
// transcript is still worth viewing. Link to the Session detail page.
|
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
|
||||||
const canView = row.mode === "headless" && !!row.sessionId && !row.isLive;
|
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
|
||||||
|
const canView = !!row.sessionId && !row.isLive;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-5 py-3 transition-colors ${
|
className={`px-5 py-3 transition-colors ${
|
||||||
@@ -479,23 +541,30 @@ function UnifiedRunRowView({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
<StatusPill status={row.status} />
|
<StatusPill status={row.status} />
|
||||||
<ModeBadge mode={row.mode} />
|
|
||||||
{row.isLive && (
|
{row.isLive && (
|
||||||
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
|
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
|
||||||
{t("runs.liveBadge", "live")}
|
{t("runs.liveBadge", "live")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{row.external && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||||
|
title={t("runs.externalHint")}
|
||||||
|
>
|
||||||
|
{t("runs.externalBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
||||||
{t("runs.currentBadge", "current")}
|
{t("runs.currentBadge", "current")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||||
{row.isLive && !isCurrent && (
|
{row.isLive && !row.external && !isCurrent && (
|
||||||
<button
|
<button
|
||||||
onClick={onAttach}
|
onClick={onAttach}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-200 px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<Play className="w-3 h-3" />
|
<Play className="w-3 h-3" />
|
||||||
{t("runs.attachLabel", "Attach")}
|
{t("runs.attachLabel", "Attach")}
|
||||||
@@ -504,6 +573,7 @@ function UnifiedRunRowView({
|
|||||||
{canResume && (
|
{canResume && (
|
||||||
<button
|
<button
|
||||||
onClick={onResume}
|
onClick={onResume}
|
||||||
|
title={row.external ? t("runs.externalHint") : undefined}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
@@ -513,7 +583,7 @@ function UnifiedRunRowView({
|
|||||||
{canView && (
|
{canView && (
|
||||||
<button
|
<button
|
||||||
onClick={onView}
|
onClick={onView}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-gray-300 hover:text-gray-100 px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
{t("runs.viewLabel", "View")}
|
{t("runs.viewLabel", "View")}
|
||||||
@@ -522,18 +592,18 @@ function UnifiedRunRowView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{row.promptPreview && (
|
{row.promptPreview && (
|
||||||
<div className="text-[12px] text-gray-300 line-clamp-2 leading-snug">
|
<div className="text-[12px] text-fg-secondary line-clamp-2 leading-snug">
|
||||||
{row.promptPreview}
|
{row.promptPreview}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="font-mono text-[10px] text-gray-500 truncate mt-1">{row.cwd}</div>
|
<div className="font-mono text-[10px] text-fg-muted truncate mt-1">{row.cwd}</div>
|
||||||
<div className="text-[10px] text-gray-600 mt-0.5 flex items-center gap-2 flex-wrap">
|
<div className="text-[10px] text-fg-muted mt-0.5 flex items-center gap-2 flex-wrap">
|
||||||
<span>{startedLabel}</span>
|
<span>{startedLabel}</span>
|
||||||
{row.model && <span className="font-mono text-gray-500">· {row.model}</span>}
|
{row.model && <span className="font-mono text-fg-muted">· {row.model}</span>}
|
||||||
{row.sessionId && (
|
{row.sessionId && (
|
||||||
<Link
|
<Link
|
||||||
to={`/sessions/${encodeURIComponent(row.sessionId)}`}
|
to={`/sessions/${encodeURIComponent(row.sessionId)}`}
|
||||||
className="inline-flex items-center gap-1 text-gray-500 hover:text-gray-300 transition-colors"
|
className="inline-flex items-center gap-1 text-fg-muted hover:text-fg-secondary transition-colors"
|
||||||
title={t("actions.viewSession")}
|
title={t("actions.viewSession")}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-2.5 h-2.5" />
|
<ExternalLink className="w-2.5 h-2.5" />
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
* the Run page and the Workspace page can both mount the same panel.
|
* the Run page and the Workspace page can both mount the same panel.
|
||||||
*
|
*
|
||||||
* What lives here:
|
* What lives here:
|
||||||
* - `RunSetup` — mode (conversation / headless), fresh-vs-resume source, the
|
* - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd /
|
||||||
* prompt editor, and the cwd / model / permission-mode / effort fields,
|
* model / permission-mode / effort fields, plus the concurrency hint and
|
||||||
* plus the concurrency hint and the Start button. Its disabled state is
|
* the Start button. Its disabled state is driven by the `binaryFound` prop,
|
||||||
* driven by the `binaryFound` prop, so a missing `claude` binary is a
|
* so a missing `claude` binary is a surfaced state here rather than a probe
|
||||||
* surfaced state here rather than a probe of its own.
|
* of its own. Picking a session to resume starts that run immediately —
|
||||||
* above the panel, with its own localStorage-persisted minimized state.
|
* a resume carries its own history, so there is nothing to type first;
|
||||||
|
* the prompt stays optional for resumes and required for fresh runs.
|
||||||
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
||||||
* `ModelPicker`, and the small `ModeOption` / `Field` layout helpers.
|
* `ModelPicker`, and the small `Field` layout helper.
|
||||||
*
|
*
|
||||||
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
||||||
* lifecycle — the page owns `api.run.start` and hands the result back through
|
* lifecycle — the page owns `api.run.start` and hands the result back through
|
||||||
@@ -45,19 +46,53 @@ import type {
|
|||||||
RunListResponse,
|
RunListResponse,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
RunMode,
|
RunStartArgs,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Session } from "../../lib/types";
|
import type { Session } from "../../lib/types";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { PromptEditor } from "./RunConsole";
|
|
||||||
import type { SlashCommand } from "./RunConsole";
|
// Minimal SlashCommand type (from deleted RunConsole)
|
||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
source: "project" | "user" | "plugin" | "builtin";
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal PromptEditor component (from deleted RunConsole)
|
||||||
|
interface PromptEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (s: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
placeholder: string;
|
||||||
|
rows?: number;
|
||||||
|
slashCommands: SlashCommand[];
|
||||||
|
fileCwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Limitations banner (above the config card) ────────────────────────
|
// ── Limitations banner (above the config card) ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
interface RunSetupProps {
|
interface RunSetupProps {
|
||||||
mode: RunMode;
|
laneId: number;
|
||||||
onModeChange: (m: RunMode) => void;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
onPromptChange: (s: string) => void;
|
onPromptChange: (s: string) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -71,7 +106,7 @@ interface RunSetupProps {
|
|||||||
onEffortChange: (e: EffortLevel) => void;
|
onEffortChange: (e: EffortLevel) => void;
|
||||||
binaryFound: boolean;
|
binaryFound: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onStart: () => void;
|
onStart: (args: RunStartArgs) => void;
|
||||||
activeRuns: RunListResponse | null;
|
activeRuns: RunListResponse | null;
|
||||||
resumeSession: Session | null;
|
resumeSession: Session | null;
|
||||||
onResumeSessionChange: (s: Session | null) => void;
|
onResumeSessionChange: (s: Session | null) => void;
|
||||||
@@ -79,54 +114,27 @@ interface RunSetupProps {
|
|||||||
* sessions. Undefined when no lane is selected (the picker then lists
|
* sessions. Undefined when no lane is selected (the picker then lists
|
||||||
* everything, same as before lanes existed). */
|
* everything, same as before lanes existed). */
|
||||||
laneCwd?: string;
|
laneCwd?: string;
|
||||||
slashCommands: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
runHistory: DashboardRunHistoryItem[];
|
runHistory: DashboardRunHistoryItem[];
|
||||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunSetup(props: RunSetupProps) {
|
export function RunSetup(props: RunSetupProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const atCap =
|
const atCap = false; // TODO: re-add when concurrency info is available
|
||||||
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
|
|
||||||
const isResume = !!props.resumeSession;
|
const isResume = !!props.resumeSession;
|
||||||
const [resumePicked, setResumePicked] = useState(isResume);
|
const [resumePicked, setResumePicked] = useState(isResume);
|
||||||
// Keep "resume picked" in sync with the parent. Two cases:
|
// Keep "resume picked" in sync with the parent. Parent set a resume session
|
||||||
// 1. Parent set a resume session (e.g. user clicked Resume in the runs
|
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
|
||||||
// modal) - flip the radio so the picker is shown and the selection
|
// is shown and the selection is visible.
|
||||||
// is visible.
|
|
||||||
// 2. Parent cleared the session and mode flipped to headless - clear
|
|
||||||
// the radio so the form is honest.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isResume && !resumePicked) setResumePicked(true);
|
if (isResume && !resumePicked) setResumePicked(true);
|
||||||
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false);
|
}, [isResume, resumePicked]);
|
||||||
}, [isResume, resumePicked, props.mode]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-surface-1">
|
<div className="rounded-xl border border-border bg-surface-1">
|
||||||
{/* Mode and source on one line. Both are two-way choices made once at
|
{/* Fresh vs resume source picker */}
|
||||||
spawn time, so a segmented row carries them; the longer explanations
|
|
||||||
live in each button's title rather than in a paragraph. */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
|
||||||
<Seg
|
|
||||||
active={props.mode === "conversation"}
|
|
||||||
label={t("mode.conversation")}
|
|
||||||
title={t("mode.conversationHint")}
|
|
||||||
onClick={() => props.onModeChange("conversation")}
|
|
||||||
/>
|
|
||||||
<Seg
|
|
||||||
active={props.mode === "headless"}
|
|
||||||
label={t("mode.headless")}
|
|
||||||
title={`${t("mode.headlessHint")} — ${t("hint.headlessExplain")}`}
|
|
||||||
onClick={() => {
|
|
||||||
props.onModeChange("headless");
|
|
||||||
setResumePicked(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.mode === "conversation" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||||
<Seg
|
<Seg
|
||||||
active={!resumePicked}
|
active={!resumePicked}
|
||||||
@@ -148,30 +156,36 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<SessionPicker
|
<SessionPicker
|
||||||
selected={props.resumeSession}
|
selected={props.resumeSession}
|
||||||
onSelect={props.onResumeSessionChange}
|
onSelect={(s) => {
|
||||||
cwd={props.laneCwd}
|
props.onResumeSessionChange(s);
|
||||||
|
// Pass the picked session explicitly: the parent's state
|
||||||
|
// update has not landed yet on this tick, so reading
|
||||||
|
// props.resumeSession here would resume nothing.
|
||||||
|
if (s && !props.busy) handleStart(props, s);
|
||||||
|
}}
|
||||||
|
// Fall back to the typed cwd when no lane is selected — otherwise
|
||||||
|
// an unfiltered picker lists every session from every repo.
|
||||||
|
cwd={props.laneCwd || props.cwd.trim() || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* Prompt */}
|
||||||
<div className="px-4 py-3 border-b border-border">
|
<div className="px-4 py-3 border-b border-border">
|
||||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
|
||||||
{t("fields.prompt")}
|
{t("fields.prompt")}
|
||||||
</label>
|
</label>
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
value={props.prompt}
|
value={props.prompt}
|
||||||
onChange={props.onPromptChange}
|
onChange={props.onPromptChange}
|
||||||
onSubmit={props.onStart}
|
onSubmit={() => handleStart(props)}
|
||||||
placeholder={t("fields.promptPlaceholder")}
|
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||||
rows={5}
|
rows={5}
|
||||||
slashCommands={props.slashCommands}
|
slashCommands={props.slashCommands ?? []}
|
||||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 text-[10px] text-gray-600">
|
<div className="mt-1 text-[10px] text-fg-muted">
|
||||||
{t("hint.shortcut")} · / for slash commands · @ for file references
|
{t("hint.shortcut")} · / for slash commands · @ for file references
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,8 +194,8 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Field label={t("fields.cwd")}>
|
<Field label={t("fields.cwd")}>
|
||||||
{isResume && props.resumeSession ? (
|
{isResume && props.resumeSession ? (
|
||||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-300 flex items-center gap-2">
|
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||||
<Lock className="w-3 h-3 text-gray-500 flex-shrink-0" />
|
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -223,7 +237,7 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.permissionMode === "bypassPermissions" && (
|
{props.permissionMode === "bypassPermissions" && (
|
||||||
<div className="mx-4 mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[11px] text-red-200 flex items-start gap-2">
|
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||||
<span>{t("hint.permissionWarning")}</span>
|
<span>{t("hint.permissionWarning")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,22 +247,18 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
<div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
<div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-3 text-[11px] min-w-0">
|
<div className="flex items-center gap-3 text-[11px] min-w-0">
|
||||||
{atCap ? (
|
{atCap ? (
|
||||||
<span className="inline-flex items-center gap-1.5 text-amber-300">
|
<span className="inline-flex items-center gap-1.5 text-status-warning">
|
||||||
<AlertCircle className="w-3.5 h-3.5" />
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
|
{t("concurrency.atCap", { max: 0 })}
|
||||||
</span>
|
|
||||||
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-gray-400">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|
||||||
{t("concurrency.active", { count: props.activeRuns.activeCount })}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={props.onStart}
|
onClick={() => handleStart(props)}
|
||||||
disabled={
|
disabled={
|
||||||
!props.binaryFound ||
|
!props.binaryFound ||
|
||||||
!props.prompt.trim() ||
|
// A resume needs no prompt — the session it continues is the input.
|
||||||
|
(!props.resumeSession && !props.prompt.trim()) ||
|
||||||
props.busy ||
|
props.busy ||
|
||||||
atCap ||
|
atCap ||
|
||||||
(resumePicked && !props.resumeSession) ||
|
(resumePicked && !props.resumeSession) ||
|
||||||
@@ -271,6 +281,23 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `session` overrides `props.resumeSession` for the auto-start fired straight
|
||||||
|
* out of the picker, before the parent's state has caught up. */
|
||||||
|
function handleStart(props: RunSetupProps, session?: Session) {
|
||||||
|
const resume = session ?? props.resumeSession;
|
||||||
|
props.onStart({
|
||||||
|
laneId: props.laneId,
|
||||||
|
// A resume is pinned to its own session's folder — that's what the locked
|
||||||
|
// cwd field shows, so it's what gets sent.
|
||||||
|
cwd: resume?.cwd || props.cwd || undefined,
|
||||||
|
model: props.model || undefined,
|
||||||
|
permissionMode: props.permissionMode || undefined,
|
||||||
|
effort: props.effort || undefined,
|
||||||
|
resumeSessionId: resume?.id || undefined,
|
||||||
|
initialPrompt: props.prompt || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** One segment of a two-way inline choice. The explanation rides on `title`
|
/** One segment of a two-way inline choice. The explanation rides on `title`
|
||||||
* instead of a hint line, which is what keeps the row to one line. */
|
* instead of a hint line, which is what keeps the row to one line. */
|
||||||
function Seg({
|
function Seg({
|
||||||
@@ -291,7 +318,7 @@ function Seg({
|
|||||||
title={title}
|
title={title}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={`rounded px-2 py-0.5 font-medium transition-colors ${
|
className={`rounded px-2 py-0.5 font-medium transition-colors ${
|
||||||
active ? "bg-accent/20 text-accent" : "text-gray-400 hover:text-gray-200"
|
active ? "bg-accent/20 text-accent" : "text-fg-secondary hover:text-fg-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -302,7 +329,7 @@ function Seg({
|
|||||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
|
<label className="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{children}
|
{children}
|
||||||
@@ -397,7 +424,7 @@ export function CwdAutocomplete({
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none" />
|
<FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
@@ -413,17 +440,17 @@ export function CwdAutocomplete({
|
|||||||
placeholder={t("fields.cwdPlaceholder")}
|
placeholder={t("fields.cwdPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50"
|
className="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-[11px] text-gray-500">{t("fields.cwdNoMatches")}</div>
|
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
|
||||||
) : (
|
) : (
|
||||||
groups.map((g) => (
|
groups.map((g) => (
|
||||||
<div key={g.kind}>
|
<div key={g.kind}>
|
||||||
<div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-500 flex items-center gap-1.5">
|
<div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted flex items-center gap-1.5">
|
||||||
{g.kind === "dashboard" ? (
|
{g.kind === "dashboard" ? (
|
||||||
<FolderOpen className="w-3 h-3" />
|
<FolderOpen className="w-3 h-3" />
|
||||||
) : g.kind === "home" ? (
|
) : g.kind === "home" ? (
|
||||||
@@ -447,8 +474,8 @@ export function CwdAutocomplete({
|
|||||||
isActive ? "bg-accent/15" : "hover:bg-surface-3"
|
isActive ? "bg-accent/15" : "hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-[11px] text-gray-200 truncate">{s.label}</div>
|
<div className="text-[11px] text-fg-secondary truncate">{s.label}</div>
|
||||||
<div className="font-mono text-[10px] text-gray-500 truncate">{s.path}</div>
|
<div className="font-mono text-[10px] text-fg-muted truncate">{s.path}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -527,13 +554,13 @@ function SessionPicker({
|
|||||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
|
||||||
{t("resume.selectedBadge")}
|
{t("resume.selectedBadge")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[11px] text-gray-200 truncate">{selected.id}</span>
|
<span className="font-mono text-[11px] text-fg-secondary truncate">{selected.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-[10px] text-gray-500 truncate mt-0.5">{selected.cwd}</div>
|
<div className="font-mono text-[10px] text-fg-muted truncate mt-0.5">{selected.cwd}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelect(null)}
|
onClick={() => onSelect(null)}
|
||||||
className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-gray-300 inline-flex items-center gap-1 flex-shrink-0"
|
className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center gap-1 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
{t("resume.clear")}
|
{t("resume.clear")}
|
||||||
@@ -546,7 +573,7 @@ function SessionPicker({
|
|||||||
<div ref={containerRef} className="relative mt-2">
|
<div ref={containerRef} className="relative mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-gray-400 inline-flex items-center gap-2"
|
className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-fg-secondary inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
{t("resume.pickSession")}
|
{t("resume.pickSession")}
|
||||||
@@ -555,20 +582,20 @@ function SessionPicker({
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden">
|
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden">
|
||||||
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
||||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder={t("resume.search")}
|
placeholder={t("resume.search")}
|
||||||
className="bg-transparent text-[11px] text-gray-100 placeholder:text-gray-500 focus:outline-none w-full"
|
className="bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-72 overflow-auto py-1">
|
<div className="max-h-72 overflow-auto py-1">
|
||||||
{sessions === null ? (
|
{sessions === null ? (
|
||||||
<div className="px-3 py-2 text-[11px] text-gray-500">…</div>
|
<div className="px-3 py-2 text-[11px] text-fg-muted">…</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-[11px] text-gray-500">{t("resume.noSessions")}</div>
|
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("resume.noSessions")}</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((s) => (
|
filtered.map((s) => (
|
||||||
<button
|
<button
|
||||||
@@ -584,27 +611,29 @@ function SessionPicker({
|
|||||||
<span
|
<span
|
||||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||||
s.status === "active"
|
s.status === "active"
|
||||||
? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30"
|
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||||
: s.status === "completed"
|
: s.status === "completed"
|
||||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||||
: s.status === "error"
|
: s.status === "error"
|
||||||
? "bg-red-500/10 text-red-300 border-red-500/30"
|
? "bg-status-danger/10 text-status-danger border-status-danger/30"
|
||||||
: "bg-surface-3 text-gray-400 border-border"
|
: "bg-surface-3 text-fg-secondary border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.status}
|
{s.status}
|
||||||
</span>
|
</span>
|
||||||
{s.name?.trim() && (
|
{s.name?.trim() && (
|
||||||
<span className="text-[11px] text-gray-200 truncate">{s.name.trim()}</span>
|
<span className="text-[11px] text-fg-secondary truncate">
|
||||||
|
{s.name.trim()}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono text-[11px] text-gray-400 truncate flex-shrink-0">
|
<span className="font-mono text-[11px] text-fg-secondary truncate flex-shrink-0">
|
||||||
{s.id.slice(0, 12)}…
|
{s.id.slice(0, 12)}…
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-gray-600 ml-auto flex-shrink-0">
|
<span className="text-[10px] text-fg-muted ml-auto flex-shrink-0">
|
||||||
{new Date(s.started_at).toLocaleString()}
|
{new Date(s.started_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-[10px] text-gray-500 truncate">{s.cwd}</div>
|
<div className="font-mono text-[10px] text-fg-muted truncate">{s.cwd}</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -666,7 +695,7 @@ function ModelPicker({ value, onChange }: { value: string; onChange: (s: string)
|
|||||||
placeholder={t("fields.modelCustomPlaceholder")}
|
placeholder={t("fields.modelCustomPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50"
|
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @file TerminalView.tsx
|
||||||
|
* @description Renders one lane's live terminal — a real `xterm.js` instance
|
||||||
|
* attached via WebSocket to the server's `/ws-pty/:runId` path (see
|
||||||
|
* server/lib/pty-attach.js), which is itself a `node-pty`-backed
|
||||||
|
* `tmux attach-session`. Binary WS frames are raw PTY bytes in both
|
||||||
|
* directions; a JSON text frame carries the initial `resize` on mount and
|
||||||
|
* the server's one-shot `exit` notice when the pane process ends.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
interface TerminalViewProps {
|
||||||
|
runId: string;
|
||||||
|
wsBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true });
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
if (containerRef.current) term.open(containerRef.current);
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||||
|
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||||
|
// would hand onmessage a Blob that the string checks below never match,
|
||||||
|
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||||
|
const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||||
|
if (data.startsWith('{"type"')) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data);
|
||||||
|
if (msg.type === "exit") {
|
||||||
|
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* not JSON — fall through and render as PTY output */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term.write(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataDisposable = term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
fit.fit();
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
dataDisposable.dispose();
|
||||||
|
ws.close();
|
||||||
|
term.dispose();
|
||||||
|
};
|
||||||
|
}, [runId, wsBaseUrl]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* @file LaneConsolePane.test.tsx
|
||||||
|
* @description Test suite for the LaneConsolePane component
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { LaneConsolePane } from "../LaneConsolePane";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import type { Lane } from "../../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../TerminalView", () => ({
|
||||||
|
TerminalView: ({ runId }: { runId: string }) => (
|
||||||
|
<div data-testid="terminal-view" data-run-id={runId} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
lanes: {
|
||||||
|
ensure: vi.fn(),
|
||||||
|
action: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
},
|
||||||
|
run: {
|
||||||
|
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
get: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RUN_MODEL_CHOICES: [],
|
||||||
|
RUN_EFFORT_CHOICES: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LANE: Lane = {
|
||||||
|
id: 1,
|
||||||
|
title: "demo",
|
||||||
|
cwd: "/workspace/a",
|
||||||
|
branch: null,
|
||||||
|
kind: "adopted",
|
||||||
|
source_repo: null,
|
||||||
|
pipeline: "default",
|
||||||
|
session_id: null,
|
||||||
|
run_id: null,
|
||||||
|
stage: "idle",
|
||||||
|
stage_since: null,
|
||||||
|
status: "idle",
|
||||||
|
gate_decision: null,
|
||||||
|
ci_status: null,
|
||||||
|
needs_action: null,
|
||||||
|
links: {},
|
||||||
|
stages: {},
|
||||||
|
notes: null,
|
||||||
|
pipeline_name: "Default",
|
||||||
|
pipeline_nodes: [],
|
||||||
|
progress: 0,
|
||||||
|
stage_seconds: null,
|
||||||
|
last_event_seconds: null,
|
||||||
|
liveness: "idle" as Lane["liveness"],
|
||||||
|
detected_stage: null,
|
||||||
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
|
active_feature_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function baseProps() {
|
||||||
|
return {
|
||||||
|
lanes: [LANE],
|
||||||
|
laneId: 1,
|
||||||
|
showLaneSelector: false,
|
||||||
|
onLaneIdChange: vi.fn(),
|
||||||
|
onLaneCreated: vi.fn(),
|
||||||
|
binaryStatus: { found: true, path: "/usr/local/bin/claude" },
|
||||||
|
cwdSuggestions: [],
|
||||||
|
activeRuns: { items: [] },
|
||||||
|
wsConnected: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LaneConsolePane", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a run through /api/lanes/<id>/start, not /api/run/start", async () => {
|
||||||
|
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
lane: { ...LANE, run_id: "run-1" },
|
||||||
|
});
|
||||||
|
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "run-1",
|
||||||
|
laneId: 1,
|
||||||
|
status: "running",
|
||||||
|
cwd: "/workspace/a",
|
||||||
|
model: null,
|
||||||
|
permissionMode: null,
|
||||||
|
effort: null,
|
||||||
|
resumeSessionId: null,
|
||||||
|
sessionId: null,
|
||||||
|
startedAt: null,
|
||||||
|
promptPreview: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LaneConsolePane {...baseProps()} />);
|
||||||
|
|
||||||
|
// Set cwd and prompt
|
||||||
|
const cwdInput = screen.getByPlaceholderText(/type to search/i);
|
||||||
|
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } });
|
||||||
|
|
||||||
|
const promptTextarea = screen.getByPlaceholderText(/ask claude/i);
|
||||||
|
fireEvent.change(promptTextarea, { target: { value: "test prompt" } });
|
||||||
|
|
||||||
|
// Find and click the Run button (the main start button in RunSetup)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object))
|
||||||
|
);
|
||||||
|
expect(api.run.start).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||||
|
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||||
|
const run = (id: string, laneId: number) => ({
|
||||||
|
id,
|
||||||
|
laneId,
|
||||||
|
status: "running" as const,
|
||||||
|
cwd: null,
|
||||||
|
model: null,
|
||||||
|
permissionMode: null,
|
||||||
|
effort: null,
|
||||||
|
resumeSessionId: null,
|
||||||
|
sessionId: null,
|
||||||
|
startedAt: null,
|
||||||
|
promptPreview: null,
|
||||||
|
});
|
||||||
|
const props = {
|
||||||
|
...baseProps(),
|
||||||
|
lanes: [LANE, LANE2],
|
||||||
|
activeRuns: { items: [run("ccam-lane-1", 1), run("ccam-lane-2", 2)] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-1")
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(<LaneConsolePane {...props} laneId={2} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-2")
|
||||||
|
);
|
||||||
|
|
||||||
|
// A lane with no live run falls back to its setup form, not the previous
|
||||||
|
// lane's terminal.
|
||||||
|
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
|
||||||
|
// Split view restores its pane lanes from localStorage, so a pane can
|
||||||
|
// render with a laneId before GET /api/lanes has answered. laneId never
|
||||||
|
// changes afterwards — only the resolved lane does.
|
||||||
|
const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
|
||||||
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||||
|
const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
|
||||||
|
expect(cwdInput.value).toBe("/home/tester");
|
||||||
|
|
||||||
|
rerender(<LaneConsolePane {...props} lanes={[LANE]} />);
|
||||||
|
await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a lane dropdown only when showLaneSelector is true", () => {
|
||||||
|
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
|
||||||
|
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<LaneConsolePane {...baseProps()} showLaneSelector={false} />);
|
||||||
|
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
|
||||||
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
|
||||||
|
expect(screen.getByTestId("console-body")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||||
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
||||||
|
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file RunConsole.test.tsx
|
|
||||||
* @description Pins the props-only boundary of `RunConsole` after its move out
|
|
||||||
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
|
|
||||||
* (no stream subscription of its own), the token meter rolls up usage from
|
|
||||||
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
|
|
||||||
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
|
|
||||||
* send and stop controls.
|
|
||||||
*
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { RunConsole, type SlashCommand } from "../RunConsole";
|
|
||||||
import type { Envelope } from "../../../hooks/useRunStream";
|
|
||||||
import type { RunHandle } from "../../../lib/api";
|
|
||||||
|
|
||||||
const HANDLE: RunHandle = {
|
|
||||||
id: "run-1",
|
|
||||||
pid: 4242,
|
|
||||||
mode: "conversation",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
model: "claude-opus-5",
|
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
effort: "",
|
|
||||||
prompt: "hi",
|
|
||||||
argv: [],
|
|
||||||
resumeSessionId: null,
|
|
||||||
status: "running",
|
|
||||||
startedAt: 1,
|
|
||||||
endedAt: null,
|
|
||||||
exitCode: null,
|
|
||||||
signal: null,
|
|
||||||
error: null,
|
|
||||||
sessionId: null,
|
|
||||||
envelopeCount: 0,
|
|
||||||
stdoutTail: "",
|
|
||||||
stderrTail: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const COMMANDS: SlashCommand[] = [
|
|
||||||
{ name: "code-review", description: "Review the working diff", source: "project" },
|
|
||||||
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
|
|
||||||
{ name: "logout", description: "Sign out", source: "builtin" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mount the console with the parent-owned follow-up state it expects, so the
|
|
||||||
* autocomplete assertions exercise the real controlled-input round trip.
|
|
||||||
*/
|
|
||||||
function renderConsole(
|
|
||||||
props: Partial<React.ComponentProps<typeof RunConsole>> = {},
|
|
||||||
onFollowUp?: (s: string) => void
|
|
||||||
) {
|
|
||||||
const seen = { followUp: "" };
|
|
||||||
function Harness() {
|
|
||||||
const [followUp, setFollowUp] = useState("");
|
|
||||||
seen.followUp = followUp;
|
|
||||||
return (
|
|
||||||
<RunConsole
|
|
||||||
handle={HANDLE}
|
|
||||||
envelopes={[]}
|
|
||||||
mode="conversation"
|
|
||||||
isLive
|
|
||||||
hasFinished={false}
|
|
||||||
followUp={followUp}
|
|
||||||
onFollowUpChange={(s) => {
|
|
||||||
setFollowUp(s);
|
|
||||||
onFollowUp?.(s);
|
|
||||||
}}
|
|
||||||
busy={null}
|
|
||||||
onSend={() => {}}
|
|
||||||
onStop={() => {}}
|
|
||||||
onNewRun={() => {}}
|
|
||||||
slashCommands={COMMANDS}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<Harness />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
return seen;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("RunConsole", () => {
|
|
||||||
it("renders assistant text from the envelopes prop", () => {
|
|
||||||
const envelopes: Envelope[] = [
|
|
||||||
{ type: "user", message: { content: "explain this repo" } },
|
|
||||||
{ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } },
|
|
||||||
] as Envelope[];
|
|
||||||
renderConsole({ envelopes });
|
|
||||||
|
|
||||||
expect(screen.getByText("explain this repo")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Here is the answer.")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the empty-stream placeholder when there are no envelopes", () => {
|
|
||||||
renderConsole({ isLive: false });
|
|
||||||
expect(screen.getByText("Nothing yet")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the token totals computed from the envelopes", () => {
|
|
||||||
// Transcript-shaped assistant envelope (no `message.id`), which is the
|
|
||||||
// branch computeTokens folds into the running totals.
|
|
||||||
const envelopes: Envelope[] = [
|
|
||||||
{
|
|
||||||
type: "assistant",
|
|
||||||
message: {
|
|
||||||
content: [{ type: "text", text: "done" }],
|
|
||||||
usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] as Envelope[];
|
|
||||||
renderConsole({ envelopes });
|
|
||||||
|
|
||||||
// Context gauge: (input + cache read) / default 200k window.
|
|
||||||
// The CLI-style meter is one status line: context usage as a single label,
|
|
||||||
// then output and cache-hit figures with terminal glyphs. Input is implied
|
|
||||||
// by the context total rather than listed separately.
|
|
||||||
expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output
|
|
||||||
expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters slash commands as the user types and fills the prompt on pick", () => {
|
|
||||||
const seen = renderConsole();
|
|
||||||
const textarea = screen.getByRole("textbox");
|
|
||||||
|
|
||||||
fireEvent.change(textarea, { target: { value: "/co" } });
|
|
||||||
|
|
||||||
expect(screen.getByText("/code-review")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("/compact")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("/logout")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("/code-review"));
|
|
||||||
|
|
||||||
expect(seen.followUp).toBe("/code-review");
|
|
||||||
expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fires onSend from the send button with the prompt the parent holds", () => {
|
|
||||||
const onSend = vi.fn();
|
|
||||||
const seen = renderConsole({ onSend });
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
||||||
|
|
||||||
expect(onSend).toHaveBeenCalledTimes(1);
|
|
||||||
expect(seen.followUp).toBe("follow up please");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fires onStop from the stop control while live, and hides it when not", () => {
|
|
||||||
const onStop = vi.fn();
|
|
||||||
renderConsole({ onStop });
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /stop/i }));
|
|
||||||
expect(onStop).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the stop control and the follow-up editor once the run is not live", () => {
|
|
||||||
renderConsole({ isLive: false, hasFinished: true });
|
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||||
|
import type { Session } from "../../../lib/types";
|
||||||
|
|
||||||
const LIVE_ID = "run-live";
|
const LIVE_ID = "run-live";
|
||||||
const PAST_ID = "run-past";
|
const PAST_ID = "run-past";
|
||||||
@@ -29,12 +30,11 @@ const activeRuns = {
|
|||||||
{
|
{
|
||||||
id: LIVE_ID,
|
id: LIVE_ID,
|
||||||
sessionId: "sess-live",
|
sessionId: "sess-live",
|
||||||
mode: "conversation",
|
|
||||||
cwd: "/tmp/live",
|
cwd: "/tmp/live",
|
||||||
model: "claude-opus-5",
|
model: "claude-opus-5",
|
||||||
status: "running",
|
status: "running",
|
||||||
prompt: "the live prompt",
|
startedAt: "2000-01-01T00:50:00Z",
|
||||||
startedAt: 3000,
|
promptPreview: "the live prompt",
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -44,7 +44,6 @@ function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistor
|
|||||||
return {
|
return {
|
||||||
id: PAST_ID,
|
id: PAST_ID,
|
||||||
session_id: "sess-past",
|
session_id: "sess-past",
|
||||||
mode: "conversation",
|
|
||||||
cwd: "/tmp/past",
|
cwd: "/tmp/past",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -64,12 +63,26 @@ const PAST = historyItem({});
|
|||||||
const HEADLESS = historyItem({
|
const HEADLESS = historyItem({
|
||||||
id: HEADLESS_ID,
|
id: HEADLESS_ID,
|
||||||
session_id: "sess-headless",
|
session_id: "sess-headless",
|
||||||
mode: "headless",
|
|
||||||
cwd: "/tmp/headless",
|
cwd: "/tmp/headless",
|
||||||
prompt_preview: "the headless prompt",
|
prompt_preview: "the headless prompt",
|
||||||
started_at: new Date(1000).toISOString(),
|
started_at: new Date(1000).toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function externalSession(over: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: "sess-external",
|
||||||
|
name: "the external prompt",
|
||||||
|
status: "active",
|
||||||
|
cwd: "/tmp/external",
|
||||||
|
model: "claude-opus-5",
|
||||||
|
started_at: new Date(3000).toISOString(),
|
||||||
|
ended_at: null,
|
||||||
|
updated_at: new Date(3000).toISOString(),
|
||||||
|
source: "local",
|
||||||
|
...over,
|
||||||
|
} as unknown as Session;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||||
const spies = {
|
const spies = {
|
||||||
onAttach: vi.fn(),
|
onAttach: vi.fn(),
|
||||||
@@ -95,10 +108,9 @@ function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionId: `sess-${id}`,
|
sessionId: `sess-${id}`,
|
||||||
mode: "conversation",
|
|
||||||
cwd: `/tmp/${id}`,
|
cwd: `/tmp/${id}`,
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
status: "completed",
|
status: "abandoned",
|
||||||
promptPreview: `prompt of ${id}`,
|
promptPreview: `prompt of ${id}`,
|
||||||
startedAt: 1000,
|
startedAt: 1000,
|
||||||
endedAt: 2000,
|
endedAt: 2000,
|
||||||
@@ -192,6 +204,44 @@ describe("ActiveRunsSwitcher", () => {
|
|||||||
expect(screen.queryByText("stale copy")).toBeNull();
|
expect(screen.queryByText("stale copy")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts and lists a session started outside the dashboard, resumable not attachable", () => {
|
||||||
|
const { spies } = renderSwitcher({
|
||||||
|
activeRuns: null,
|
||||||
|
runHistory: [],
|
||||||
|
externalSessions: [externalSession()],
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||||
|
expect(screen.getByText("the external prompt")).toBeTruthy();
|
||||||
|
expect(screen.getByText(i18n.t("run:runs.externalBadge"))).toBeTruthy();
|
||||||
|
// No tmux session of ours to attach to — Resume is the only action.
|
||||||
|
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
expect(spies.onResumeFromHistory).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session:sess-external",
|
||||||
|
session_id: "sess-external",
|
||||||
|
cwd: "/tmp/external",
|
||||||
|
status: "running",
|
||||||
|
isLive: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips external sessions already covered by a run, remote ones, and cwd-less ones", () => {
|
||||||
|
renderSwitcher({
|
||||||
|
runHistory: [],
|
||||||
|
externalSessions: [
|
||||||
|
externalSession({ id: "sess-live" }), // same session as the live run
|
||||||
|
externalSession({ id: "sess-remote", source: "remote-1" }),
|
||||||
|
externalSession({ id: "sess-nocwd", cwd: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||||
|
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("the external prompt")).toBeNull();
|
||||||
|
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("fires attach with the run id of the row that was clicked", () => {
|
it("fires attach with the run id of the row that was clicked", () => {
|
||||||
const { spies } = renderSwitcher();
|
const { spies } = renderSwitcher();
|
||||||
openModal();
|
openModal();
|
||||||
@@ -216,39 +266,31 @@ describe("RunsModal", () => {
|
|||||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fires resume with the history item behind a finished conversation row", () => {
|
it("fires resume with the history item behind a finished row", () => {
|
||||||
const { spies } = renderModal([row(PAST_ID)]);
|
const { spies } = renderModal([row(PAST_ID)]);
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
||||||
expect(spies.onView).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fires view — not resume — for a finished headless row", () => {
|
it("fires view for a finished row", () => {
|
||||||
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
|
const { spies } = renderModal([row(HEADLESS_ID)]);
|
||||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
||||||
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
||||||
expect(spies.onResume).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by status, by mode and by free text", () => {
|
it("filters by status and by free text", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
||||||
row("b", { status: "error", promptPreview: "bravo" }),
|
row("b", { status: "killed", promptPreview: "bravo" }),
|
||||||
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
|
row("c", { status: "abandoned", promptPreview: "charlie" }),
|
||||||
];
|
];
|
||||||
renderModal(rows);
|
renderModal(rows);
|
||||||
|
|
||||||
fireEvent.click(chip(i18n.t("run:status.error")));
|
fireEvent.click(chip(i18n.t("run:status.killed")));
|
||||||
expect(screen.getByText("bravo")).toBeTruthy();
|
expect(screen.getByText("bravo")).toBeTruthy();
|
||||||
expect(screen.queryByText("alpha")).toBeNull();
|
expect(screen.queryByText("alpha")).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(allChip(0));
|
fireEvent.click(allChip(0));
|
||||||
fireEvent.click(chip(i18n.t("run:mode.headless")));
|
|
||||||
expect(screen.getByText("charlie")).toBeTruthy();
|
|
||||||
expect(screen.queryByText("bravo")).toBeNull();
|
|
||||||
|
|
||||||
fireEvent.click(allChip(1));
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText(
|
screen.getByPlaceholderText(
|
||||||
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ type Spies = ReturnType<typeof renderSetup>["spies"];
|
|||||||
|
|
||||||
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
||||||
const spies = {
|
const spies = {
|
||||||
onModeChange: vi.fn(),
|
|
||||||
onPromptChange: vi.fn(),
|
onPromptChange: vi.fn(),
|
||||||
onCwdChange: vi.fn(),
|
onCwdChange: vi.fn(),
|
||||||
onModelChange: vi.fn(),
|
onModelChange: vi.fn(),
|
||||||
@@ -52,7 +51,7 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
|||||||
const utils = render(
|
const utils = render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<RunSetup
|
<RunSetup
|
||||||
mode="conversation"
|
laneId={1}
|
||||||
prompt="do the thing"
|
prompt="do the thing"
|
||||||
cwd="/Users/tester"
|
cwd="/Users/tester"
|
||||||
cwdSuggestions={SUGGESTIONS}
|
cwdSuggestions={SUGGESTIONS}
|
||||||
@@ -95,15 +94,6 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("RunSetup — selections report through callbacks", () => {
|
describe("RunSetup — selections report through callbacks", () => {
|
||||||
it("reports the mode from the one-shot / conversation options", () => {
|
|
||||||
const { spies } = renderSetup();
|
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
|
|
||||||
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
|
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
|
|
||||||
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
|
|
||||||
onlyCalled(spies, "onModeChange");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the prompt from the editor", () => {
|
it("reports the prompt from the editor", () => {
|
||||||
const { spies } = renderSetup({ prompt: "" });
|
const { spies } = renderSetup({ prompt: "" });
|
||||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||||
@@ -171,7 +161,7 @@ describe("RunSetup — missing binary and other blocked states", () => {
|
|||||||
expect(runButton().disabled).toBe(false);
|
expect(runButton().disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => {
|
it("still disables Run without a prompt or without a cwd", () => {
|
||||||
const { unmount } = renderSetup({ prompt: " " });
|
const { unmount } = renderSetup({ prompt: " " });
|
||||||
expect(runButton().disabled).toBe(true);
|
expect(runButton().disabled).toBe(true);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -179,12 +169,6 @@ describe("RunSetup — missing binary and other blocked states", () => {
|
|||||||
const noCwd = renderSetup({ cwd: "" });
|
const noCwd = renderSetup({ cwd: "" });
|
||||||
expect(runButton().disabled).toBe(true);
|
expect(runButton().disabled).toBe(true);
|
||||||
noCwd.unmount();
|
noCwd.unmount();
|
||||||
|
|
||||||
renderSetup({
|
|
||||||
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
|
|
||||||
});
|
|
||||||
expect(runButton().disabled).toBe(true);
|
|
||||||
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the Starting… label while busy", () => {
|
it("shows the Starting… label while busy", () => {
|
||||||
@@ -217,9 +201,47 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists everything when no lane is selected", async () => {
|
it("starts the resume immediately when a session is picked", async () => {
|
||||||
const { api } = await import("../../../lib/api");
|
const { api } = await import("../../../lib/api");
|
||||||
renderSetup({ laneCwd: undefined });
|
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||||
|
sessions: [
|
||||||
|
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "" });
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
|
||||||
|
|
||||||
|
expect(spies.onResumeSessionChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "sess-in-lane" })
|
||||||
|
);
|
||||||
|
// No prompt typed, no Run click - the pick itself is the start, and it
|
||||||
|
// carries the session's own cwd rather than the form's.
|
||||||
|
expect(spies.onStart).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ resumeSessionId: "sess-in-lane", cwd: "/Users/tester/lane-a" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the typed cwd when no lane is selected", async () => {
|
||||||
|
const { api } = await import("../../../lib/api");
|
||||||
|
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cwd: "/Users/tester" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists everything when no lane is selected and cwd is empty", async () => {
|
||||||
|
const { api } = await import("../../../lib/api");
|
||||||
|
renderSetup({ laneCwd: undefined, cwd: "" });
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
|
||||||
@@ -237,7 +259,7 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
rerender(
|
rerender(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<RunSetup
|
<RunSetup
|
||||||
mode="conversation"
|
laneId={1}
|
||||||
prompt="do the thing"
|
prompt="do the thing"
|
||||||
cwd="/Users/tester"
|
cwd="/Users/tester"
|
||||||
cwdSuggestions={SUGGESTIONS}
|
cwdSuggestions={SUGGESTIONS}
|
||||||
@@ -251,7 +273,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
slashCommands={[]}
|
slashCommands={[]}
|
||||||
runHistory={[]}
|
runHistory={[]}
|
||||||
laneCwd="/Users/tester/lane-b"
|
laneCwd="/Users/tester/lane-b"
|
||||||
onModeChange={vi.fn()}
|
|
||||||
onPromptChange={vi.fn()}
|
onPromptChange={vi.fn()}
|
||||||
onCwdChange={vi.fn()}
|
onCwdChange={vi.fn()}
|
||||||
onModelChange={vi.fn()}
|
onModelChange={vi.fn()}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @file TerminalView.test.tsx
|
||||||
|
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||||
|
* a WS connection to the right URL, writes incoming binary frames to the
|
||||||
|
* mocked terminal, and forwards typed input as outgoing binary frames.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { TerminalView } from "../TerminalView";
|
||||||
|
|
||||||
|
const writeMock = vi.fn();
|
||||||
|
const onDataHandlers: Array<(d: string) => void> = [];
|
||||||
|
const openMock = vi.fn();
|
||||||
|
const disposeMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", () => ({
|
||||||
|
Terminal: vi.fn().mockImplementation(() => ({
|
||||||
|
open: openMock,
|
||||||
|
write: writeMock,
|
||||||
|
onData: (fn: (d: string) => void) => {
|
||||||
|
onDataHandlers.push(fn);
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
dispose: disposeMock,
|
||||||
|
loadAddon: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class MockWebSocket {
|
||||||
|
static instances: MockWebSocket[] = [];
|
||||||
|
url: string;
|
||||||
|
sent: unknown[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((e: { data: unknown }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
MockWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
send(data: unknown) {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @ts-expect-error test override
|
||||||
|
global.WebSocket = MockWebSocket;
|
||||||
|
|
||||||
|
describe("TerminalView", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
MockWebSocket.instances = [];
|
||||||
|
onDataHandlers.length = 0;
|
||||||
|
writeMock.mockClear();
|
||||||
|
openMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("opens a WS connection to the run's ws-pty path", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/ccam-lane-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes incoming WS data to the terminal", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws.onopen?.();
|
||||||
|
ws.onmessage?.({ data: "hello" });
|
||||||
|
expect(writeMock).toHaveBeenCalledWith("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws.onopen?.();
|
||||||
|
const bytes = new TextEncoder().encode("hello-binary").buffer;
|
||||||
|
ws.onmessage?.({ data: bytes });
|
||||||
|
expect(writeMock).toHaveBeenCalledWith("hello-binary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards terminal keystrokes as outgoing WS sends", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
onDataHandlers[0]!("ls -la\r");
|
||||||
|
expect(ws.sent).toEqual(["ls -la\r"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
|
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -581,8 +576,8 @@ export function AgentCollaborationNetwork({
|
|||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<p className="text-sm font-medium text-gray-400">{t("pipeline.noData")}</p>
|
<p className="text-sm font-medium text-fg-secondary">{t("pipeline.noData")}</p>
|
||||||
<p className="text-xs text-gray-600 mt-1">{t("pipeline.noDataDesc")}</p>
|
<p className="text-xs text-fg-muted mt-1">{t("pipeline.noDataDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -615,7 +610,7 @@ export function AgentCollaborationNetwork({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-3 px-1">
|
<div className="flex flex-wrap items-center gap-3 mt-3 px-1">
|
||||||
<span className="text-[10px] text-gray-600 uppercase tracking-widest font-medium">
|
<span className="text-[10px] text-fg-muted uppercase tracking-widest font-medium">
|
||||||
{t("pipeline.legend")}
|
{t("pipeline.legend")}
|
||||||
</span>
|
</span>
|
||||||
{nodes.map((n) => (
|
{nodes.map((n) => (
|
||||||
@@ -627,7 +622,7 @@ export function AgentCollaborationNetwork({
|
|||||||
border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`,
|
border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-gray-500">{n.id}</span>
|
<span className="text-[11px] text-fg-muted">{n.id}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
@@ -635,7 +630,7 @@ export function AgentCollaborationNetwork({
|
|||||||
<line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" />
|
<line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" />
|
||||||
<polygon points="14,1 20,4 14,7" fill="#64748b" />
|
<polygon points="14,1 20,4 14,7" fill="#64748b" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-[11px] text-gray-500">{t("pipeline.legendDesc")}</span>
|
<span className="text-[11px] text-fg-muted">{t("pipeline.legendDesc")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,11 +25,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -284,10 +279,10 @@ function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
|
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
|
||||||
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
|
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
|
||||||
<span className="text-[11px] font-medium text-gray-500 uppercase tracking-wider leading-tight">
|
<span className="text-[11px] font-medium text-fg-muted uppercase tracking-wider leading-tight">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{sub && <span className="text-[11px] text-gray-600 tabular-nums">{sub}</span>}
|
{sub && <span className="text-[11px] text-fg-muted tabular-nums">{sub}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -334,7 +329,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
|||||||
|
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500">
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
|
||||||
<svg
|
<svg
|
||||||
width="40"
|
width="40"
|
||||||
height="40"
|
height="40"
|
||||||
@@ -357,7 +352,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{/* What compaction is - one line so the numbers below make sense */}
|
{/* What compaction is - one line so the numbers below make sense */}
|
||||||
<p className="text-xs text-gray-500 leading-relaxed">{t("compaction.help")}</p>
|
<p className="text-xs text-fg-muted leading-relaxed">{t("compaction.help")}</p>
|
||||||
|
|
||||||
{/* Stat tiles */}
|
{/* Stat tiles */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
@@ -375,18 +370,18 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
|||||||
<StatBox
|
<StatBox
|
||||||
label={t("compaction.avgPerSession")}
|
label={t("compaction.avgPerSession")}
|
||||||
value={avgPerSession.toFixed(1)}
|
value={avgPerSession.toFixed(1)}
|
||||||
accent="text-blue-300"
|
accent="text-blue-400"
|
||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
label={t("compaction.peakSession")}
|
label={t("compaction.peakSession")}
|
||||||
value={peak.toLocaleString()}
|
value={peak.toLocaleString()}
|
||||||
accent="text-emerald-400"
|
accent="text-status-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Histogram: sessions by compaction count */}
|
{/* Histogram: sessions by compaction count */}
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-2">
|
||||||
{t("compaction.distribution")}
|
{t("compaction.distribution")}
|
||||||
</p>
|
</p>
|
||||||
<svg
|
<svg
|
||||||
@@ -399,7 +394,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plain-English summary + (when present) tokens freed */}
|
{/* Plain-English summary + (when present) tokens freed */}
|
||||||
<p className="text-xs text-gray-500 leading-relaxed">
|
<p className="text-xs text-fg-muted leading-relaxed">
|
||||||
{t("compaction.summary", {
|
{t("compaction.summary", {
|
||||||
affected: affected.toLocaleString(),
|
affected: affected.toLocaleString(),
|
||||||
total: data.totalSessions.toLocaleString(),
|
total: data.totalSessions.toLocaleString(),
|
||||||
@@ -420,8 +415,8 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
|||||||
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
|
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-gray-100">{tip.title}</div>
|
<div className="font-medium text-fg-primary">{tip.title}</div>
|
||||||
<div className="mt-0.5 text-gray-400">{tip.detail}</div>
|
<div className="mt-0.5 text-fg-secondary">{tip.detail}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -121,7 +116,7 @@ function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps)
|
|||||||
<div className="flex items-center gap-3 py-1.5 group">
|
<div className="flex items-center gap-3 py-1.5 group">
|
||||||
{/* Label column */}
|
{/* Label column */}
|
||||||
<div className="flex-shrink-0 w-[140px] text-right" title={displayName}>
|
<div className="flex-shrink-0 w-[140px] text-right" title={displayName}>
|
||||||
<span className="text-xs font-medium text-gray-400 truncate block group-hover:text-gray-200 transition-colors">
|
<span className="text-xs font-medium text-fg-secondary truncate block group-hover:text-fg-secondary transition-colors">
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +149,7 @@ function LaneRow({ lane, color, maxCount, onShowTip, onHideTip }: LaneRowProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timing range */}
|
{/* Timing range */}
|
||||||
<div className="flex-shrink-0 w-[72px] text-[11px] text-gray-600 tabular-nums">
|
<div className="flex-shrink-0 w-[72px] text-[11px] text-fg-muted tabular-nums">
|
||||||
{startPct}%–{endPct}%
|
{startPct}%–{endPct}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +224,7 @@ function EmptyState() {
|
|||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-gray-600"
|
className="w-5 h-5 text-fg-muted"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -240,8 +235,8 @@ function EmptyState() {
|
|||||||
<rect x="3" y="16" width="15" height="4" rx="1" />
|
<rect x="3" y="16" width="15" height="4" rx="1" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-400">{t("concurrency.noData")}</p>
|
<p className="text-sm font-medium text-fg-secondary">{t("concurrency.noData")}</p>
|
||||||
<p className="text-xs text-gray-600 mt-1">{t("concurrency.noDataDesc")}</p>
|
<p className="text-xs text-fg-muted mt-1">{t("concurrency.noDataDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -314,15 +309,15 @@ export function ConcurrencyTimeline({ data }: ConcurrencyTimelineProps) {
|
|||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="flex-shrink-0 w-[140px]" />
|
<div className="flex-shrink-0 w-[140px]" />
|
||||||
<div className="flex-1 flex items-center justify-between">
|
<div className="flex-1 flex items-center justify-between">
|
||||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider">
|
<span className="text-[10px] text-fg-muted uppercase tracking-wider">
|
||||||
{t("concurrency.sessions")}
|
{t("concurrency.sessions")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-gray-600 tabular-nums">
|
<span className="text-[10px] text-fg-muted tabular-nums">
|
||||||
{maxCount}
|
{maxCount}
|
||||||
{t("concurrency.max")}
|
{t("concurrency.max")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 w-[72px] text-[10px] text-gray-600 uppercase tracking-wider">
|
<div className="flex-shrink-0 w-[72px] text-[10px] text-fg-muted uppercase tracking-wider">
|
||||||
{t("concurrency.timing")}
|
{t("concurrency.timing")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -92,7 +87,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
if (!hasErrors) {
|
if (!hasErrors) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full bg-status-success/10 border border-status-success/20 flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
@@ -107,10 +102,10 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
<polyline points="22 4 12 14.01 9 11.01" />
|
<polyline points="22 4 12 14.01 9 11.01" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-emerald-400 font-medium">
|
<span className="text-sm text-status-success font-medium">
|
||||||
{t("errorPropagation.noErrors")}
|
{t("errorPropagation.noErrors")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-600">{t("errorPropagation.allSuccess")}</span>
|
<span className="text-xs text-fg-muted">{t("errorPropagation.allSuccess")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,20 +119,20 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Error rate summary bar */}
|
{/* Error rate summary bar */}
|
||||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-red-500/5 border border-red-500/15">
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-status-danger/5 border border-status-danger/15">
|
||||||
<div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center">
|
<div className="flex-shrink-0 min-w-[2.75rem] h-10 px-2 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center">
|
||||||
<span className="text-[13px] font-bold text-red-400 tabular-nums whitespace-nowrap">
|
<span className="text-[13px] font-bold text-status-danger tabular-nums whitespace-nowrap">
|
||||||
{errorRatePct}%
|
{errorRatePct}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium text-red-300">
|
<p className="text-xs font-medium text-status-danger">
|
||||||
{t("errorPropagation.sessionsErrorSummary", {
|
{t("errorPropagation.sessionsErrorSummary", {
|
||||||
errorSessions: data.sessionsWithErrors,
|
errorSessions: data.sessionsWithErrors,
|
||||||
totalSessions: data.totalSessions,
|
totalSessions: data.totalSessions,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
<p className="text-[11px] text-fg-muted mt-0.5">
|
||||||
{totalErrors > 0
|
{totalErrors > 0
|
||||||
? `${totalErrors}${t("errorPropagation.agentErrors")}`
|
? `${totalErrors}${t("errorPropagation.agentErrors")}`
|
||||||
: t("errorPropagation.sessionErrorsOnly")}
|
: t("errorPropagation.sessionErrorsOnly")}
|
||||||
@@ -148,7 +143,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
{/* Errors by depth - horizontal bars */}
|
{/* Errors by depth - horizontal bars */}
|
||||||
{hasDepthData && (
|
{hasDepthData && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
|
<p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
|
||||||
{t("errorPropagation.errorsByDepth")}
|
{t("errorPropagation.errorsByDepth")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -165,7 +160,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
onMouseEnter={() => setHoveredDepth(d.depth)}
|
onMouseEnter={() => setHoveredDepth(d.depth)}
|
||||||
onMouseLeave={() => setHoveredDepth(null)}
|
onMouseLeave={() => setHoveredDepth(null)}
|
||||||
>
|
>
|
||||||
<span className="text-[11px] text-gray-500 w-24 flex-shrink-0 text-right truncate">
|
<span className="text-[11px] text-fg-muted w-24 flex-shrink-0 text-right truncate">
|
||||||
{depthLabel(d.depth)}
|
{depthLabel(d.depth)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 h-5 bg-surface-3 rounded overflow-hidden relative">
|
<div className="flex-1 h-5 bg-surface-3 rounded overflow-hidden relative">
|
||||||
@@ -194,7 +189,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
{/* Error-prone agent types */}
|
{/* Error-prone agent types */}
|
||||||
{topTypes.length > 0 && (
|
{topTypes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
|
<p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
|
||||||
{t("errorPropagation.errorProneTypes")}
|
{t("errorPropagation.errorProneTypes")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -210,16 +205,16 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: DEPTH_COLORS[Math.min(i, DEPTH_COLORS.length - 1)] }}
|
style={{ backgroundColor: DEPTH_COLORS[Math.min(i, DEPTH_COLORS.length - 1)] }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-300 truncate flex-1 min-w-0">
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
{t.subagent_type}
|
{t.subagent_type}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0">
|
<div className="w-16 h-1.5 bg-surface-4 rounded-full overflow-hidden flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-red-400/60"
|
className="h-full rounded-full bg-status-danger/60"
|
||||||
style={{ width: `${Math.max(pct, 8)}%` }}
|
style={{ width: `${Math.max(pct, 8)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-semibold text-red-300 tabular-nums w-5 text-right flex-shrink-0">
|
<span className="text-[11px] font-semibold text-status-danger tabular-nums w-5 text-right flex-shrink-0">
|
||||||
{t.count}
|
{t.count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,14 +227,14 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
{/* API & session errors */}
|
{/* API & session errors */}
|
||||||
{data.eventErrors && data.eventErrors.length > 0 && (
|
{data.eventErrors && data.eventErrors.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-2.5">
|
<p className="text-[10px] font-medium text-fg-muted uppercase tracking-wider mb-2.5">
|
||||||
{t("errorPropagation.apiSessionErrors")}
|
{t("errorPropagation.apiSessionErrors")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{data.eventErrors.map((e) => (
|
{data.eventErrors.map((e) => (
|
||||||
<div
|
<div
|
||||||
key={e.summary}
|
key={e.summary}
|
||||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-amber-500/5 border border-amber-500/10 hover:border-amber-500/20 transition-colors"
|
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-status-warning/5 border border-status-warning/10 hover:border-status-warning/20 transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
@@ -256,10 +251,13 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
|||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs text-gray-300 truncate flex-1 min-w-0" title={e.summary}>
|
<span
|
||||||
|
className="text-xs text-fg-secondary truncate flex-1 min-w-0"
|
||||||
|
title={e.summary}
|
||||||
|
>
|
||||||
{e.summary}
|
{e.summary}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 text-[11px] font-semibold text-amber-400 tabular-nums">
|
<span className="flex-shrink-0 text-[11px] font-semibold text-status-warning tabular-nums">
|
||||||
{e.count}x
|
{e.count}x
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `../../lib/format`
|
* - `../../lib/format`
|
||||||
@@ -402,7 +397,7 @@ export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) {
|
|||||||
|
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500">
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
|
||||||
<svg
|
<svg
|
||||||
width="40"
|
width="40"
|
||||||
height="40"
|
height="40"
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -787,7 +782,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
className="w-6 h-6 text-gray-500"
|
className="w-6 h-6 text-fg-muted"
|
||||||
>
|
>
|
||||||
<circle cx="6" cy="12" r="2" />
|
<circle cx="6" cy="12" r="2" />
|
||||||
<circle cx="18" cy="6" r="2" />
|
<circle cx="18" cy="6" r="2" />
|
||||||
@@ -796,8 +791,10 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
<line x1="8" y1="13" x2="16" y2="17" />
|
<line x1="8" y1="13" x2="16" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-medium text-gray-300 mb-2">{t("orchestration.noData")}</h3>
|
<h3 className="text-base font-medium text-fg-secondary mb-2">
|
||||||
<p className="text-sm text-gray-500 max-w-sm">{t("orchestration.noDataDesc")}</p>
|
{t("orchestration.noData")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-fg-muted max-w-sm">{t("orchestration.noDataDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -831,7 +828,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 px-1 mt-4">
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 px-1 mt-4">
|
||||||
<span className="text-[10px] text-gray-600 uppercase tracking-widest font-medium mr-1">
|
<span className="text-[10px] text-fg-muted uppercase tracking-widest font-medium mr-1">
|
||||||
{t("orchestration.legend")}
|
{t("orchestration.legend")}
|
||||||
</span>
|
</span>
|
||||||
{LEGEND_ITEMS.map((item) => (
|
{LEGEND_ITEMS.map((item) => (
|
||||||
@@ -840,7 +837,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
|
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
|
||||||
style={{ background: item.color, border: `1px solid ${item.border}` }}
|
style={{ background: item.color, border: `1px solid ${item.border}` }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-gray-500">{item.label}</span>
|
<span className="text-[11px] text-fg-muted">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
@@ -848,7 +845,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
|||||||
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
|
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
|
||||||
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
|
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-gray-500">{t("orchestration.edgeWeight")}</span>
|
<span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -882,12 +879,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
|
|||||||
const meta = describeNode(node, t);
|
const meta = describeNode(node, t);
|
||||||
|
|
||||||
const title = document.createElement("p");
|
const title = document.createElement("p");
|
||||||
title.className = "text-xs font-semibold text-gray-200";
|
title.className = "text-xs font-semibold text-fg-secondary";
|
||||||
title.textContent = node.label;
|
title.textContent = node.label;
|
||||||
el.appendChild(title);
|
el.appendChild(title);
|
||||||
|
|
||||||
const subtitle = document.createElement("p");
|
const subtitle = document.createElement("p");
|
||||||
subtitle.className = "text-[10px] uppercase tracking-wider text-gray-600 mt-0.5 mb-2";
|
subtitle.className = "text-[10px] uppercase tracking-wider text-fg-muted mt-0.5 mb-2";
|
||||||
subtitle.textContent = meta.layer;
|
subtitle.textContent = meta.layer;
|
||||||
el.appendChild(subtitle);
|
el.appendChild(subtitle);
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `../../lib/format`
|
* - `../../lib/format`
|
||||||
@@ -121,17 +116,17 @@ function Tooltip({ state }: { state: TooltipState }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-gray-200 pointer-events-none whitespace-nowrap"
|
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||||
style={{
|
style={{
|
||||||
left: nearRight ? state.x - 12 : state.x + 12,
|
left: nearRight ? state.x - 12 : state.x + 12,
|
||||||
top: state.y - 10,
|
top: state.y - 10,
|
||||||
transform: nearRight ? "translateX(-100%)" : undefined,
|
transform: nearRight ? "translateX(-100%)" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-gray-100 mb-1 truncate max-w-[180px]">
|
<p className="font-semibold text-fg-primary mb-1 truncate max-w-[180px]">
|
||||||
{state.item.name ?? state.item.id.slice(0, 12)}
|
{state.item.name ?? state.item.id.slice(0, 12)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-0.5 text-gray-400">
|
<div className="flex flex-col gap-0.5 text-fg-secondary">
|
||||||
<span>
|
<span>
|
||||||
{t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)}
|
{t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)}
|
||||||
</span>
|
</span>
|
||||||
@@ -173,7 +168,7 @@ function Legend() {
|
|||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: statusColor(s) }}
|
style={{ backgroundColor: statusColor(s) }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-fg-muted">
|
||||||
{t(`common:status.${s}`, { defaultValue: s })}
|
{t(`common:status.${s}`, { defaultValue: s })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +185,7 @@ function EmptyState() {
|
|||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-3">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-gray-600"
|
className="w-5 h-5 text-fg-muted"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -201,8 +196,8 @@ function EmptyState() {
|
|||||||
<circle cx="14" cy="17" r="4" />
|
<circle cx="14" cy="17" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-400">{t("complexity.noData")}</p>
|
<p className="text-sm font-medium text-fg-secondary">{t("complexity.noData")}</p>
|
||||||
<p className="text-xs text-gray-600 mt-1">{t("complexity.noDataDesc")}</p>
|
<p className="text-xs text-fg-muted mt-1">{t("complexity.noDataDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/api`
|
* - `../../lib/api`
|
||||||
* - `../../lib/format`
|
* - `../../lib/format`
|
||||||
@@ -83,15 +78,15 @@ function statusColor(status: string): string {
|
|||||||
case "completed":
|
case "completed":
|
||||||
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
|
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||||
case "working":
|
case "working":
|
||||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
|
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||||
case "error":
|
case "error":
|
||||||
return "text-red-400 bg-red-500/10 border-red-500/20";
|
return "text-status-danger bg-status-danger/10 border-status-danger/20";
|
||||||
case "active":
|
case "active":
|
||||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
|
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||||
case "waiting":
|
case "waiting":
|
||||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||||
default:
|
default:
|
||||||
return "text-gray-400 bg-gray-500/10 border-gray-500/20";
|
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +135,8 @@ function TabBar({ active, onChange }: TabBarProps) {
|
|||||||
className={[
|
className={[
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
|
||||||
active === tab.id
|
active === tab.id
|
||||||
? "bg-surface-5 text-gray-100 shadow-sm"
|
? "bg-surface-5 text-fg-primary shadow-sm"
|
||||||
: "text-gray-500 hover:text-gray-300",
|
: "text-fg-muted hover:text-fg-secondary",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
@@ -192,20 +187,20 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
|||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-gray-200"}`}
|
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Subagent type */}
|
{/* Subagent type */}
|
||||||
{node.subagent_type && (
|
{node.subagent_type && (
|
||||||
<span className="text-xs text-gray-500 truncate flex-shrink-0">
|
<span className="text-xs text-fg-muted truncate flex-shrink-0">
|
||||||
[{node.subagent_type}]
|
[{node.subagent_type}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Duration */}
|
{/* Duration */}
|
||||||
<span className="ml-auto flex-shrink-0 text-xs text-gray-600 tabular-nums">{dur}</span>
|
<span className="ml-auto flex-shrink-0 text-xs text-fg-muted tabular-nums">{dur}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{node.children.length > 0 && (
|
{node.children.length > 0 && (
|
||||||
@@ -226,7 +221,7 @@ interface AgentTreeProps {
|
|||||||
function AgentTree({ tree }: AgentTreeProps) {
|
function AgentTree({ tree }: AgentTreeProps) {
|
||||||
const { t } = useTranslation("workflows");
|
const { t } = useTranslation("workflows");
|
||||||
if (tree.length === 0) {
|
if (tree.length === 0) {
|
||||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noAgentTree")}</p>;
|
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noAgentTree")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -249,7 +244,7 @@ interface ToolTimelineProps {
|
|||||||
function ToolTimeline({ events }: ToolTimelineProps) {
|
function ToolTimeline({ events }: ToolTimelineProps) {
|
||||||
const { t } = useTranslation("workflows");
|
const { t } = useTranslation("workflows");
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noToolEvents")}</p>;
|
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noToolEvents")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -267,11 +262,13 @@ function ToolTimeline({ events }: ToolTimelineProps) {
|
|||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
{ev.summary && (
|
{ev.summary && (
|
||||||
<span className="text-xs text-gray-400 truncate flex-1 min-w-0">{ev.summary}</span>
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
|
{ev.summary}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums ml-auto">
|
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums ml-auto">
|
||||||
{safeTimestamp(ev.created_at)}
|
{safeTimestamp(ev.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,22 +285,22 @@ interface EventSequenceProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_TYPE_COLOR: Record<string, string> = {
|
const EVENT_TYPE_COLOR: Record<string, string> = {
|
||||||
tool_use: "text-blue-400",
|
tool_use: "text-blue-500",
|
||||||
tool_result: "text-emerald-400",
|
tool_result: "text-status-success",
|
||||||
agent_start: "text-indigo-400",
|
agent_start: "text-indigo-400",
|
||||||
agent_stop: "text-violet-400",
|
agent_stop: "text-violet-400",
|
||||||
compaction: "text-amber-400",
|
compaction: "text-status-warning",
|
||||||
error: "text-red-400",
|
error: "text-status-danger",
|
||||||
};
|
};
|
||||||
|
|
||||||
function eventTypeColor(type: string): string {
|
function eventTypeColor(type: string): string {
|
||||||
return EVENT_TYPE_COLOR[type] ?? "text-gray-400";
|
return EVENT_TYPE_COLOR[type] ?? "text-fg-secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventSequence({ events }: EventSequenceProps) {
|
function EventSequence({ events }: EventSequenceProps) {
|
||||||
const { t } = useTranslation("workflows");
|
const { t } = useTranslation("workflows");
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noEvents")}</p>;
|
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noEvents")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recent = events.slice(0, 100);
|
const recent = events.slice(0, 100);
|
||||||
@@ -325,18 +322,18 @@ function EventSequence({ events }: EventSequenceProps) {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<span className="text-xs text-gray-400 flex-1 min-w-0 truncate">
|
<span className="text-xs text-fg-secondary flex-1 min-w-0 truncate">
|
||||||
{ev.summary ?? ev.tool_name ?? "-"}
|
{ev.summary ?? ev.tool_name ?? "-"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{safeTimestamp(ev.created_at)}
|
{safeTimestamp(ev.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{events.length > 100 && (
|
{events.length > 100 && (
|
||||||
<p className="text-xs text-gray-600 text-center py-2">
|
<p className="text-xs text-fg-muted text-center py-2">
|
||||||
{t("drillIn.showingOf", { total: events.length })}
|
{t("drillIn.showingOf", { total: events.length })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -365,11 +362,11 @@ function ErrorState({ message }: ErrorStateProps) {
|
|||||||
const { t } = useTranslation("workflows");
|
const { t } = useTranslation("workflows");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center px-4">
|
<div className="flex flex-col items-center justify-center py-10 text-center px-4">
|
||||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-3">
|
<div className="w-9 h-9 rounded-xl bg-status-danger/10 border border-status-danger/20 flex items-center justify-center mb-3">
|
||||||
<X className="w-4 h-4 text-red-400" />
|
<X className="w-4 h-4 text-status-danger" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-red-400">{t("drillIn.failedLoad")}</p>
|
<p className="text-sm font-medium text-status-danger">{t("drillIn.failedLoad")}</p>
|
||||||
<p className="text-xs text-gray-600 mt-1 max-w-xs">{message}</p>
|
<p className="text-xs text-fg-muted mt-1 max-w-xs">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -405,17 +402,19 @@ function NoSessionState({ onSelectSession }: NoSessionStateProps) {
|
|||||||
|
|
||||||
<div className="flex flex-col items-center text-center mt-2">
|
<div className="flex flex-col items-center text-center mt-2">
|
||||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4">
|
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4">
|
||||||
<GitFork className="w-5 h-5 text-gray-600" />
|
<GitFork className="w-5 h-5 text-fg-muted" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-400 mb-1">{t("drillIn.noSessionSelected")}</p>
|
<p className="text-sm font-medium text-fg-secondary mb-1">
|
||||||
<p className="text-xs text-gray-600 max-w-xs">{t("drillIn.noSessionDesc")}</p>
|
{t("drillIn.noSessionSelected")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-fg-muted max-w-xs">{t("drillIn.noSessionDesc")}</p>
|
||||||
|
|
||||||
{/* Preview tab pills */}
|
{/* Preview tab pills */}
|
||||||
<div className="flex gap-2 mt-5">
|
<div className="flex gap-2 mt-5">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-600 bg-surface-3 border border-border"
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-fg-muted bg-surface-3 border border-border"
|
||||||
>
|
>
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -444,10 +443,10 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
|||||||
<div className="flex flex-col gap-3 mb-4">
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-gray-100 truncate">
|
<p className="text-sm font-semibold text-fg-primary truncate">
|
||||||
{session.name ?? session.id}
|
{session.name ?? session.id}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
<p className="text-xs text-fg-muted mt-0.5 truncate">
|
||||||
{formatModelName(session.model) ?? t("drillIn.unknownModel")} ·{" "}
|
{formatModelName(session.model) ?? t("drillIn.unknownModel")} ·{" "}
|
||||||
{t(`common:status.${session.status}`, { defaultValue: session.status })}
|
{t(`common:status.${session.status}`, { defaultValue: session.status })}
|
||||||
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
||||||
@@ -456,7 +455,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-gray-500 hover:text-gray-200 hover:bg-white/10 transition-colors"
|
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||||
aria-label={t("drillIn.closePanel")}
|
aria-label={t("drillIn.closePanel")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -569,13 +568,13 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
placeholder={t("drillIn.searchPlaceholder")}
|
placeholder={t("drillIn.searchPlaceholder")}
|
||||||
className="flex-1 bg-transparent text-xs text-gray-200 placeholder-gray-600 outline-none min-w-0"
|
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
@@ -584,7 +583,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
/>
|
/>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={[
|
className={[
|
||||||
"w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform duration-150",
|
"w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform duration-150",
|
||||||
open ? "rotate-180" : "",
|
open ? "rotate-180" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
/>
|
/>
|
||||||
@@ -605,7 +604,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<p className="text-xs text-gray-600 text-center py-6 px-3">
|
<p className="text-xs text-fg-muted text-center py-6 px-3">
|
||||||
{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}
|
{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -625,22 +624,22 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
{s.status}
|
{s.status}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 min-w-0">
|
<span className="flex-1 min-w-0">
|
||||||
<span className="block text-xs font-medium text-gray-200 truncate">
|
<span className="block text-xs font-medium text-fg-secondary truncate">
|
||||||
{s.name ?? s.id}
|
{s.name ?? s.id}
|
||||||
</span>
|
</span>
|
||||||
{s.name && (
|
{s.name && (
|
||||||
<span className="block text-[10px] text-gray-600 font-mono truncate">
|
<span className="block text-[10px] text-fg-muted font-mono truncate">
|
||||||
{s.id}
|
{s.id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{s.model && (
|
{s.model && (
|
||||||
<span className="flex-shrink-0 text-[10px] text-gray-500 truncate max-w-[80px]">
|
<span className="flex-shrink-0 text-[10px] text-fg-muted truncate max-w-[80px]">
|
||||||
{formatModelName(s.model)}
|
{formatModelName(s.model)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{s.started_at && (
|
{s.started_at && (
|
||||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums">
|
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums">
|
||||||
{safeTimestamp(s.started_at)}
|
{safeTimestamp(s.started_at)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -656,7 +655,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleLoadMore}
|
onClick={handleLoadMore}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full px-3 py-2 text-xs text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
|
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
|
||||||
</button>
|
</button>
|
||||||
@@ -737,11 +736,11 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
|
|||||||
<div className="bg-surface-2 border border-border rounded-xl p-4">
|
<div className="bg-surface-2 border border-border rounded-xl p-4">
|
||||||
<SessionSelector onSelectSession={onSelectSession} />
|
<SessionSelector onSelectSession={onSelectSession} />
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-xs text-gray-600 font-mono truncate">{sessionId}</p>
|
<p className="text-xs text-fg-muted font-mono truncate">{sessionId}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-600 hover:text-gray-300 hover:bg-white/10 transition-colors"
|
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||||
aria-label={t("drillIn.close")}
|
aria-label={t("drillIn.close")}
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -146,7 +141,7 @@ function SuccessRing({ rate, color }: SuccessRingProps) {
|
|||||||
{clampedRate.toFixed(0)}%
|
{clampedRate.toFixed(0)}%
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-[10px] font-medium text-gray-500 uppercase tracking-wider">
|
<span className="text-[10px] font-medium text-fg-muted uppercase tracking-wider">
|
||||||
{t("effectiveness.success")}
|
{t("effectiveness.success")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +208,7 @@ function Sparkline({ data, color }: SparklineProps) {
|
|||||||
{bars.map((_, i) => (
|
{bars.map((_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 text-center text-[8px] text-gray-600 leading-none select-none"
|
className="flex-1 text-center text-[8px] text-fg-muted leading-none select-none"
|
||||||
>
|
>
|
||||||
{dayLabels[i % dayLabels.length] ?? ""}
|
{dayLabels[i % dayLabels.length] ?? ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -281,11 +276,11 @@ function SparklineTooltip({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
className="fixed z-[60] px-2 py-1 bg-[#12121f] border border-[#2a2a4a] rounded-md shadow-xl text-[10px] text-gray-200 whitespace-nowrap pointer-events-none"
|
className="fixed z-[60] px-2 py-1 bg-[#12121f] border border-[#2a2a4a] rounded-md shadow-xl text-[10px] text-fg-secondary whitespace-nowrap pointer-events-none"
|
||||||
style={{ left: pos.left, top: pos.top }}
|
style={{ left: pos.left, top: pos.top }}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{label}</span>
|
<span className="font-medium">{label}</span>
|
||||||
<span className="text-gray-400 mx-1">·</span>
|
<span className="text-fg-secondary mx-1">·</span>
|
||||||
<span className="tabular-nums" style={{ color }}>
|
<span className="tabular-nums" style={{ color }}>
|
||||||
{t("effectiveness.sessionCount", { count: value })}
|
{t("effectiveness.sessionCount", { count: value })}
|
||||||
</span>
|
</span>
|
||||||
@@ -302,10 +297,10 @@ interface MetricBoxProps {
|
|||||||
function MetricBox({ label, value }: MetricBoxProps) {
|
function MetricBox({ label, value }: MetricBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-0.5 bg-surface-3 rounded-lg px-2 py-2 flex-1 min-w-0 overflow-hidden">
|
<div className="flex flex-col items-center gap-0.5 bg-surface-3 rounded-lg px-2 py-2 flex-1 min-w-0 overflow-hidden">
|
||||||
<span className="text-xs font-semibold text-gray-200 tabular-nums truncate w-full text-center">
|
<span className="text-xs font-semibold text-fg-secondary tabular-nums truncate w-full text-center">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-gray-500 uppercase tracking-wider truncate w-full text-center">
|
<span className="text-[9px] text-fg-muted uppercase tracking-wider truncate w-full text-center">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,7 +332,7 @@ function ScoreCard({ item, colorIndex }: ScoreCardProps) {
|
|||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-200 truncate" title={item.subagent_type}>
|
<span className="text-sm font-medium text-fg-secondary truncate" title={item.subagent_type}>
|
||||||
{item.subagent_type}
|
{item.subagent_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +353,7 @@ function ScoreCard({ item, colorIndex }: ScoreCardProps) {
|
|||||||
|
|
||||||
{/* Sparkline */}
|
{/* Sparkline */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-[10px] text-gray-500 uppercase tracking-wider">
|
<span className="text-[10px] text-fg-muted uppercase tracking-wider">
|
||||||
{t("effectiveness.weeklyActivity")}
|
{t("effectiveness.weeklyActivity")}
|
||||||
</span>
|
</span>
|
||||||
<Sparkline data={item.trend} color={color} />
|
<Sparkline data={item.trend} color={color} />
|
||||||
@@ -375,7 +370,7 @@ export function SubagentEffectiveness({ data }: SubagentEffectivenessProps) {
|
|||||||
const { t } = useTranslation("workflows");
|
const { t } = useTranslation("workflows");
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-gray-500 text-sm">
|
<div className="flex items-center justify-center py-16 text-fg-muted text-sm">
|
||||||
{t("effectiveness.noData")}
|
{t("effectiveness.noData")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -507,7 +502,7 @@ export function ToolExecutionFlow({
|
|||||||
<div className="relative" ref={containerRef} onMouseLeave={hideTip}>
|
<div className="relative" ref={containerRef} onMouseLeave={hideTip}>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<div className="flex items-center justify-center" style={{ height: dimensions.height }}>
|
<div className="flex items-center justify-center" style={{ height: dimensions.height }}>
|
||||||
<span className="text-sm text-gray-500">{t("toolFlow.noData")}</span>
|
<span className="text-sm text-fg-muted">{t("toolFlow.noData")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<svg
|
||||||
@@ -663,7 +658,7 @@ function Legend() {
|
|||||||
style={{ background: color, opacity: 0.9 }}
|
style={{ background: color, opacity: 0.9 }}
|
||||||
className="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
className="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400">{t(`toolLegend.${key}`)}</span>
|
<span className="text-xs text-fg-secondary">{t(`toolLegend.${key}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user