From 9b1fa67385ddec60d02570631c88f0831163d6a0 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 30 Jul 2026 15:27:26 +0700 Subject: [PATCH 1/3] fix(lanes): stop the stuck "waiting for your input" banner and idle status Two lane-card symptoms, one source: touchLaneFromHook. needs_action was stamped on every Notification hook, including Claude Code's bare idle nudge ("Claude is waiting for your input"), which fires ~60s AFTER Stop. Nothing later arrives to clear it, so the card kept a permanent amber "needs you" while the user was simply not typing. The nudge now clears the flag instead of raising it - it proves the CLI is parked at an idle prompt. Permission/AskUserQuestion messages are unchanged. lane.status only moved through the dashboard run lifecycle (run_id), so a lane driven by `claude` in a terminal read "idle" for the whole time Claude was working in it. For lanes the dashboard did not launch, the turn hooks now mirror it: UserPromptSubmit/PreToolUse/PostToolUse -> running, Stop/SessionEnd/idle-nudge -> idle. SubagentStop is excluded (a subagent finishing is not the end of the turn) and provisioning/failed are never stomped. Side effect: classifyLiveness treats status=running as "expect live", so a turn with no hook for more than LANE_DEAD_SEC (300s) now shows the dead dot. That is the detector working as designed; raise LANE_DEAD_SEC if a long single Bash trips it. --- server/__tests__/lanes-api.test.js | 76 +++++++++++++++++++++++++++++- server/routes/hooks.js | 42 +++++++++++++++-- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index 3aac561..b89f27e 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -336,6 +336,77 @@ describe("hook → lane binding", () => { await request("DELETE", `/api/lanes/${id}`); }); + + it("the bare idle nudge never raises needs_action and clears a stale one", async () => { + const created = await request("POST", "/api/lanes", { + cwd: "/tmp/lane-idle-nudge", + title: "Idle Nudge", + }); + const id = created.body.lane.id; + + await request("POST", "/api/hooks/event", { + hook_type: "SessionStart", + data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" }, + }); + await request("POST", "/api/hooks/event", { + hook_type: "Notification", + data: { + session_id: "sess-nudge", + cwd: "/tmp/lane-idle-nudge", + message: "Claude needs your permission to use Bash", + }, + }); + assert.equal( + (await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, + "Claude needs your permission to use Bash" + ); + + // The 60s idle nudge fires after Stop: it proves the CLI is parked at an + // idle prompt, so it must clear rather than pin the banner. + await request("POST", "/api/hooks/event", { + hook_type: "Notification", + data: { + session_id: "sess-nudge", + cwd: "/tmp/lane-idle-nudge", + message: "Claude is waiting for your input", + }, + }); + const lane = (await request("GET", `/api/lanes/${id}`)).body.lane; + assert.equal(lane.needs_action, null); + assert.equal(lane.status, "idle"); + + await request("DELETE", `/api/lanes/${id}`); + }); + + it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => { + const created = await request("POST", "/api/lanes", { + cwd: "/tmp/lane-turn-status", + title: "Turn Status", + }); + const id = created.body.lane.id; + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle"); + + await request("POST", "/api/hooks/event", { + hook_type: "UserPromptSubmit", + data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" }, + }); + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running"); + + // A subagent finishing is not the end of the turn. + await request("POST", "/api/hooks/event", { + hook_type: "SubagentStop", + data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" }, + }); + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running"); + + await request("POST", "/api/hooks/event", { + hook_type: "Stop", + data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" }, + }); + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle"); + + await request("DELETE", `/api/lanes/${id}`); + }); }); describe("hook → stage detection", () => { @@ -705,7 +776,10 @@ describe("GET /api/lanes/branches", () => { }); it("400s for a path that does not exist", async () => { - const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`); + const r = await request( + "GET", + `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}` + ); assert.equal(r.status, 400); assert.equal(r.body.error.code, "EBADSOURCEREPO"); }); diff --git a/server/routes/hooks.js b/server/routes/hooks.js index 169a542..2c1093e 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -136,14 +136,35 @@ function recoverInterruptedSession(sessionId, fullSess, mainAgentId, reasonSuffi }); } +// Claude Code fires Notification for two unrelated things: a real block +// (permission prompt / AskUserQuestion) and a bare idle nudge ~60 s after a +// turn ended. The nudge arrives AFTER Stop, so no later hook is coming to +// clear it — a lane stamped from it sat on a permanent "⚠ Claude is waiting +// for your input" while the user was simply not typing. The nudge is not a +// blocking event: it proves the opposite (the CLI is parked at an idle +// prompt), so it clears the lane instead of stamping it. +const IDLE_NUDGE_RE = /^\s*claude is waiting for your input[.!]?\s*$/i; + +// Hooks that prove the CLI is mid-turn / between turns for the lane's session. +// Only these move lane.status; anything else (SubagentStop, SessionStart, +// Notification) leaves it alone — a subagent finishing does not end the turn. +const LANE_WORKING_HOOKS = new Set(["UserPromptSubmit", "PreToolUse", "PostToolUse"]); +const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]); + /** * Attach an incoming hook to the lane that owns its cwd. Lanes are optional and * this is best-effort: the hook path must never fail because of lane * bookkeeping, so everything here is inside one try/catch. * - * `needs_action` mirrors Claude Code's Notification hook (a permission prompt or - * an idle nudge). The next non-Notification hook from the same session means the - * agent is moving again, so the flag clears itself — no user click required. + * `needs_action` mirrors Claude Code's Notification hook, minus the idle nudge + * (see IDLE_NUDGE_RE). The next non-Notification hook from the same session + * means the agent is moving again, so the flag clears itself — no user click + * required. + * + * `status` is mirrored the same way for lanes the dashboard did NOT launch + * (run_id null). Those never pass through the run lifecycle that sets + * running/idle, so without this an adopted lane read "idle" for the entire + * time Claude was working in it. */ function touchLaneFromHook(hookType, data) { try { @@ -183,14 +204,27 @@ function touchLaneFromHook(hookType, data) { } const patch = {}; + const idleNudge = hookType === "Notification" && IDLE_NUDGE_RE.test(data.message || ""); if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id; - if (hookType === "Notification") { + if (hookType === "Notification" && !idleNudge) { patch.needs_action = data.message || "needs you"; } else if (lane.needs_action && data.session_id === lane.session_id) { // Clear only when the hook comes from the session currently bound to the lane // (evaluated before any rebinding, so a rebinding hook never clears in the same pass) patch.needs_action = null; } + + // Only idle↔running is mirrored: provisioning and failed are lifecycle + // states this path must never stomp on. + if (!lane.run_id && (lane.status === "idle" || lane.status === "running")) { + const next = LANE_WORKING_HOOKS.has(hookType) + ? "running" + : LANE_DONE_HOOKS.has(hookType) || idleNudge + ? "idle" + : null; + if (next && next !== lane.status) patch.status = next; + } + if (!Object.keys(patch).length) return; lanesLib.updateLane(lane.id, patch); broadcastLane(lane.id); -- 2.52.0 From 4905d63b977bdada647ab93dd277530570b41704 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 31 Jul 2026 10:53:07 +0700 Subject: [PATCH 2/3] fix(lanes): clear stale detection on real stage transitions, exclude scratch dirs from implement detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setStage() left detected_stage/signal/at untouched across a real transition, so a prior task's leftover inference (e.g. `tests` from earlier work) both misrepresented a fresh task's progress and — because recordDetection is forward-only — silently rejected every real detection behind it until the old one aged past DETECTION_TTL_MS. A real stage change now clears the detection columns; a same-stage heartbeat leaves a live detection alone. Also excludes `.superpowers/` from the `implement` node's Write detect rule - brainstorm-companion scratch files were being counted as implementation work. fix(client): disable Node's default --experimental-webstorage in tests Node 25 enables --experimental-webstorage by default, which defines a broken global `localStorage` (no backing file configured) ahead of jsdom's own full polyfill. Every test touching real localStorage failed with "localStorage.clear/getItem is not a function" — not flaky, not test-specific, just this one Node default. NODE_OPTIONS=--no-experimental-webstorage on the test scripts lets jsdom's polyfill take over as before. --- client/package.json | 4 +-- server/__tests__/lanes-lib.test.js | 47 +++++++++++++++++++++++++++--- server/data/pipelines/default.json | 2 +- server/lib/lanes.js | 18 ++++++++++-- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/client/package.json b/client/package.json index 0ecb3ab..62adb1d 100644 --- a/client/package.json +++ b/client/package.json @@ -7,8 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest" + "test": "NODE_OPTIONS=--no-experimental-webstorage vitest run", + "test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest" }, "dependencies": { "@fontsource/inter": "^5.2.8", diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index dc8c75d..893b599 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -587,6 +587,39 @@ describe("stage detection", () => { lanes.deleteLane(l.id); }); + it("a real stage transition clears a stale detection and unblocks a fresh one behind it", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-cross-task" }); + // A prior, unrelated task's tool calls left `tests` as the detected stage. + lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" }); + assert.equal(lanes.getLane(l.id).detected_stage, "tests"); + + // A new task starts and the agent declares an EARLIER stage. Without the + // fix, the leftover `tests` detection both misrepresents the new task's + // progress and (via forward-only) rejects every real detection for it + // until `tests` ages past DETECTION_TTL_MS. + const after = lanes.setStage(l.id, { stage: "plan" }); + assert.equal(after.detected_stage, null); + assert.equal(after.detected_signal, null); + assert.equal(after.detected_at, null); + + assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), { + written: true, + }); + assert.equal(lanes.getLane(l.id).detected_stage, "implement"); + lanes.deleteLane(l.id); + }); + + it("re-declaring the SAME stage (a heartbeat) leaves a live detection alone", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-detect-heartbeat-noop" }); + lanes.setStage(l.id, { stage: "plan" }); + assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), { + written: true, + }); + lanes.setStage(l.id, { stage: "plan", note: "still planning" }); + assert.equal(lanes.getLane(l.id).detected_stage, "implement"); + lanes.deleteLane(l.id); + }); + it("migration: detection columns are added to a database holding an old-schema lanes row", () => { const tmpPath = pathMod.join( os.tmpdir(), @@ -744,12 +777,18 @@ describe("stage detection", () => { it("detection expiry: stale detection behind declared stage still rejects", () => { const { db } = require("../db"); const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" }); - // Stand up the detection BEFORE declaring, otherwise declared-wins refuses - // it and there is no standing detection left to age. - lanes.recordDetection(l.id, { nodeId: "tests" }); lanes.setStage(l.id, { stage: "review" }); + // setStage now clears detected_* on every real transition (a fresh + // declaration supersedes older inference), so a standing detection is + // written directly here, bypassing that, to isolate the rule this test + // is actually about: declared-wins is never relaxed by staleness, no + // matter how the stale detection got there. const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id); + db.prepare("UPDATE lanes SET detected_stage = ?, detected_at = ? WHERE id = ?").run( + "tests", + oldTime, + l.id + ); // Try backward detection to implement - should still be rejected because declared stage is ahead const result = lanes.recordDetection(l.id, { nodeId: "implement" }); diff --git a/server/data/pipelines/default.json b/server/data/pipelines/default.json index 27daa5c..a18226d 100644 --- a/server/data/pipelines/default.json +++ b/server/data/pipelines/default.json @@ -50,7 +50,7 @@ }, { "tool": "Write", - "match": "^(?!.*(?:^|/)docs/)" + "match": "^(?!.*(?:^|/)(?:docs|\\.superpowers)/)" } ] }, diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 91ac42f..d19abbe 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -189,24 +189,36 @@ function deleteLane(id) { * Record a stage transition. `stage_since` moves ONLY when the stage value * actually changes, so the UI's time-on-phase is real; a re-report of the same * stage (a heartbeat, an added note) leaves it alone. + * + * A real transition also clears `detected_stage`/`detected_signal`/`detected_at`. + * Inference tracks progress relative to whatever the agent last declared; once + * the agent declares again, any older detection is either stale (a prior task's + * leftover, e.g. `tests` from earlier work bleeding into a fresh `plan`) or + * redundant (the agent's own claim now covers it). Left in place it would both + * paint stale progress in the UI AND — because recordDetection is forward-only + * — silently reject every real detection for the new stage until the old one + * ages past DETECTION_TTL_MS. */ function setStage(id, { stage, status, evidence, note, result } = {}) { const lane = getLane(id); if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" }); const next = stage || lane.stage; + const changed = next !== lane.stage; const stages = { ...lane.stages }; const prev = stages[next] || {}; stages[next] = { - enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(), + enteredAt: !changed && prev.enteredAt ? prev.enteredAt : nowIso(), evidence: evidence !== undefined ? evidence : prev.evidence || null, result: result !== undefined ? result : prev.result || null, }; db.prepare( - `UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ? + `UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${ + changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : "" + } WHERE id = ?` ).run( next, - next === lane.stage ? lane.stage_since || nowIso() : nowIso(), + changed ? nowIso() : lane.stage_since || nowIso(), status || lane.status, JSON.stringify(stages), note !== undefined ? note : lane.notes, -- 2.52.0 From b6733633511dbe3079a26eaea49b0d0de2434398 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 31 Jul 2026 10:54:31 +0700 Subject: [PATCH 3/3] feat(theme): dark/light mode with a Radix Colors-based palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a working Dark/Light toggle (next to the language switcher, same row as EN/VI) and re-themes the whole dashboard, not just the handful of components that already used semantic tokens. - Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css, tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover, fg.primary/secondary/muted, status.success/danger/warning. One class flip on re-themes everything — no per-element dark: variant pairs. - useTheme() hook: localStorage-persisted, defaults to dark, no prefers-color-scheme fallback (client/src/hooks/useTheme.ts). - Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs, scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of every raw neutral/gray/slate + emerald/red/amber Tailwind utility across client/src onto the new tokens, so every badge/button/component pulls the same shade per status/role instead of each picking its own. - Palette values are the literal Radix Colors (radix-ui.com/colors) scale constants — slate/blue/green/red/amber steps 1-12 — adopted after three rounds of hand-picked values that kept overshooting (flat, then too dark, then glaring); see docs/superpowers/specs/2026-07-31-color-redesign- dark-light-mode-design.md for the full history and role mapping. - PipelineMap: done/current/failed/passed-no-evidence/detected share one visual language (border + text + translucent wash of the same status color); `current` alone stays a solid accent fill, the one state that gets to look bolder ("you are here"). - LaneCard: removed the stage/kind/auto-stage chips that duplicated the Workspace lane-detail header already showing them. Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink, orange, yellow, and blue where it plays a role-coloring part e.g. message bubbles) are deliberately out of scope — collapsing those onto shared tokens would erase the distinction between different kinds of thing, not a status. --- ARCHITECTURE.md | 2 +- client/README.md | 9 +- client/src/components/AgentCard.tsx | 10 +- client/src/components/AlertsNotifications.tsx | 66 +- client/src/components/Checkbox.tsx | 4 +- client/src/components/ConfirmModal.tsx | 12 +- client/src/components/DateTimePicker.tsx | 20 +- client/src/components/EmptyState.tsx | 6 +- client/src/components/EventDetail.tsx | 26 +- client/src/components/EventFilters.tsx | 16 +- client/src/components/EventFiltersInfo.tsx | 26 +- client/src/components/FieldHelp.tsx | 10 +- client/src/components/ImportHistory.tsx | 122 +- client/src/components/RemoteSources.tsx | 83 +- client/src/components/Select.tsx | 8 +- client/src/components/SessionCard.tsx | 10 +- client/src/components/SessionOverview.tsx | 87 +- client/src/components/Sidebar.tsx | 210 ++- client/src/components/StatCard.tsx | 6 +- client/src/components/StatusBadge.tsx | 2 +- client/src/components/Tabby/TabbyPanel.tsx | 36 +- client/src/components/Tip.tsx | 2 +- client/src/components/UpdateNotifier.tsx | 20 +- client/src/components/WebhookSettings.tsx | 78 +- .../components/__tests__/AgentCard.test.tsx | 2 +- .../components/__tests__/StatCard.test.tsx | 4 +- .../components/__tests__/StatusBadge.test.tsx | 4 +- .../src/components/conversation/CodeBlock.tsx | 22 +- .../conversation/ConversationView.tsx | 28 +- .../conversation/MarkdownContent.tsx | 44 +- .../components/conversation/MessageList.tsx | 84 +- .../components/conversation/ToolCallBlock.tsx | 36 +- .../src/components/event-views/primitives.tsx | 70 +- .../src/components/event-views/tool-views.tsx | 4 +- client/src/components/lanes/AddLaneModal.tsx | 18 +- .../components/lanes/DestructiveLaneModal.tsx | 26 +- client/src/components/lanes/LaneCard.tsx | 218 ++- client/src/components/lanes/LaneStripCard.tsx | 28 +- client/src/components/lanes/PipelineMap.tsx | 37 +- .../lanes/__tests__/LaneCard.test.tsx | 75 +- .../lanes/__tests__/PipelineMap.test.tsx | 11 +- client/src/components/run/RunConsole.tsx | 102 +- client/src/components/run/RunHistory.tsx | 54 +- client/src/components/run/RunSetup.tsx | 67 +- .../workflows/AgentCollaborationNetwork.tsx | 10 +- .../components/workflows/CompactionImpact.tsx | 20 +- .../workflows/ConcurrencyTimeline.tsx | 16 +- .../workflows/ErrorPropagationMap.tsx | 39 +- .../workflows/ModelDelegationFlow.tsx | 2 +- .../components/workflows/OrchestrationDAG.tsx | 18 +- .../workflows/SessionComplexityScatter.tsx | 14 +- .../components/workflows/SessionDrillIn.tsx | 92 +- .../workflows/SubagentEffectiveness.tsx | 18 +- .../workflows/ToolExecutionFlow.tsx | 4 +- .../components/workflows/WorkflowPatterns.tsx | 36 +- .../workflows/WorkflowRunsPanel.tsx | 94 +- .../components/workflows/WorkflowStats.tsx | 26 +- client/src/hooks/__tests__/useTheme.test.ts | 49 + client/src/hooks/useTheme.ts | 60 + client/src/i18n/locales/en/lanes.json | 9 +- client/src/i18n/locales/en/nav.json | 6 + client/src/i18n/locales/vi/lanes.json | 7 +- client/src/i18n/locales/vi/nav.json | 6 + client/src/index.css | 89 +- client/src/pages/ActivityFeed.tsx | 42 +- client/src/pages/Analytics.tsx | 204 +-- client/src/pages/CcConfig.tsx | 488 +++---- client/src/pages/Dashboard.tsx | 220 +-- client/src/pages/KanbanBoard.tsx | 24 +- client/src/pages/NotFound.tsx | 6 +- client/src/pages/SessionDetail.tsx | 116 +- client/src/pages/Sessions.tsx | 66 +- client/src/pages/Settings.tsx | 324 ++--- client/src/pages/Workflows.tsx | 56 +- client/src/pages/Workspace.tsx | 246 ++-- .../screens.snapshot.test.tsx.snap | 1208 ++++++++--------- client/tailwind.config.js | 39 +- ...26-07-31-color-redesign-dark-light-mode.md | 489 +++++++ ...1-color-redesign-dark-light-mode-design.md | 149 ++ scripts/darken-status-colors.mjs | 78 ++ scripts/migrate-color-tokens.mjs | 111 ++ scripts/tokenize-status-colors.mjs | 68 + 82 files changed, 3776 insertions(+), 2578 deletions(-) create mode 100644 client/src/hooks/__tests__/useTheme.test.ts create mode 100644 client/src/hooks/useTheme.ts create mode 100644 docs/superpowers/plans/2026-07-31-color-redesign-dark-light-mode.md create mode 100644 docs/superpowers/specs/2026-07-31-color-redesign-dark-light-mode-design.md create mode 100644 scripts/darken-status-colors.mjs create mode 100644 scripts/migrate-color-tokens.mjs create mode 100644 scripts/tokenize-status-colors.mjs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 205cf79..6f9f0ae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2896,7 +2896,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 | | **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 | -| **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 `` give clean shell composition | | **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 | diff --git a/client/README.md b/client/README.md index 6747623..1734df0 100644 --- a/client/README.md +++ b/client/README.md @@ -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 - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **Vite 6.1** - Lightning-fast HMR during development, optimized production builds -- **Tailwind CSS 3.4** - Utility-first CSS framework 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 - **WebSocket** - Real-time event streaming from server - **Lucide Icons** - Modern, consistent icon set @@ -203,17 +203,18 @@ client/ │ │ │ ├── hooks/ │ │ ├── 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) │ ├── App.tsx # Root component + router setup │ ├── 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) ├── index.html # HTML template ├── 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 └── package.json ``` diff --git a/client/src/components/AgentCard.tsx b/client/src/components/AgentCard.tsx index 19f9378..4c176ef 100644 --- a/client/src/components/AgentCard.tsx +++ b/client/src/components/AgentCard.tsx @@ -194,14 +194,14 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { {isMain ? : }
-

