claude-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/README.zh-TW.md +99 -0
  5. package/bin/cdb.ts +60 -0
  6. package/bun.lock +1612 -0
  7. package/bunfig.toml +4 -0
  8. package/components.json +20 -0
  9. package/next.config.ts +19 -0
  10. package/package.json +62 -0
  11. package/postcss.config.mjs +9 -0
  12. package/prompts/pm-system.md +61 -0
  13. package/prompts/rd-system.md +68 -0
  14. package/prompts/sec-system.md +93 -0
  15. package/prompts/test-system.md +71 -0
  16. package/prompts/ui-system.md +72 -0
  17. package/server.ts +118 -0
  18. package/sql.js.d.ts +33 -0
  19. package/src/__tests__/api/usage/route.test.ts +193 -0
  20. package/src/__tests__/components/layout/TopNav.test.tsx +155 -0
  21. package/src/__tests__/components/layout/UsageIndicator.test.tsx +503 -0
  22. package/src/__tests__/hooks/useUsage.test.tsx +174 -0
  23. package/src/__tests__/lib/usage/get-token.test.ts +117 -0
  24. package/src/__tests__/react-sanity.test.tsx +14 -0
  25. package/src/__tests__/sanity.test.ts +7 -0
  26. package/src/__tests__/setup.ts +1 -0
  27. package/src/app/api/health/route.ts +8 -0
  28. package/src/app/api/usage/route.ts +86 -0
  29. package/src/app/api/workflows/[id]/route.ts +17 -0
  30. package/src/app/api/workflows/route.ts +14 -0
  31. package/src/app/globals.css +74 -0
  32. package/src/app/history/page.tsx +15 -0
  33. package/src/app/layout.tsx +24 -0
  34. package/src/app/page.tsx +112 -0
  35. package/src/components/agent/AgentCard.tsx +117 -0
  36. package/src/components/agent/AgentCardGrid.tsx +14 -0
  37. package/src/components/agent/AgentOutput.tsx +87 -0
  38. package/src/components/agent/AgentStatusBadge.tsx +20 -0
  39. package/src/components/events/EventLog.tsx +65 -0
  40. package/src/components/events/EventLogItem.tsx +39 -0
  41. package/src/components/history/HistoryTable.tsx +105 -0
  42. package/src/components/layout/DashboardShell.tsx +12 -0
  43. package/src/components/layout/TopNav.tsx +86 -0
  44. package/src/components/layout/UsageIndicator.tsx +163 -0
  45. package/src/components/pipeline/PipelineBar.tsx +59 -0
  46. package/src/components/pipeline/PipelineNode.tsx +55 -0
  47. package/src/components/terminal/TerminalPanel.tsx +138 -0
  48. package/src/components/terminal/XTermRenderer.tsx +129 -0
  49. package/src/components/ui/badge.tsx +37 -0
  50. package/src/components/ui/button.tsx +55 -0
  51. package/src/components/ui/card.tsx +80 -0
  52. package/src/components/ui/input.tsx +26 -0
  53. package/src/components/ui/scroll-area.tsx +52 -0
  54. package/src/components/ui/separator.tsx +31 -0
  55. package/src/components/ui/textarea.tsx +25 -0
  56. package/src/components/ui/tooltip.tsx +73 -0
  57. package/src/components/workflow/WorkflowLauncher.tsx +102 -0
  58. package/src/hooks/useAgentStream.ts +27 -0
  59. package/src/hooks/useAutoScroll.ts +24 -0
  60. package/src/hooks/useUsage.ts +66 -0
  61. package/src/hooks/useWebSocket.ts +289 -0
  62. package/src/lib/agents/prompts.ts +341 -0
  63. package/src/lib/db/connection.ts +263 -0
  64. package/src/lib/db/queries.ts +257 -0
  65. package/src/lib/db/schema.ts +39 -0
  66. package/src/lib/output-buffer.ts +41 -0
  67. package/src/lib/terminal/pty-manager.ts +106 -0
  68. package/src/lib/usage/get-token.ts +48 -0
  69. package/src/lib/utils.ts +6 -0
  70. package/src/lib/websocket/connection-manager.ts +71 -0
  71. package/src/lib/websocket/protocol.ts +90 -0
  72. package/src/lib/websocket/server.ts +231 -0
  73. package/src/lib/workflow/agent-runner.ts +254 -0
  74. package/src/lib/workflow/context-builder.ts +62 -0
  75. package/src/lib/workflow/engine.ts +310 -0
  76. package/src/lib/workflow/pipeline.ts +28 -0
  77. package/src/lib/workflow/types.ts +111 -0
  78. package/src/stores/agentStore.ts +152 -0
  79. package/src/stores/eventStore.ts +35 -0
  80. package/src/stores/terminalStore.ts +20 -0
  81. package/src/stores/uiStore.ts +35 -0
  82. package/src/stores/workflowStore.ts +57 -0
  83. package/src/types/css.d.ts +4 -0
  84. package/src/types/index.ts +12 -0
  85. package/tailwind.config.ts +65 -0
  86. package/tsconfig.json +25 -0
  87. package/tsconfig.server.json +21 -0
  88. package/vitest.config.ts +25 -0
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { execSync } from "child_process";
3
+
4
+ // We need to test getClaudeOAuthToken which calls execSync internally.
5
+ // We'll mock child_process.execSync.
6
+
7
+ // Import the module under test
8
+ // Since we can't easily mock execSync at the module level with bun,
9
+ // we'll test the function's logic by calling it and handling outcomes.
10
+
11
+ describe("getClaudeOAuthToken", () => {
12
+ // We'll dynamically import and test the function behavior
13
+
14
+ it("should export getClaudeOAuthToken function", async () => {
15
+ const mod = await import("@/lib/usage/get-token");
16
+ expect(typeof mod.getClaudeOAuthToken).toBe("function");
17
+ });
18
+
19
+ it("should return a string token when Keychain has valid credentials", async () => {
20
+ // This test verifies the real Keychain access on macOS.
21
+ // It will pass in the CI/dev environment where credentials exist.
22
+ // If no credentials exist, the function should throw.
23
+ const { getClaudeOAuthToken } = await import("@/lib/usage/get-token");
24
+
25
+ try {
26
+ const token = getClaudeOAuthToken();
27
+ // If we get here, a token was found
28
+ expect(typeof token).toBe("string");
29
+ expect(token.length).toBeGreaterThan(0);
30
+ } catch (error) {
31
+ // If Keychain access fails, that's expected in some environments
32
+ expect(error).toBeInstanceOf(Error);
33
+ expect((error as Error).message).toContain("Failed to read Claude Code token from Keychain");
34
+ }
35
+ });
36
+
37
+ it("should throw an error with descriptive message when Keychain access fails", async () => {
38
+ // We test the error wrapping behavior by checking the error message format
39
+ const { getClaudeOAuthToken } = await import("@/lib/usage/get-token");
40
+
41
+ // We can't easily force a failure without mocking, but we can verify
42
+ // the function exists and has the right signature
43
+ expect(getClaudeOAuthToken).toBeDefined();
44
+ expect(getClaudeOAuthToken.length).toBe(0); // no params
45
+ });
46
+ });
47
+
48
+ describe("getClaudeOAuthToken - credential parsing logic", () => {
49
+ // Test the JSON parsing and token extraction logic in isolation
50
+ // by simulating what the function does internally.
51
+
52
+ it("should correctly parse valid credential JSON", () => {
53
+ const validCredentials = JSON.stringify({
54
+ claudeAiOauth: {
55
+ accessToken: "test-token-123",
56
+ refreshToken: "refresh-token-456",
57
+ },
58
+ });
59
+
60
+ const parsed = JSON.parse(validCredentials);
61
+ const token = parsed?.claudeAiOauth?.accessToken;
62
+
63
+ expect(token).toBe("test-token-123");
64
+ });
65
+
66
+ it("should handle missing accessToken field", () => {
67
+ const invalidCredentials = JSON.stringify({
68
+ claudeAiOauth: {
69
+ refreshToken: "refresh-token-456",
70
+ },
71
+ });
72
+
73
+ const parsed = JSON.parse(invalidCredentials);
74
+ const token = parsed?.claudeAiOauth?.accessToken;
75
+
76
+ expect(token).toBeUndefined();
77
+ });
78
+
79
+ it("should handle missing claudeAiOauth field", () => {
80
+ const invalidCredentials = JSON.stringify({
81
+ someOtherField: "value",
82
+ });
83
+
84
+ const parsed = JSON.parse(invalidCredentials);
85
+ const token = parsed?.claudeAiOauth?.accessToken;
86
+
87
+ expect(token).toBeUndefined();
88
+ });
89
+
90
+ it("should handle empty JSON object", () => {
91
+ const emptyCredentials = JSON.stringify({});
92
+
93
+ const parsed = JSON.parse(emptyCredentials);
94
+ const token = parsed?.claudeAiOauth?.accessToken;
95
+
96
+ expect(token).toBeUndefined();
97
+ });
98
+
99
+ it("should throw on invalid JSON string", () => {
100
+ expect(() => JSON.parse("not valid json")).toThrow();
101
+ });
102
+
103
+ it("should handle empty accessToken string", () => {
104
+ const credentials = JSON.stringify({
105
+ claudeAiOauth: {
106
+ accessToken: "",
107
+ refreshToken: "refresh-token",
108
+ },
109
+ });
110
+
111
+ const parsed = JSON.parse(credentials);
112
+ const token = parsed?.claudeAiOauth?.accessToken;
113
+
114
+ // Empty string is falsy - the actual function checks `if (!token)`
115
+ expect(!token).toBe(true);
116
+ });
117
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { render, screen } from "@testing-library/react";
3
+ import React from "react";
4
+
5
+ function TestComponent({ text }: { text: string }) {
6
+ return <div data-testid="test">{text}</div>;
7
+ }
8
+
9
+ describe("React Testing Library sanity check", () => {
10
+ it("should render a component", () => {
11
+ render(<TestComponent text="hello" />);
12
+ expect(screen.getByTestId("test").textContent).toBe("hello");
13
+ });
14
+ });
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ describe("sanity check", () => {
4
+ it("should pass", () => {
5
+ expect(1 + 1).toBe(2);
6
+ });
7
+ });
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom/vitest";
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ status: "ok",
6
+ timestamp: new Date().toISOString(),
7
+ });
8
+ }
@@ -0,0 +1,86 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getClaudeOAuthToken } from "@/lib/usage/get-token";
3
+
4
+ const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
5
+ const ANTHROPIC_BETA_HEADER = "oauth-2025-04-20";
6
+
7
+ /** Shape of a single usage bucket from the Anthropic API */
8
+ interface UsageBucket {
9
+ utilization: number;
10
+ resets_at: string | null;
11
+ }
12
+
13
+ /** Full response from Anthropic usage API */
14
+ interface AnthropicUsageResponse {
15
+ five_hour: UsageBucket | null;
16
+ seven_day: UsageBucket | null;
17
+ seven_day_sonnet: UsageBucket | null;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ /** Our simplified response to the frontend */
22
+ export interface UsageApiResponse {
23
+ five_hour: UsageBucket | null;
24
+ seven_day: UsageBucket | null;
25
+ seven_day_sonnet: UsageBucket | null;
26
+ error?: string;
27
+ }
28
+
29
+ export async function GET() {
30
+ try {
31
+ // 1. Read token from macOS Keychain
32
+ const token = getClaudeOAuthToken();
33
+
34
+ // 2. Call Anthropic usage API
35
+ const response = await fetch(ANTHROPIC_USAGE_URL, {
36
+ method: "GET",
37
+ headers: {
38
+ Authorization: `Bearer ${token}`,
39
+ "anthropic-beta": ANTHROPIC_BETA_HEADER,
40
+ },
41
+ // Don't cache — always fetch fresh data
42
+ cache: "no-store",
43
+ });
44
+
45
+ if (!response.ok) {
46
+ const errorText = await response.text().catch(() => "Unknown error");
47
+ console.error(
48
+ `[Usage API] Anthropic API returned ${response.status}: ${errorText}`
49
+ );
50
+ return NextResponse.json(
51
+ {
52
+ five_hour: null,
53
+ seven_day: null,
54
+ seven_day_sonnet: null,
55
+ error: `Anthropic API error: ${response.status}`,
56
+ } satisfies UsageApiResponse,
57
+ { status: 502 }
58
+ );
59
+ }
60
+
61
+ const data: AnthropicUsageResponse = await response.json();
62
+
63
+ // 3. Return only the three buckets the frontend needs
64
+ const result: UsageApiResponse = {
65
+ five_hour: data.five_hour ?? null,
66
+ seven_day: data.seven_day ?? null,
67
+ seven_day_sonnet: data.seven_day_sonnet ?? null,
68
+ };
69
+
70
+ return NextResponse.json(result);
71
+ } catch (error) {
72
+ const message =
73
+ error instanceof Error ? error.message : "Unknown error";
74
+ console.error(`[Usage API] Error: ${message}`);
75
+
76
+ return NextResponse.json(
77
+ {
78
+ five_hour: null,
79
+ seven_day: null,
80
+ seven_day_sonnet: null,
81
+ error: message,
82
+ } satisfies UsageApiResponse,
83
+ { status: 500 }
84
+ );
85
+ }
86
+ }
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getWorkflow, getStepsForWorkflow } from "@/lib/db/queries";
3
+
4
+ export async function GET(
5
+ request: NextRequest,
6
+ { params }: { params: Promise<{ id: string }> }
7
+ ) {
8
+ const { id } = await params;
9
+ const workflow = getWorkflow(id);
10
+
11
+ if (!workflow) {
12
+ return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
13
+ }
14
+
15
+ const steps = getStepsForWorkflow(id);
16
+ return NextResponse.json({ workflow, steps });
17
+ }
@@ -0,0 +1,14 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { listWorkflows } from "@/lib/db/queries";
3
+
4
+ export async function GET(request: NextRequest) {
5
+ const searchParams = request.nextUrl.searchParams;
6
+ const rawLimit = parseInt(searchParams.get("limit") || "50", 10);
7
+ const rawOffset = parseInt(searchParams.get("offset") || "0", 10);
8
+
9
+ const limit = Number.isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 200);
10
+ const offset = Number.isNaN(rawOffset) || rawOffset < 0 ? 0 : rawOffset;
11
+
12
+ const workflows = listWorkflows(limit, offset);
13
+ return NextResponse.json({ workflows });
14
+ }
@@ -0,0 +1,74 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 228 14% 8%;
8
+ --foreground: 220 10% 90%;
9
+ --card: 225 14% 11%;
10
+ --card-foreground: 220 10% 90%;
11
+ --popover: 225 14% 11%;
12
+ --popover-foreground: 220 10% 90%;
13
+ --primary: 262 83% 58%;
14
+ --primary-foreground: 0 0% 100%;
15
+ --secondary: 225 14% 15%;
16
+ --secondary-foreground: 220 10% 90%;
17
+ --muted: 225 14% 15%;
18
+ --muted-foreground: 220 10% 55%;
19
+ --accent: 225 14% 18%;
20
+ --accent-foreground: 220 10% 90%;
21
+ --destructive: 0 84% 60%;
22
+ --destructive-foreground: 0 0% 100%;
23
+ --border: 225 14% 18%;
24
+ --input: 225 14% 18%;
25
+ --ring: 262 83% 58%;
26
+ --radius: 0.5rem;
27
+ }
28
+ }
29
+
30
+ @layer base {
31
+ * {
32
+ @apply border-border;
33
+ }
34
+ body {
35
+ @apply bg-background text-foreground;
36
+ }
37
+ }
38
+
39
+ /* Scrollbar styling */
40
+ ::-webkit-scrollbar {
41
+ width: 6px;
42
+ height: 6px;
43
+ }
44
+
45
+ ::-webkit-scrollbar-track {
46
+ background: hsl(225 14% 8%);
47
+ }
48
+
49
+ ::-webkit-scrollbar-thumb {
50
+ background: hsl(225 14% 22%);
51
+ border-radius: 3px;
52
+ }
53
+
54
+ ::-webkit-scrollbar-thumb:hover {
55
+ background: hsl(225 14% 30%);
56
+ }
57
+
58
+ /* Agent card markdown prose overrides */
59
+ .agent-output .prose {
60
+ @apply text-sm leading-relaxed;
61
+ }
62
+
63
+ .agent-output .prose pre {
64
+ @apply bg-black/40 rounded-md text-xs;
65
+ }
66
+
67
+ .agent-output .prose code {
68
+ @apply text-xs;
69
+ }
70
+
71
+ /* Terminal container */
72
+ .terminal-container .xterm {
73
+ padding: 8px;
74
+ }
@@ -0,0 +1,15 @@
1
+ "use client";
2
+
3
+ import { DashboardShell } from "@/components/layout/DashboardShell";
4
+ import { HistoryTable } from "@/components/history/HistoryTable";
5
+
6
+ export default function HistoryPage() {
7
+ return (
8
+ <DashboardShell>
9
+ <div className="p-4">
10
+ <h2 className="text-lg font-semibold mb-4">Workflow History</h2>
11
+ <HistoryTable />
12
+ </div>
13
+ </DashboardShell>
14
+ );
15
+ }
@@ -0,0 +1,24 @@
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({ subsets: ["latin"] });
6
+
7
+ export const metadata: Metadata = {
8
+ title: "Claude Dashboard",
9
+ description: "Multi-Agent Workflow Dashboard",
10
+ };
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: {
15
+ children: React.ReactNode;
16
+ }) {
17
+ return (
18
+ <html lang="en" className="dark">
19
+ <body className={`${inter.className} min-h-screen bg-background antialiased`}>
20
+ {children}
21
+ </body>
22
+ </html>
23
+ );
24
+ }
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef } from "react";
4
+ import { DashboardShell } from "@/components/layout/DashboardShell";
5
+ import { PipelineBar } from "@/components/pipeline/PipelineBar";
6
+ import { AgentCardGrid } from "@/components/agent/AgentCardGrid";
7
+ import { EventLog } from "@/components/events/EventLog";
8
+ import { TerminalPanel } from "@/components/terminal/TerminalPanel";
9
+ import { WorkflowLauncher } from "@/components/workflow/WorkflowLauncher";
10
+ import { useWebSocket } from "@/hooks/useWebSocket";
11
+ import { useWorkflowStore } from "@/stores/workflowStore";
12
+ import { useUiStore } from "@/stores/uiStore";
13
+
14
+ export default function DashboardPage() {
15
+ const { send } = useWebSocket();
16
+ const workflowId = useWorkflowStore((s) => s.workflowId);
17
+ const { bottomPanelHeight, terminalVisible, eventLogVisible } = useUiStore();
18
+ const resizing = useRef(false);
19
+ const startY = useRef(0);
20
+ const startHeight = useRef(0);
21
+
22
+ const handleStart = useCallback(
23
+ (prompt: string) => {
24
+ send({ type: "workflow:start", payload: { prompt, projectPath: "" } });
25
+ },
26
+ [send]
27
+ );
28
+
29
+ const handlePause = useCallback(() => {
30
+ if (workflowId) {
31
+ send({ type: "workflow:pause", payload: { workflowId } });
32
+ }
33
+ }, [send, workflowId]);
34
+
35
+ const handleResume = useCallback(() => {
36
+ if (workflowId) {
37
+ send({ type: "workflow:resume", payload: { workflowId } });
38
+ }
39
+ }, [send, workflowId]);
40
+
41
+ const handleCancel = useCallback(() => {
42
+ if (workflowId) {
43
+ send({ type: "workflow:cancel", payload: { workflowId } });
44
+ }
45
+ }, [send, workflowId]);
46
+
47
+ // Resizable handle for bottom panel
48
+ const handleMouseDown = useCallback(
49
+ (e: React.MouseEvent) => {
50
+ resizing.current = true;
51
+ startY.current = e.clientY;
52
+ startHeight.current = bottomPanelHeight;
53
+
54
+ const handleMouseMove = (e: MouseEvent) => {
55
+ if (!resizing.current) return;
56
+ const delta = startY.current - e.clientY;
57
+ const newHeight = Math.max(100, Math.min(600, startHeight.current + delta));
58
+ useUiStore.getState().setBottomPanelHeight(newHeight);
59
+ };
60
+
61
+ const handleMouseUp = () => {
62
+ resizing.current = false;
63
+ document.removeEventListener("mousemove", handleMouseMove);
64
+ document.removeEventListener("mouseup", handleMouseUp);
65
+ };
66
+
67
+ document.addEventListener("mousemove", handleMouseMove);
68
+ document.addEventListener("mouseup", handleMouseUp);
69
+ },
70
+ [bottomPanelHeight]
71
+ );
72
+
73
+ return (
74
+ <DashboardShell>
75
+ <WorkflowLauncher
76
+ onStart={handleStart}
77
+ onPause={handlePause}
78
+ onResume={handleResume}
79
+ onCancel={handleCancel}
80
+ />
81
+ <PipelineBar />
82
+
83
+ {/* Agent Cards */}
84
+ <div className="flex-1 min-h-0 overflow-hidden">
85
+ <AgentCardGrid />
86
+ </div>
87
+
88
+ {/* Resizable handle */}
89
+ <div
90
+ onMouseDown={handleMouseDown}
91
+ className="h-1.5 bg-border hover:bg-primary/50 cursor-row-resize flex-shrink-0 transition-colors"
92
+ />
93
+
94
+ {/* Bottom panels */}
95
+ <div
96
+ className="flex-shrink-0 flex overflow-hidden"
97
+ style={{ height: bottomPanelHeight }}
98
+ >
99
+ {eventLogVisible && (
100
+ <div className={`${terminalVisible ? "w-1/2 border-r border-border" : "w-full"} overflow-hidden`}>
101
+ <EventLog />
102
+ </div>
103
+ )}
104
+ {terminalVisible && (
105
+ <div className={`${eventLogVisible ? "w-1/2" : "w-full"} overflow-hidden`}>
106
+ <TerminalPanel send={send} />
107
+ </div>
108
+ )}
109
+ </div>
110
+ </DashboardShell>
111
+ );
112
+ }
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useAgentStream } from "@/hooks/useAgentStream";
5
+ import type { AgentRole } from "@/lib/workflow/types";
6
+ import { AGENT_CONFIG } from "@/lib/workflow/types";
7
+ import { AgentStatusBadge } from "./AgentStatusBadge";
8
+ import { AgentOutput } from "./AgentOutput";
9
+
10
+ interface AgentCardProps {
11
+ role: AgentRole;
12
+ }
13
+
14
+ function formatDuration(ms: number | null): string {
15
+ if (ms == null) return "";
16
+ const s = Math.floor(ms / 1000);
17
+ if (s < 60) return `${s}s`;
18
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
19
+ }
20
+
21
+ function LiveTimer({ startedAt }: { startedAt: number | null }) {
22
+ const [now, setNow] = useState(Date.now());
23
+
24
+ useEffect(() => {
25
+ if (!startedAt) return;
26
+ const timer = setInterval(() => setNow(Date.now()), 1000);
27
+ return () => clearInterval(timer);
28
+ }, [startedAt]);
29
+
30
+ if (!startedAt) return null;
31
+
32
+ const elapsed = Math.floor((now - startedAt) / 1000);
33
+ const display =
34
+ elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`;
35
+
36
+ return (
37
+ <span className="text-[10px] text-muted-foreground tabular-nums">{display}</span>
38
+ );
39
+ }
40
+
41
+ export function AgentCard({ role }: AgentCardProps) {
42
+ const config = AGENT_CONFIG[role];
43
+ const {
44
+ status,
45
+ output,
46
+ error,
47
+ durationMs,
48
+ tokensIn,
49
+ tokensOut,
50
+ activity,
51
+ startedAt,
52
+ retryCount,
53
+ } = useAgentStream(role);
54
+
55
+ return (
56
+ <div
57
+ className="flex flex-col h-full rounded-lg border bg-[#1F2229] overflow-hidden"
58
+ style={{
59
+ borderColor:
60
+ status === "running"
61
+ ? config.color
62
+ : status === "completed"
63
+ ? `${config.color}40`
64
+ : undefined,
65
+ }}
66
+ >
67
+ {/* Header */}
68
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
69
+ <div className="flex items-center gap-2">
70
+ <div
71
+ className="w-2 h-2 rounded-full"
72
+ style={{ backgroundColor: config.color }}
73
+ />
74
+ <span className="text-xs font-semibold">{config.label}</span>
75
+ {retryCount > 0 && (
76
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 font-medium">
77
+ retry {retryCount}
78
+ </span>
79
+ )}
80
+ </div>
81
+ <div className="flex items-center gap-2">
82
+ {status === "running" ? (
83
+ <LiveTimer startedAt={startedAt} />
84
+ ) : (
85
+ durationMs != null && (
86
+ <span className="text-[10px] text-muted-foreground">
87
+ {formatDuration(durationMs)}
88
+ </span>
89
+ )
90
+ )}
91
+ <AgentStatusBadge status={status} />
92
+ </div>
93
+ </div>
94
+
95
+ {/* Output area */}
96
+ <div className="flex-1 overflow-hidden min-h-0">
97
+ {error ? (
98
+ <div className="p-2 text-xs text-red-400">{error}</div>
99
+ ) : (
100
+ <AgentOutput
101
+ output={output}
102
+ isStreaming={status === "running"}
103
+ activity={activity}
104
+ />
105
+ )}
106
+ </div>
107
+
108
+ {/* Footer stats */}
109
+ {(tokensIn != null || tokensOut != null) && (
110
+ <div className="px-3 py-1 border-t border-border flex gap-3 text-[10px] text-muted-foreground">
111
+ {tokensIn != null && <span>In: {tokensIn.toLocaleString()}</span>}
112
+ {tokensOut != null && <span>Out: {tokensOut.toLocaleString()}</span>}
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { AGENT_ORDER } from "@/lib/workflow/types";
4
+ import { AgentCard } from "./AgentCard";
5
+
6
+ export function AgentCardGrid() {
7
+ return (
8
+ <div className="grid grid-cols-5 gap-2 p-2 h-full min-h-0">
9
+ {AGENT_ORDER.map((role) => (
10
+ <AgentCard key={role} role={role} />
11
+ ))}
12
+ </div>
13
+ );
14
+ }