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,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
+ }