@frixaco/hbench 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.
@@ -0,0 +1,138 @@
1
+ export function Dashboard() {
2
+ const ws = useWS();
3
+ const repoUrlInput = useDashboardStore(selectRepoUrlInput);
4
+ const runRequested = useDashboardStore(selectRunRequested);
5
+
6
+ const trimmedRepoUrlInput = useDashboardStore(selectTrimmedRepoUrlInput);
7
+ const isRepoReady = useDashboardStore(selectIsRepoReady);
8
+ const launchedAgentCount = useDashboardStore(selectLaunchedAgentCount);
9
+
10
+ const handleLaunchAgent = useCallback(
11
+ (agent: string) => {
12
+ ws.send(agent);
13
+ launchAgent(agent);
14
+ },
15
+ [ws],
16
+ );
17
+
18
+ return (
19
+ <main className="flex min-h-screen w-full flex-col bg-background">
20
+ <header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/80">
21
+ <div className="mx-auto flex h-11 max-w-480 items-center gap-3 px-4">
22
+ <span className="text-sm font-bold tracking-tight">hbench</span>
23
+
24
+ <div className="mx-2 h-4 w-px bg-border" />
25
+
26
+ <div className="flex min-w-0 flex-1 items-center gap-2">
27
+ <Input
28
+ value={repoUrlInput}
29
+ onChange={(e) => setRepoUrlInput(e.currentTarget.value)}
30
+ placeholder="https://github.com/org/repo"
31
+ className="h-7 max-w-sm font-mono text-xs"
32
+ />
33
+ <Button
34
+ size="xs"
35
+ disabled={trimmedRepoUrlInput.length === 0}
36
+ onClick={() => {
37
+ ws.send(
38
+ JSON.stringify({
39
+ type: "setup",
40
+ repoUrl: trimmedRepoUrlInput,
41
+ }),
42
+ );
43
+ }}
44
+ >
45
+ Setup
46
+ </Button>
47
+ <Button
48
+ size="xs"
49
+ variant="outline"
50
+ disabled={trimmedRepoUrlInput.length === 0}
51
+ onClick={() => {
52
+ ws.send(
53
+ JSON.stringify({
54
+ type: "use-existing",
55
+ repoUrl: trimmedRepoUrlInput,
56
+ }),
57
+ );
58
+ }}
59
+ >
60
+ Use Existing
61
+ </Button>
62
+ <Button
63
+ size="xs"
64
+ variant="destructive"
65
+ onClick={() => {
66
+ ws.send(JSON.stringify({ type: "wipe" }));
67
+ }}
68
+ >
69
+ <Trash2 /> Wipe
70
+ </Button>
71
+ </div>
72
+
73
+ <div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
74
+ <span className="flex items-center gap-1.5">
75
+ <span
76
+ className={cn(
77
+ "size-1.5 rounded-full",
78
+ ws.isReady ? "bg-emerald-500" : "bg-red-400",
79
+ )}
80
+ />
81
+ {ws.isReady ? "ws" : "offline"}
82
+ </span>
83
+ <span className="flex items-center gap-1.5">
84
+ <span
85
+ className={cn(
86
+ "size-1.5 rounded-full",
87
+ isRepoReady ? "bg-emerald-500" : "bg-muted-foreground/50",
88
+ )}
89
+ />
90
+ {isRepoReady ? "ready" : "no repo"}
91
+ </span>
92
+ </div>
93
+
94
+ <span className="text-xs tabular-nums text-muted-foreground">
95
+ {launchedAgentCount}/{agents.length}
96
+ </span>
97
+ </div>
98
+ </header>
99
+
100
+ <CommandBar />
101
+
102
+ <div className="flex-1 px-3 pt-3 pb-4">
103
+ <div className="mx-auto grid max-w-480 gap-3 grid-cols-[repeat(auto-fit,minmax(420px,1fr))]">
104
+ {agents.map((agent) => (
105
+ <TUI
106
+ key={agent}
107
+ name={agent}
108
+ runRequested={runRequested[agent] ?? false}
109
+ isRepoReady={isRepoReady}
110
+ repoUrlInput={trimmedRepoUrlInput}
111
+ onLaunch={() => handleLaunchAgent(agent)}
112
+ />
113
+ ))}
114
+ </div>
115
+ </div>
116
+ </main>
117
+ );
118
+ }
119
+
120
+ import { useCallback } from "react";
121
+ import { Trash2 } from "lucide-react";
122
+ import { Button } from "./components/button";
123
+ import { Input } from "./components/input";
124
+ import { useWS } from "./lib/websocket";
125
+ import { cn } from "./lib/utils";
126
+ import {
127
+ agents,
128
+ launchAgent,
129
+ selectIsRepoReady,
130
+ selectRunRequested,
131
+ selectLaunchedAgentCount,
132
+ selectRepoUrlInput,
133
+ selectTrimmedRepoUrlInput,
134
+ setRepoUrlInput,
135
+ useDashboardStore,
136
+ } from "./lib/store";
137
+ import { TUI } from "./components/tui";
138
+ import { CommandBar } from "./components/cmd-bar";
package/ui/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <!-- <link rel="icon" type="image/svg+xml" href="./logo.svg" /> -->
7
+ <title>Bun + React</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./index.tsx"></script>
12
+ </body>
13
+ </html>
package/ui/index.tsx ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This file is the entry point for the React app, it sets up the root
3
+ * element and renders the App component to the DOM.
4
+ *
5
+ * It is included in `ui/index.html`.
6
+ */
7
+
8
+ function start() {
9
+ const root = createRoot(document.getElementById("root")!);
10
+ root.render(<App />);
11
+ }
12
+
13
+ if (document.readyState === "loading") {
14
+ document.addEventListener("DOMContentLoaded", start);
15
+ } else {
16
+ start();
17
+ }
18
+
19
+ import { createRoot } from "react-dom/client";
20
+ import { App } from "./app";
@@ -0,0 +1,127 @@
1
+ const svg = (content: string, size: number) =>
2
+ `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}'>${content}</svg>`)}")`;
3
+
4
+ const s = (opacity: number) => `stroke-opacity='${opacity}'`;
5
+ const f = (opacity: number) => `fill-opacity='${opacity}'`;
6
+
7
+ const n = (value: number) => value.toFixed(2);
8
+
9
+ const hash = (value: string) => {
10
+ let hashed = 2166136261;
11
+ for (let i = 0; i < value.length; i += 1) {
12
+ hashed ^= value.charCodeAt(i);
13
+ hashed = Math.imul(hashed, 16777619);
14
+ }
15
+ return hashed >>> 0;
16
+ };
17
+
18
+ const createRng = (seed: string) => {
19
+ let state = hash(seed) || 1;
20
+ return () => {
21
+ state += 0x6d2b79f5;
22
+ let value = Math.imul(state ^ (state >>> 15), state | 1);
23
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
24
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
25
+ };
26
+ };
27
+
28
+ type ShapeFactory = (x: number, y: number, rand: () => number) => string;
29
+
30
+ const randomPattern = (
31
+ seed: string,
32
+ size: number,
33
+ count: number,
34
+ shapeFactory: ShapeFactory,
35
+ ) => {
36
+ const rand = createRng(seed);
37
+ const margin = 6;
38
+ let content = "";
39
+
40
+ for (let i = 0; i < count; i += 1) {
41
+ const x = margin + rand() * (size - margin * 2);
42
+ const y = margin + rand() * (size - margin * 2);
43
+ content += shapeFactory(x, y, rand);
44
+ }
45
+
46
+ return svg(content, size);
47
+ };
48
+
49
+ const starPath = (cx: number, cy: number, radius: number) => {
50
+ const inner = radius * 0.45;
51
+ let path = "";
52
+
53
+ for (let i = 0; i < 10; i += 1) {
54
+ const angle = -Math.PI / 2 + (Math.PI * i) / 5;
55
+ const distance = i % 2 === 0 ? radius : inner;
56
+ const px = cx + Math.cos(angle) * distance;
57
+ const py = cy + Math.sin(angle) * distance;
58
+ path += `${i === 0 ? "M" : "L"}${n(px)} ${n(py)} `;
59
+ }
60
+
61
+ return `${path}Z`;
62
+ };
63
+
64
+ const petalPath = (cx: number, cy: number, width: number, height: number) =>
65
+ `M${n(cx)} ${n(cy - height)}
66
+ Q${n(cx + width)} ${n(cy - height * 0.55)} ${n(cx + width * 1.35)} ${n(cy)}
67
+ Q${n(cx + width)} ${n(cy + height * 0.55)} ${n(cx)} ${n(cy + height)}
68
+ Q${n(cx - width)} ${n(cy + height * 0.55)} ${n(cx - width * 1.35)} ${n(cy)}
69
+ Q${n(cx - width)} ${n(cy - height * 0.55)} ${n(cx)} ${n(cy - height)} Z`;
70
+
71
+ const patterns: Record<string, string> = {
72
+ amp: randomPattern("amp", 80, 18, (x, y, rand) => {
73
+ const arm = 2 + rand() * 2.8;
74
+ return `<line x1='${n(x - arm)}' y1='${n(y - arm)}' x2='${n(x + arm)}' y2='${n(y + arm)}' stroke='white' ${s(0.06)} stroke-width='1.4'/>
75
+ <line x1='${n(x + arm)}' y1='${n(y - arm)}' x2='${n(x - arm)}' y2='${n(y + arm)}' stroke='white' ${s(0.04)} stroke-width='1'/>`;
76
+ }),
77
+
78
+ droid: randomPattern("droid", 84, 16, (x, y, rand) => {
79
+ const width = 5 + rand() * 4;
80
+ const height = 3 + rand() * 2.5;
81
+ const left = x - width / 2;
82
+ const top = y - height / 2;
83
+ const baseY = top + height + 1.5;
84
+ return `<rect x='${n(left)}' y='${n(top)}' width='${n(width)}' height='${n(height)}' rx='1' fill='none' stroke='white' ${s(0.06)} stroke-width='1'/>
85
+ <line x1='${n(left)}' y1='${n(baseY)}' x2='${n(left + width)}' y2='${n(baseY)}' stroke='white' ${s(0.04)} stroke-width='1'/>`;
86
+ }),
87
+
88
+ pi: randomPattern("pi", 82, 18, (x, y, rand) => {
89
+ const ring = 1.7 + rand() * 1.8;
90
+ const dot = 0.7 + rand() * 1.1;
91
+ const offsetX = (rand() - 0.5) * 6;
92
+ const offsetY = (rand() - 0.5) * 6;
93
+ return `<circle cx='${n(x)}' cy='${n(y)}' r='${n(ring)}' fill='none' stroke='white' ${s(0.06)} stroke-width='1'/>
94
+ <circle cx='${n(x + offsetX)}' cy='${n(y + offsetY)}' r='${n(dot)}' fill='white' ${f(0.03)}/>`;
95
+ }),
96
+
97
+ opencode: randomPattern("opencode", 80, 16, (x, y, rand) => {
98
+ const outer = 2.4 + rand() * 2.2;
99
+ const inner = 1.2 + rand() * 1.4;
100
+ const innerX = x + (rand() - 0.5) * 4.5;
101
+ const innerY = y + (rand() - 0.5) * 4.5;
102
+ return `<rect x='${n(x - outer)}' y='${n(y - outer)}' width='${n(outer * 2)}' height='${n(outer * 2)}' fill='none' stroke='white' ${s(0.06)} stroke-width='1'/>
103
+ <rect x='${n(innerX - inner)}' y='${n(innerY - inner)}' width='${n(inner * 2)}' height='${n(inner * 2)}' fill='white' ${f(0.03)}/>`;
104
+ }),
105
+
106
+ claude: randomPattern("claude", 86, 14, (x, y, rand) => {
107
+ const radius = 2.8 + rand() * 2.2;
108
+ return `<path d='${starPath(x, y, radius)}' fill='none' stroke='white' ${s(0.06)} stroke-width='1'/>`;
109
+ }),
110
+
111
+ codex: randomPattern("codex", 88, 14, (x, y, rand) => {
112
+ const width = 1.9 + rand() * 1.8;
113
+ const height = 1.7 + rand() * 1.6;
114
+ const gap = 1.1 + rand() * 1.2;
115
+ const top = petalPath(x, y - gap, width, height);
116
+ const bottom = petalPath(x, y + gap, width, height);
117
+ return `<path d='${top} ${bottom}' fill='none' stroke='white' ${s(0.06)} stroke-width='1'/>`;
118
+ }),
119
+ };
120
+
121
+ export function getAgentPattern(
122
+ agent: string,
123
+ ): React.CSSProperties | undefined {
124
+ const bg = patterns[agent];
125
+ if (!bg) return undefined;
126
+ return { backgroundImage: bg, backgroundRepeat: "repeat" };
127
+ }
@@ -0,0 +1,38 @@
1
+ type FetchAgentDiffInput = {
2
+ agent: string;
3
+ repoUrlInput?: string | null;
4
+ signal?: AbortSignal;
5
+ requestId?: string;
6
+ };
7
+
8
+ const normalizeDiffErrorMessage = (body: string) => {
9
+ const trimmedBody = body.trim();
10
+ return trimmedBody.length > 0 ? trimmedBody : "Failed to load diff";
11
+ };
12
+
13
+ export async function fetchAgentDiff({
14
+ agent,
15
+ repoUrlInput,
16
+ signal,
17
+ requestId,
18
+ }: FetchAgentDiffInput): Promise<string> {
19
+ const search = new URLSearchParams({
20
+ agent,
21
+ t: requestId ?? Date.now().toString(),
22
+ });
23
+
24
+ const trimmedRepoUrlInput = repoUrlInput?.trim();
25
+ if (trimmedRepoUrlInput) {
26
+ search.set("repoUrl", trimmedRepoUrlInput);
27
+ }
28
+
29
+ const response = await fetch(`/api/diff?${search.toString()}`, {
30
+ signal,
31
+ });
32
+ const body = await response.text();
33
+ if (!response.ok) {
34
+ throw new Error(normalizeDiffErrorMessage(body));
35
+ }
36
+
37
+ return body;
38
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "amp": ["rush", "deep", "smart", "oracle"],
3
+ "droid": ["gpt-5.2", "gpt-5.2-codex", "kimi-k2.5"],
4
+ "pi": ["claude-opus-4-5", "gpt-5.2", "gpt-5.2-codex"],
5
+ "codex": ["gpt-5.2", "gpt-5.2-codex"],
6
+ "claude": ["claude-opus-4-5", "claude-sonnet-4-5"],
7
+ "opencode": ["claude opus 4.5", "kimi k2.5", "gpt-5.2", "gpt-5.2 codex"]
8
+ }
@@ -0,0 +1,82 @@
1
+ export type ReviewModelOption = {
2
+ id: string;
3
+ label: string;
4
+ };
5
+
6
+ type DiffEntry = {
7
+ agent: string;
8
+ diff: string;
9
+ };
10
+
11
+ const maxPerAgentDiffChars = 6_000;
12
+ const maxTotalDiffChars = 24_000;
13
+
14
+ const reviewSystemPrompt = [
15
+ "You are a strict code-review judge comparing git diffs from multiple agents.",
16
+ "Return markdown only. No preamble, no chain-of-thought, no speculation, no repetition.",
17
+ "Keep response under 220 words.",
18
+ "Focus only on what is visible in the diffs: correctness, risk, tests, maintainability.",
19
+ "Use exactly this structure:",
20
+ "## Best vs Worst",
21
+ "- Best: <agent> — <one-line reason>",
22
+ "- Worst: <agent> — <one-line reason>",
23
+ "## Agent Review",
24
+ "| Agent | Verdict | Good | Problems |",
25
+ "|---|---|---|---|",
26
+ "| <agent> | pass/warn/fail | <short> | <short> |",
27
+ "## Comparison",
28
+ "- Why best beats second best (one line)",
29
+ "- Biggest risk in worst patch (one line)",
30
+ ].join(" ");
31
+
32
+ export const reviewModelOptions: Array<ReviewModelOption> = [
33
+ {
34
+ id: "openai/gpt-5.2",
35
+ label: "GPT 5.2",
36
+ },
37
+ {
38
+ id: "google/gemini-3-pro-preview",
39
+ label: "Gemini 3 Pro Preview",
40
+ },
41
+ ];
42
+
43
+ const truncateText = (value: string, maxChars: number) => {
44
+ if (value.length <= maxChars) return value;
45
+ const omitted = value.length - maxChars;
46
+ return `${value.slice(0, maxChars)}\n\n... [truncated ${String(omitted)} chars]`;
47
+ };
48
+
49
+ export const buildReviewPrompt = ({
50
+ repoUrl,
51
+ diffs,
52
+ }: {
53
+ repoUrl: string;
54
+ diffs: Array<DiffEntry>;
55
+ }): string => {
56
+ let budgetLeft = maxTotalDiffChars;
57
+ const diffBlocks = diffs
58
+ .map(({ agent, diff }) => {
59
+ if (budgetLeft <= 0) {
60
+ return `## Agent: ${agent}\n\nDiff omitted due to total size limit.`;
61
+ }
62
+
63
+ const capped = truncateText(diff, maxPerAgentDiffChars);
64
+ const withinBudget = truncateText(capped, budgetLeft);
65
+ budgetLeft -= withinBudget.length;
66
+
67
+ return `## Agent: ${agent}\n\n\`\`\`diff\n${withinBudget}\n\`\`\``;
68
+ })
69
+ .join("\n\n");
70
+
71
+ const repoHeader = repoUrl
72
+ ? `Repository: ${repoUrl}`
73
+ : "Repository: local worktree";
74
+
75
+ return [
76
+ reviewSystemPrompt,
77
+ repoHeader,
78
+ "Review each agent diff. Rank best and worst. Keep it concise.",
79
+ 'Important: evaluate only changed code shown below. If information is missing, say "unclear from diff".',
80
+ diffBlocks,
81
+ ].join("\n\n");
82
+ };
@@ -0,0 +1,90 @@
1
+ export const agents = Object.keys(modelsJson as Record<string, unknown>);
2
+
3
+ type RunRequestedState = Record<string, boolean>;
4
+
5
+ type DashboardState = {
6
+ prompt: string;
7
+ repoUrlInput: string;
8
+ activeRepoUrl: string | null;
9
+ runRequested: RunRequestedState;
10
+ isStoppingAgents: boolean;
11
+ };
12
+
13
+ const createRequestedState = (requested: boolean): RunRequestedState =>
14
+ agents.reduce((acc, agent) => {
15
+ acc[agent] = requested;
16
+ return acc;
17
+ }, {} as RunRequestedState);
18
+
19
+ export const createRunRequestedState = () => createRequestedState(false);
20
+ export const createLaunchedRequestedState = () => createRequestedState(true);
21
+
22
+ export const useDashboardStore = create<DashboardState>()(() => ({
23
+ prompt: "",
24
+ repoUrlInput: "",
25
+ activeRepoUrl: null,
26
+ runRequested: createRunRequestedState(),
27
+ isStoppingAgents: false,
28
+ }));
29
+
30
+ export const setPrompt = (prompt: string) => {
31
+ useDashboardStore.setState({ prompt });
32
+ };
33
+
34
+ export const setRepoUrlInput = (repoUrlInput: string) => {
35
+ useDashboardStore.setState({ repoUrlInput });
36
+ };
37
+
38
+ export const setActiveRepoUrl = (activeRepoUrl: string | null) => {
39
+ useDashboardStore.setState({ activeRepoUrl });
40
+ };
41
+
42
+ export const setIsStoppingAgents = (isStoppingAgents: boolean) => {
43
+ useDashboardStore.setState({ isStoppingAgents });
44
+ };
45
+
46
+ export const launchAgent = (agent: string) => {
47
+ useDashboardStore.setState((state) => ({
48
+ runRequested: {
49
+ ...state.runRequested,
50
+ [agent]: true,
51
+ },
52
+ }));
53
+ };
54
+
55
+ export const launchAllAgents = () => {
56
+ useDashboardStore.setState({ runRequested: createLaunchedRequestedState() });
57
+ };
58
+
59
+ export const resetRunRequested = () => {
60
+ useDashboardStore.setState({ runRequested: createRunRequestedState() });
61
+ };
62
+
63
+ export const selectPrompt = (state: DashboardState) => state.prompt;
64
+
65
+ export const selectRepoUrlInput = (state: DashboardState) => state.repoUrlInput;
66
+
67
+ export const selectRunRequested = (state: DashboardState) => state.runRequested;
68
+
69
+ export const selectIsStoppingAgents = (state: DashboardState) =>
70
+ state.isStoppingAgents;
71
+
72
+ export const selectTrimmedRepoUrlInput = (state: DashboardState) =>
73
+ state.repoUrlInput.trim();
74
+
75
+ export const selectTrimmedPrompt = (state: DashboardState) =>
76
+ state.prompt.trim();
77
+
78
+ export const selectIsRepoReady = (state: DashboardState) => {
79
+ const trimmedRepoUrlInput = state.repoUrlInput.trim();
80
+ return (
81
+ trimmedRepoUrlInput.length > 0 &&
82
+ state.activeRepoUrl === trimmedRepoUrlInput
83
+ );
84
+ };
85
+
86
+ export const selectLaunchedAgentCount = (state: DashboardState) =>
87
+ Object.values(state.runRequested).filter(Boolean).length;
88
+
89
+ import { create } from "zustand";
90
+ import modelsJson from "./models.json";
@@ -0,0 +1,7 @@
1
+ export function cn(...inputs: Array<ClassValue>) {
2
+ return twMerge(clsx(inputs));
3
+ }
4
+
5
+ import { clsx } from "clsx";
6
+ import { twMerge } from "tailwind-merge";
7
+ import type { ClassValue } from "clsx";
@@ -0,0 +1,144 @@
1
+ const WebSocketContext = createContext<WebSocket | null>(null);
2
+
3
+ export function useWS() {
4
+ const socket = useContext(WebSocketContext);
5
+
6
+ return {
7
+ isReady: socket?.readyState == WebSocket.OPEN,
8
+ socket,
9
+ send: (msg: string) => socket?.send(msg),
10
+ };
11
+ }
12
+
13
+ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
14
+ const [conn, setConn] = useState<WebSocket | null>(null);
15
+ const setupToastIdRef = useRef<string | number | null>(null);
16
+ const wipeToastIdRef = useRef<string | number | null>(null);
17
+
18
+ useEffect(() => {
19
+ let retry = 0;
20
+ let socket: WebSocket | null = null;
21
+ let timeout: ReturnType<typeof setTimeout> | null = null;
22
+
23
+ const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
24
+ const wsUrl = `${wsProtocol}//${window.location.host}/api/vt`;
25
+
26
+ function initWSConnection() {
27
+ const wsConn = new WebSocket(wsUrl);
28
+ wsConn.binaryType = "arraybuffer";
29
+
30
+ socket = wsConn;
31
+ setConn(wsConn);
32
+
33
+ wsConn.onopen = () => {
34
+ retry = 0;
35
+ };
36
+ wsConn.onerror = (err) => {
37
+ console.error("ws err:", err);
38
+ };
39
+ wsConn.onclose = () => {
40
+ console.error(`ws closed, retrying ${retry} times`);
41
+
42
+ if (retry < 3) {
43
+ retry++;
44
+
45
+ timeout = setTimeout(() => initWSConnection(), 2000);
46
+ }
47
+ };
48
+ }
49
+
50
+ initWSConnection();
51
+
52
+ return () => {
53
+ socket?.close();
54
+ setConn(null);
55
+
56
+ if (timeout) clearTimeout(timeout);
57
+ };
58
+ }, []);
59
+
60
+ useEffect(() => {
61
+ if (!conn) return;
62
+
63
+ const handleStatus = (event: MessageEvent) => {
64
+ if (typeof event.data !== "string") return;
65
+ try {
66
+ const payload = JSON.parse(event.data);
67
+ if (payload?.type === "setup-status") {
68
+ const isUseExisting = payload.mode === "existing";
69
+ if (payload.status === "start") {
70
+ setActiveRepoUrl(null);
71
+ setupToastIdRef.current = toast.loading(
72
+ isUseExisting
73
+ ? "Loading existing worktrees..."
74
+ : "Setting up worktrees...",
75
+ {
76
+ description: payload.repoUrl,
77
+ },
78
+ );
79
+ return;
80
+ }
81
+ if (payload.status === "success") {
82
+ setActiveRepoUrl(
83
+ typeof payload.repoUrl === "string"
84
+ ? payload.repoUrl.trim()
85
+ : null,
86
+ );
87
+ toast.success(
88
+ isUseExisting ? "Using existing worktrees" : "Setup complete",
89
+ {
90
+ id: setupToastIdRef.current ?? undefined,
91
+ description: payload.repoUrl,
92
+ },
93
+ );
94
+ return;
95
+ }
96
+ if (payload.status === "error") {
97
+ setActiveRepoUrl(null);
98
+ toast.error(
99
+ isUseExisting ? "Use Existing failed" : "Setup failed",
100
+ {
101
+ id: setupToastIdRef.current ?? undefined,
102
+ description: payload.message ?? payload.repoUrl,
103
+ },
104
+ );
105
+ }
106
+ }
107
+
108
+ if (payload?.type === "wipe-status") {
109
+ if (payload.status === "start") {
110
+ setActiveRepoUrl(null);
111
+ wipeToastIdRef.current = toast.loading("Wiping ~/.hbench...");
112
+ return;
113
+ }
114
+ if (payload.status === "success") {
115
+ setActiveRepoUrl(null);
116
+ toast.success("Sandbox wiped", {
117
+ id: wipeToastIdRef.current ?? undefined,
118
+ });
119
+ return;
120
+ }
121
+ if (payload.status === "error") {
122
+ toast.error("Wipe failed", {
123
+ id: wipeToastIdRef.current ?? undefined,
124
+ description: payload.message,
125
+ });
126
+ }
127
+ }
128
+ } catch (error) {
129
+ console.warn("Invalid status payload", error);
130
+ }
131
+ };
132
+
133
+ conn.addEventListener("message", handleStatus);
134
+ return () => {
135
+ conn.removeEventListener("message", handleStatus);
136
+ };
137
+ }, [conn]);
138
+
139
+ return <WebSocketContext value={conn}>{children}</WebSocketContext>;
140
+ }
141
+
142
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
143
+ import { toast } from "sonner";
144
+ import { setActiveRepoUrl } from "./store";