+

{/* Auto-generated main-agent titles (e.g. "Main Agent - Session 229d93fd" or "Main Agent - work - e3f8e613") swap the placeholder for the real session name when one exists; custom (sub)agent names are left untouched. */} {isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}

- {subtitle &&

{subtitle}

} + {subtitle &&

{subtitle}

}
{/* compact: cards are narrow — inline reason chip would squeeze the @@ -210,10 +210,10 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { {agent.task && ( -

{agent.task}

+

{agent.task}

)} -
+
{agent.current_tool && ( @@ -243,7 +243,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { {t("ran")} {formatDuration(agent.started_at, agent.ended_at)} - {timeAgo(agent.ended_at)} + {timeAgo(agent.ended_at)} ) : ( diff --git a/client/src/components/AlertsNotifications.tsx b/client/src/components/AlertsNotifications.tsx index f5b04a5..719b474 100644 --- a/client/src/components/AlertsNotifications.tsx +++ b/client/src/components/AlertsNotifications.tsx @@ -403,8 +403,8 @@ export function AlertsNotifications() { onClick={() => setTab(tb.key)} className={`inline-flex items-center gap-2 text-xs font-medium px-3.5 py-2 rounded-lg transition-colors ${ active - ? "bg-surface-4 text-gray-100 shadow-sm" - : "text-gray-500 hover:text-gray-300 hover:bg-surface-3" + ? "bg-surface-4 text-fg-primary shadow-sm" + : "text-fg-muted hover:text-fg-secondary hover:bg-surface-3" }`} > @@ -413,10 +413,10 @@ export function AlertsNotifications() { {tb.badge} @@ -432,8 +432,8 @@ export function AlertsNotifications() {
-

{t("rules.title")}

-

{ts("alertsHub.rulesHint")}

+

{t("rules.title")}

+

{ts("alertsHub.rulesHint")}

- {formError &&

{formError}

} + {formError &&

{formError}

}
)} @@ -680,7 +680,7 @@ export function AlertsNotifications() {
{rule.name} @@ -688,7 +688,7 @@ export function AlertsNotifications() { {t(`ruleTypes.${rule.rule_type}`)}
-

+

{describeRule(rule, t)} ·{" "} {t("rules.cooldown", { seconds: rule.cooldown_seconds })}

@@ -698,8 +698,8 @@ export function AlertsNotifications() { onClick={() => onToggleRule(rule)} className={`text-xs px-2.5 py-1.5 rounded-md border transition-colors ${ rule.enabled - ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10" - : "border-border text-gray-500 hover:text-gray-300 hover:bg-surface-3" + ? "border-status-success/30 text-status-success hover:bg-status-success/10" + : "border-border text-fg-muted hover:text-fg-secondary hover:bg-surface-3" }`} title={rule.enabled ? t("rules.disable") : t("rules.enable")} > @@ -707,7 +707,7 @@ export function AlertsNotifications() { @@ -222,11 +222,11 @@ export function DateTimePicker({ onClick={() => 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" > - + {viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })} @@ -244,7 +244,7 @@ export function DateTimePicker({
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => ( -
+
{day}
))} @@ -254,7 +254,7 @@ export function DateTimePicker({ {/* Time Picker */}
-
+
Time
@@ -262,7 +262,7 @@ export function DateTimePicker({ type="time" value={timeValue} 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" />
diff --git a/client/src/components/EmptyState.tsx b/client/src/components/EmptyState.tsx index 5b5aac6..063c3c2 100644 --- a/client/src/components/EmptyState.tsx +++ b/client/src/components/EmptyState.tsx @@ -91,10 +91,10 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat return (
- +
-

{title}

-

{description}

+

{title}

+

{description}

{action}
); diff --git a/client/src/components/EventDetail.tsx b/client/src/components/EventDetail.tsx index 4239ad5..a2ec15a 100644 --- a/client/src/components/EventDetail.tsx +++ b/client/src/components/EventDetail.tsx @@ -284,7 +284,7 @@ function SummaryBlock({ return (
- + {t("eventDetail.summary")}
@@ -293,19 +293,19 @@ function SummaryBlock({ - + {summary.headline}
{summary.bullets.length > 0 && ( -