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.
- package/.claude/settings.local.json +10 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/README.zh-TW.md +99 -0
- package/bin/cdb.ts +60 -0
- package/bun.lock +1612 -0
- package/bunfig.toml +4 -0
- package/components.json +20 -0
- package/next.config.ts +19 -0
- package/package.json +62 -0
- package/postcss.config.mjs +9 -0
- package/prompts/pm-system.md +61 -0
- package/prompts/rd-system.md +68 -0
- package/prompts/sec-system.md +93 -0
- package/prompts/test-system.md +71 -0
- package/prompts/ui-system.md +72 -0
- package/server.ts +118 -0
- package/sql.js.d.ts +33 -0
- package/src/__tests__/api/usage/route.test.ts +193 -0
- package/src/__tests__/components/layout/TopNav.test.tsx +155 -0
- package/src/__tests__/components/layout/UsageIndicator.test.tsx +503 -0
- package/src/__tests__/hooks/useUsage.test.tsx +174 -0
- package/src/__tests__/lib/usage/get-token.test.ts +117 -0
- package/src/__tests__/react-sanity.test.tsx +14 -0
- package/src/__tests__/sanity.test.ts +7 -0
- package/src/__tests__/setup.ts +1 -0
- package/src/app/api/health/route.ts +8 -0
- package/src/app/api/usage/route.ts +86 -0
- package/src/app/api/workflows/[id]/route.ts +17 -0
- package/src/app/api/workflows/route.ts +14 -0
- package/src/app/globals.css +74 -0
- package/src/app/history/page.tsx +15 -0
- package/src/app/layout.tsx +24 -0
- package/src/app/page.tsx +112 -0
- package/src/components/agent/AgentCard.tsx +117 -0
- package/src/components/agent/AgentCardGrid.tsx +14 -0
- package/src/components/agent/AgentOutput.tsx +87 -0
- package/src/components/agent/AgentStatusBadge.tsx +20 -0
- package/src/components/events/EventLog.tsx +65 -0
- package/src/components/events/EventLogItem.tsx +39 -0
- package/src/components/history/HistoryTable.tsx +105 -0
- package/src/components/layout/DashboardShell.tsx +12 -0
- package/src/components/layout/TopNav.tsx +86 -0
- package/src/components/layout/UsageIndicator.tsx +163 -0
- package/src/components/pipeline/PipelineBar.tsx +59 -0
- package/src/components/pipeline/PipelineNode.tsx +55 -0
- package/src/components/terminal/TerminalPanel.tsx +138 -0
- package/src/components/terminal/XTermRenderer.tsx +129 -0
- package/src/components/ui/badge.tsx +37 -0
- package/src/components/ui/button.tsx +55 -0
- package/src/components/ui/card.tsx +80 -0
- package/src/components/ui/input.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +52 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/textarea.tsx +25 -0
- package/src/components/ui/tooltip.tsx +73 -0
- package/src/components/workflow/WorkflowLauncher.tsx +102 -0
- package/src/hooks/useAgentStream.ts +27 -0
- package/src/hooks/useAutoScroll.ts +24 -0
- package/src/hooks/useUsage.ts +66 -0
- package/src/hooks/useWebSocket.ts +289 -0
- package/src/lib/agents/prompts.ts +341 -0
- package/src/lib/db/connection.ts +263 -0
- package/src/lib/db/queries.ts +257 -0
- package/src/lib/db/schema.ts +39 -0
- package/src/lib/output-buffer.ts +41 -0
- package/src/lib/terminal/pty-manager.ts +106 -0
- package/src/lib/usage/get-token.ts +48 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/websocket/connection-manager.ts +71 -0
- package/src/lib/websocket/protocol.ts +90 -0
- package/src/lib/websocket/server.ts +231 -0
- package/src/lib/workflow/agent-runner.ts +254 -0
- package/src/lib/workflow/context-builder.ts +62 -0
- package/src/lib/workflow/engine.ts +310 -0
- package/src/lib/workflow/pipeline.ts +28 -0
- package/src/lib/workflow/types.ts +111 -0
- package/src/stores/agentStore.ts +152 -0
- package/src/stores/eventStore.ts +35 -0
- package/src/stores/terminalStore.ts +20 -0
- package/src/stores/uiStore.ts +35 -0
- package/src/stores/workflowStore.ts +57 -0
- package/src/types/css.d.ts +4 -0
- package/src/types/index.ts +12 -0
- package/tailwind.config.ts +65 -0
- package/tsconfig.json +25 -0
- package/tsconfig.server.json +21 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> {
|
|
7
|
+
content: React.ReactNode;
|
|
8
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
9
|
+
delayMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
|
|
13
|
+
({ className, content, side = "top", delayMs = 200, children, ...props }, ref) => {
|
|
14
|
+
const [visible, setVisible] = React.useState(false);
|
|
15
|
+
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
16
|
+
|
|
17
|
+
const showTooltip = React.useCallback(() => {
|
|
18
|
+
timeoutRef.current = setTimeout(() => setVisible(true), delayMs);
|
|
19
|
+
}, [delayMs]);
|
|
20
|
+
|
|
21
|
+
const hideTooltip = React.useCallback(() => {
|
|
22
|
+
if (timeoutRef.current) {
|
|
23
|
+
clearTimeout(timeoutRef.current);
|
|
24
|
+
timeoutRef.current = null;
|
|
25
|
+
}
|
|
26
|
+
setVisible(false);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
return () => {
|
|
31
|
+
if (timeoutRef.current) {
|
|
32
|
+
clearTimeout(timeoutRef.current);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const positionClasses = {
|
|
38
|
+
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
|
39
|
+
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
|
40
|
+
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
|
41
|
+
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={ref}
|
|
47
|
+
className={cn("relative inline-flex", className)}
|
|
48
|
+
onMouseEnter={showTooltip}
|
|
49
|
+
onMouseLeave={hideTooltip}
|
|
50
|
+
onFocus={showTooltip}
|
|
51
|
+
onBlur={hideTooltip}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
{visible && content && (
|
|
56
|
+
<div
|
|
57
|
+
role="tooltip"
|
|
58
|
+
className={cn(
|
|
59
|
+
"absolute z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
|
60
|
+
"pointer-events-none whitespace-nowrap",
|
|
61
|
+
positionClasses[side]
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{content}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
Tooltip.displayName = "Tooltip";
|
|
72
|
+
|
|
73
|
+
export { Tooltip };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, type FormEvent } from "react";
|
|
4
|
+
import { useWorkflowStore } from "@/stores/workflowStore";
|
|
5
|
+
import { useAgentStore } from "@/stores/agentStore";
|
|
6
|
+
import { useEventStore } from "@/stores/eventStore";
|
|
7
|
+
|
|
8
|
+
interface WorkflowLauncherProps {
|
|
9
|
+
onStart: (prompt: string) => void;
|
|
10
|
+
onPause: () => void;
|
|
11
|
+
onResume: () => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function WorkflowLauncher({
|
|
16
|
+
onStart,
|
|
17
|
+
onPause,
|
|
18
|
+
onResume,
|
|
19
|
+
onCancel,
|
|
20
|
+
}: WorkflowLauncherProps) {
|
|
21
|
+
const [prompt, setPrompt] = useState("");
|
|
22
|
+
const { status, workflowId } = useWorkflowStore();
|
|
23
|
+
const resetAgents = useAgentStore((s) => s.resetAll);
|
|
24
|
+
const clearEvents = useEventStore((s) => s.clear);
|
|
25
|
+
const resetWorkflow = useWorkflowStore((s) => s.reset);
|
|
26
|
+
|
|
27
|
+
const handleSubmit = useCallback(
|
|
28
|
+
(e: FormEvent) => {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
if (!prompt.trim()) return;
|
|
31
|
+
resetWorkflow();
|
|
32
|
+
resetAgents();
|
|
33
|
+
clearEvents();
|
|
34
|
+
onStart(prompt.trim());
|
|
35
|
+
},
|
|
36
|
+
[prompt, onStart, resetWorkflow, resetAgents, clearEvents]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const isIdle = status === "pending" || status === "completed" || status === "failed" || status === "cancelled";
|
|
40
|
+
const isRunning = status === "running";
|
|
41
|
+
const isPaused = status === "paused";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="border-b border-border bg-card/50 px-4 py-3">
|
|
45
|
+
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
46
|
+
<input
|
|
47
|
+
type="text"
|
|
48
|
+
value={prompt}
|
|
49
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
50
|
+
placeholder="Describe your development task..."
|
|
51
|
+
disabled={!isIdle}
|
|
52
|
+
className="flex-1 h-9 rounded-md border border-border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
|
53
|
+
/>
|
|
54
|
+
{isIdle && (
|
|
55
|
+
<button
|
|
56
|
+
type="submit"
|
|
57
|
+
disabled={!prompt.trim()}
|
|
58
|
+
className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
59
|
+
>
|
|
60
|
+
Start
|
|
61
|
+
</button>
|
|
62
|
+
)}
|
|
63
|
+
{isRunning && (
|
|
64
|
+
<>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onPause}
|
|
68
|
+
className="h-9 px-4 rounded-md bg-yellow-600 text-white text-sm font-medium hover:bg-yellow-700"
|
|
69
|
+
>
|
|
70
|
+
Pause
|
|
71
|
+
</button>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={onCancel}
|
|
75
|
+
className="h-9 px-4 rounded-md bg-red-600 text-white text-sm font-medium hover:bg-red-700"
|
|
76
|
+
>
|
|
77
|
+
Cancel
|
|
78
|
+
</button>
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
{isPaused && (
|
|
82
|
+
<>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={onResume}
|
|
86
|
+
className="h-9 px-4 rounded-md bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700"
|
|
87
|
+
>
|
|
88
|
+
Resume
|
|
89
|
+
</button>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={onCancel}
|
|
93
|
+
className="h-9 px-4 rounded-md bg-red-600 text-white text-sm font-medium hover:bg-red-700"
|
|
94
|
+
>
|
|
95
|
+
Cancel
|
|
96
|
+
</button>
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
</form>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useAgentStore } from "@/stores/agentStore";
|
|
5
|
+
import type { AgentRole } from "@/lib/workflow/types";
|
|
6
|
+
|
|
7
|
+
export function useAgentStream(role: AgentRole) {
|
|
8
|
+
const agent = useAgentStore((s) => s.agents[role]);
|
|
9
|
+
|
|
10
|
+
const output = useMemo(() => {
|
|
11
|
+
return agent.outputChunks.join("");
|
|
12
|
+
}, [agent.outputChunks]);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
status: agent.status,
|
|
16
|
+
output,
|
|
17
|
+
error: agent.error,
|
|
18
|
+
startedAt: agent.startedAt,
|
|
19
|
+
completedAt: agent.completedAt,
|
|
20
|
+
durationMs: agent.durationMs,
|
|
21
|
+
tokensIn: agent.tokensIn,
|
|
22
|
+
tokensOut: agent.tokensOut,
|
|
23
|
+
activity: agent.activity,
|
|
24
|
+
lastActivityAt: agent.lastActivityAt,
|
|
25
|
+
retryCount: agent.retryCount,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
export function useAutoScroll<T extends HTMLElement>(deps: any[]) {
|
|
6
|
+
const ref = useRef<T>(null);
|
|
7
|
+
const userScrolled = useRef(false);
|
|
8
|
+
|
|
9
|
+
const handleScroll = useCallback(() => {
|
|
10
|
+
const el = ref.current;
|
|
11
|
+
if (!el) return;
|
|
12
|
+
const { scrollTop, scrollHeight, clientHeight } = el;
|
|
13
|
+
// User has scrolled up if not near bottom
|
|
14
|
+
userScrolled.current = scrollHeight - scrollTop - clientHeight > 50;
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const el = ref.current;
|
|
19
|
+
if (!el || userScrolled.current) return;
|
|
20
|
+
el.scrollTop = el.scrollHeight;
|
|
21
|
+
}, deps);
|
|
22
|
+
|
|
23
|
+
return { ref, handleScroll };
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/** Shape of a single usage bucket */
|
|
6
|
+
export interface UsageBucket {
|
|
7
|
+
utilization: number;
|
|
8
|
+
resets_at: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Response from /api/usage */
|
|
12
|
+
export interface UsageData {
|
|
13
|
+
five_hour: UsageBucket | null;
|
|
14
|
+
seven_day: UsageBucket | null;
|
|
15
|
+
seven_day_sonnet: UsageBucket | null;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const POLL_INTERVAL_MS = 60_000; // 60 seconds
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hook to poll Claude Code usage data from /api/usage.
|
|
23
|
+
*
|
|
24
|
+
* - Fetches immediately on mount
|
|
25
|
+
* - Polls every 60 seconds
|
|
26
|
+
* - Gracefully handles errors (returns null values)
|
|
27
|
+
*/
|
|
28
|
+
export function useUsage() {
|
|
29
|
+
const [data, setData] = useState<UsageData | null>(null);
|
|
30
|
+
const [loading, setLoading] = useState(true);
|
|
31
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
32
|
+
|
|
33
|
+
const fetchUsage = useCallback(async () => {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch("/api/usage");
|
|
36
|
+
const json: UsageData = await res.json();
|
|
37
|
+
setData(json);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error("[useUsage] Failed to fetch usage:", err);
|
|
40
|
+
setData({
|
|
41
|
+
five_hour: null,
|
|
42
|
+
seven_day: null,
|
|
43
|
+
seven_day_sonnet: null,
|
|
44
|
+
error: "Network error",
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
// Fetch immediately
|
|
53
|
+
fetchUsage();
|
|
54
|
+
|
|
55
|
+
// Then poll every 60s
|
|
56
|
+
intervalRef.current = setInterval(fetchUsage, POLL_INTERVAL_MS);
|
|
57
|
+
|
|
58
|
+
return () => {
|
|
59
|
+
if (intervalRef.current) {
|
|
60
|
+
clearInterval(intervalRef.current);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}, [fetchUsage]);
|
|
64
|
+
|
|
65
|
+
return { data, loading };
|
|
66
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useWorkflowStore } from "@/stores/workflowStore";
|
|
5
|
+
import { useAgentStore } from "@/stores/agentStore";
|
|
6
|
+
import { useEventStore } from "@/stores/eventStore";
|
|
7
|
+
import { useTerminalStore } from "@/stores/terminalStore";
|
|
8
|
+
import { OutputBuffer } from "@/lib/output-buffer";
|
|
9
|
+
import type { AgentRole } from "@/lib/workflow/types";
|
|
10
|
+
import { AGENT_CONFIG, AGENT_ORDER, getStageForRole } from "@/lib/workflow/types";
|
|
11
|
+
|
|
12
|
+
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
|
13
|
+
|
|
14
|
+
export function useWebSocket() {
|
|
15
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
16
|
+
const reconnectAttempt = useRef(0);
|
|
17
|
+
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
18
|
+
const buffersRef = useRef<Map<string, OutputBuffer>>(new Map());
|
|
19
|
+
|
|
20
|
+
const { setWorkflow, setStatus, setCurrentStage, setCompleted } =
|
|
21
|
+
useWorkflowStore();
|
|
22
|
+
const { appendChunks, setAgentStarted, setAgentCompleted, setAgentFailed, setAgentActivity, setAgentRetry } =
|
|
23
|
+
useAgentStore();
|
|
24
|
+
const addEvent = useEventStore((s) => s.addEvent);
|
|
25
|
+
const { setTerminalId, setConnected } = useTerminalStore();
|
|
26
|
+
|
|
27
|
+
const getOrCreateBuffer = useCallback(
|
|
28
|
+
(role: AgentRole) => {
|
|
29
|
+
const key = role;
|
|
30
|
+
if (!buffersRef.current.has(key)) {
|
|
31
|
+
const buffer = new OutputBuffer((chunks) => {
|
|
32
|
+
appendChunks(role, chunks);
|
|
33
|
+
}, 50);
|
|
34
|
+
buffersRef.current.set(key, buffer);
|
|
35
|
+
}
|
|
36
|
+
return buffersRef.current.get(key)!;
|
|
37
|
+
},
|
|
38
|
+
[appendChunks]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const handleMessage = useCallback(
|
|
42
|
+
(data: any) => {
|
|
43
|
+
switch (data.type) {
|
|
44
|
+
case "pong":
|
|
45
|
+
break;
|
|
46
|
+
|
|
47
|
+
case "workflow:created": {
|
|
48
|
+
const { workflowId, title } = data.payload;
|
|
49
|
+
// Destroy old buffers so stale data from a previous workflow doesn't leak
|
|
50
|
+
for (const buffer of buffersRef.current.values()) buffer.destroy();
|
|
51
|
+
buffersRef.current.clear();
|
|
52
|
+
setWorkflow(workflowId, title);
|
|
53
|
+
addEvent({ type: "info", message: `Workflow started: ${title}` });
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case "workflow:completed": {
|
|
58
|
+
setCompleted();
|
|
59
|
+
addEvent({ type: "success", message: "Workflow completed successfully" });
|
|
60
|
+
// Flush all buffers
|
|
61
|
+
for (const buffer of buffersRef.current.values()) buffer.flush();
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case "workflow:failed": {
|
|
66
|
+
const { error } = data.payload;
|
|
67
|
+
setStatus("failed");
|
|
68
|
+
addEvent({ type: "error", message: `Workflow failed: ${error}` });
|
|
69
|
+
for (const buffer of buffersRef.current.values()) buffer.flush();
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case "workflow:paused":
|
|
74
|
+
setStatus("paused");
|
|
75
|
+
addEvent({ type: "warning", message: "Workflow paused" });
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
case "workflow:cancelled":
|
|
79
|
+
setStatus("cancelled");
|
|
80
|
+
addEvent({ type: "warning", message: "Workflow cancelled" });
|
|
81
|
+
for (const buffer of buffersRef.current.values()) buffer.flush();
|
|
82
|
+
// Reset any agents still running — the server won't send step:failed for cancelled agents
|
|
83
|
+
for (const role of AGENT_ORDER) {
|
|
84
|
+
if (useAgentStore.getState().agents[role].status === "running") {
|
|
85
|
+
setAgentFailed(role, "Cancelled");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case "step:started": {
|
|
91
|
+
const { role } = data.payload as { role: AgentRole };
|
|
92
|
+
const stage = getStageForRole(role);
|
|
93
|
+
const currentRetry = useAgentStore.getState().agents[role].retryCount;
|
|
94
|
+
setCurrentStage(stage.index);
|
|
95
|
+
setAgentStarted(role);
|
|
96
|
+
const retryLabel = currentRetry > 0 ? ` (retry ${currentRetry})` : "";
|
|
97
|
+
addEvent({
|
|
98
|
+
type: "info",
|
|
99
|
+
role,
|
|
100
|
+
message: `${AGENT_CONFIG[role].label} agent started${retryLabel}`,
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "step:stream": {
|
|
106
|
+
const { role, chunk } = data.payload as {
|
|
107
|
+
role: AgentRole;
|
|
108
|
+
chunk: string;
|
|
109
|
+
};
|
|
110
|
+
const buffer = getOrCreateBuffer(role);
|
|
111
|
+
buffer.push(chunk);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case "step:completed": {
|
|
116
|
+
const { role, output, durationMs, tokensIn, tokensOut } =
|
|
117
|
+
data.payload as {
|
|
118
|
+
role: AgentRole;
|
|
119
|
+
output: string;
|
|
120
|
+
durationMs: number;
|
|
121
|
+
tokensIn?: number;
|
|
122
|
+
tokensOut?: number;
|
|
123
|
+
};
|
|
124
|
+
// Flush buffer first
|
|
125
|
+
const buffer = buffersRef.current.get(role);
|
|
126
|
+
if (buffer) buffer.flush();
|
|
127
|
+
setAgentCompleted(role, output, durationMs, tokensIn, tokensOut);
|
|
128
|
+
addEvent({
|
|
129
|
+
type: "success",
|
|
130
|
+
role,
|
|
131
|
+
message: `${AGENT_CONFIG[role].label} agent completed in ${(durationMs / 1000).toFixed(1)}s`,
|
|
132
|
+
});
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "step:failed": {
|
|
137
|
+
const { role, error } = data.payload as {
|
|
138
|
+
role: AgentRole;
|
|
139
|
+
error: string;
|
|
140
|
+
};
|
|
141
|
+
const buffer = buffersRef.current.get(role);
|
|
142
|
+
if (buffer) buffer.flush();
|
|
143
|
+
setAgentFailed(role, error);
|
|
144
|
+
addEvent({
|
|
145
|
+
type: "error",
|
|
146
|
+
role,
|
|
147
|
+
message: `${AGENT_CONFIG[role].label} agent failed: ${error}`,
|
|
148
|
+
});
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case "step:activity": {
|
|
153
|
+
const { role, activity } = data.payload as {
|
|
154
|
+
role: AgentRole;
|
|
155
|
+
activity: import("@/lib/workflow/types").AgentActivity;
|
|
156
|
+
};
|
|
157
|
+
setAgentActivity(role, activity);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case "step:retry": {
|
|
162
|
+
const { role, attempt, maxRetries, reason } = data.payload as {
|
|
163
|
+
role: AgentRole;
|
|
164
|
+
attempt: number;
|
|
165
|
+
maxRetries: number;
|
|
166
|
+
reason: string;
|
|
167
|
+
};
|
|
168
|
+
setAgentRetry(role, attempt);
|
|
169
|
+
addEvent({
|
|
170
|
+
type: "warning",
|
|
171
|
+
role,
|
|
172
|
+
message: `${AGENT_CONFIG[role].label} agent retrying (${attempt}/${maxRetries}): ${reason}`,
|
|
173
|
+
});
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case "terminal:created": {
|
|
178
|
+
const { terminalId } = data.payload;
|
|
179
|
+
setTerminalId(terminalId);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case "terminal:output": {
|
|
184
|
+
const terminalId = data.payload.terminalId;
|
|
185
|
+
const output = data.payload.data;
|
|
186
|
+
window.dispatchEvent(
|
|
187
|
+
new CustomEvent("terminal:output", {
|
|
188
|
+
detail: { terminalId, data: output },
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case "terminal:error": {
|
|
195
|
+
const { error } = data.payload;
|
|
196
|
+
window.dispatchEvent(
|
|
197
|
+
new CustomEvent("terminal:error", {
|
|
198
|
+
detail: { error },
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case "terminal:closed":
|
|
205
|
+
setTerminalId(null);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
[
|
|
210
|
+
setWorkflow,
|
|
211
|
+
setStatus,
|
|
212
|
+
setCurrentStage,
|
|
213
|
+
setCompleted,
|
|
214
|
+
setAgentStarted,
|
|
215
|
+
setAgentCompleted,
|
|
216
|
+
setAgentFailed,
|
|
217
|
+
setAgentActivity,
|
|
218
|
+
setAgentRetry,
|
|
219
|
+
addEvent,
|
|
220
|
+
setTerminalId,
|
|
221
|
+
getOrCreateBuffer,
|
|
222
|
+
]
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const connect = useCallback(() => {
|
|
226
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
227
|
+
const url = `${protocol}//${window.location.host}/ws`;
|
|
228
|
+
|
|
229
|
+
const ws = new WebSocket(url);
|
|
230
|
+
wsRef.current = ws;
|
|
231
|
+
|
|
232
|
+
ws.onopen = () => {
|
|
233
|
+
console.log("[WS] Connected");
|
|
234
|
+
reconnectAttempt.current = 0;
|
|
235
|
+
setConnected(true);
|
|
236
|
+
addEvent({ type: "info", message: "Connected to server" });
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
ws.onmessage = (event) => {
|
|
240
|
+
try {
|
|
241
|
+
const data = JSON.parse(event.data);
|
|
242
|
+
handleMessage(data);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error("[WS] Parse error:", err);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
ws.onclose = () => {
|
|
249
|
+
console.log("[WS] Disconnected");
|
|
250
|
+
setConnected(false);
|
|
251
|
+
wsRef.current = null;
|
|
252
|
+
|
|
253
|
+
// Auto-reconnect with backoff
|
|
254
|
+
const delay =
|
|
255
|
+
RECONNECT_DELAYS[
|
|
256
|
+
Math.min(reconnectAttempt.current, RECONNECT_DELAYS.length - 1)
|
|
257
|
+
];
|
|
258
|
+
reconnectAttempt.current++;
|
|
259
|
+
reconnectTimer.current = setTimeout(connect, delay);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
ws.onerror = (err) => {
|
|
263
|
+
console.error("[WS] Error:", err);
|
|
264
|
+
};
|
|
265
|
+
}, [handleMessage, setConnected, addEvent]);
|
|
266
|
+
|
|
267
|
+
const send = useCallback((message: object) => {
|
|
268
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
269
|
+
wsRef.current.send(JSON.stringify(message));
|
|
270
|
+
}
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
connect();
|
|
275
|
+
// Ping every 30s
|
|
276
|
+
const pingInterval = setInterval(() => {
|
|
277
|
+
send({ type: "ping" });
|
|
278
|
+
}, 30000);
|
|
279
|
+
|
|
280
|
+
return () => {
|
|
281
|
+
clearInterval(pingInterval);
|
|
282
|
+
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
|
283
|
+
if (wsRef.current) wsRef.current.close();
|
|
284
|
+
for (const buffer of buffersRef.current.values()) buffer.destroy();
|
|
285
|
+
};
|
|
286
|
+
}, [connect, send]);
|
|
287
|
+
|
|
288
|
+
return { send, wsRef };
|
|
289
|
+
}
|