Skip to content

Commit 4b0ad1f

Browse files
committed
feat(v7-h42): useGenerateStream hook + LiveGenPanel for /ws/gen/{job_id}
Closes plan item #42. Backend already publishes per-token events to gen_event_bus and exposes them via /ws/gen/{job_id}, but the UI had no consumer — gen.run looked like a single-shot RPC even though the streaming infrastructure was there. - useGenerateStream(baseUrl, jobId, active): mirror of useLiveTrainStream — collects {step, token_id, finish_reason} events into a buffer, exponential-backoff reconnect on unexpected drop, finish:'ok' frame flips one-shot toast, reset + dismissToast helpers. - LiveGenPanel: rolling tail of last N token ids (default 64), per-second dead-man-switch update, finish toast with dismiss, reconnect counter pill. 5/5 useGenerateStream vitest cover URL construction, inactive no-op, event collect + finish + close, reset/dismissToast lifecycle. 6/6 LiveGenPanel vitest cover empty-state, waiting placeholder, token-count + tail, tail window cap, finish toast, reconnect pill.
1 parent cc5718c commit 4b0ad1f

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// V7-H42: live gen.run token stream display.
2+
//
3+
// Mirror of LiveTrainPanel for the generation path:
4+
// * rolling tail of last N tokens (default 64),
5+
// * dead-man-switch when no event for > stallSeconds,
6+
// * finish:'ok' toast.
7+
//
8+
// Consumes useGenerateStream's events array.
9+
10+
import { useEffect, useState } from "react";
11+
import type { GenTokenEvent } from "@/hooks/useGenerateStream";
12+
13+
export interface LiveGenPanelProps {
14+
events: GenTokenEvent[];
15+
genInFlight: boolean;
16+
finishToast?: boolean;
17+
reconnectAttempts?: number;
18+
stallSeconds?: number;
19+
onDismissToast?: () => void;
20+
/** Window of latest tokens to render. */
21+
tail?: number;
22+
}
23+
24+
export function LiveGenPanel({
25+
events, genInFlight, finishToast = false,
26+
reconnectAttempts = 0, stallSeconds = 8, onDismissToast,
27+
tail = 64,
28+
}: LiveGenPanelProps): JSX.Element {
29+
// Dead-man-switch tick — refreshes every second while gen is active
30+
// so "stalled X.Xs" updates without a fresh event.
31+
const [, setTick] = useState(0);
32+
useEffect(() => {
33+
if (!genInFlight) return;
34+
const id = setInterval(() => setTick((t) => t + 1), 1000);
35+
return () => clearInterval(id);
36+
}, [genInFlight]);
37+
38+
const last = events.length > 0 ? events[events.length - 1] : null;
39+
const window = events.slice(-tail);
40+
const stalled = last && genInFlight
41+
? ((Date.now() / 1000)
42+
- ((last as { ts?: number }).ts ?? 0))
43+
: 0;
44+
45+
if (!genInFlight && events.length === 0 && !finishToast) {
46+
return <></>;
47+
}
48+
49+
return (
50+
<div data-testid="live-gen-panel"
51+
style={{ padding: 8, border: "1px solid #e5e7eb",
52+
borderRadius: 4, background: "#f9fafb",
53+
fontFamily: "system-ui, sans-serif", fontSize: 11 }}>
54+
<div style={{ display: "flex", justifyContent: "space-between",
55+
marginBottom: 4 }}>
56+
<strong>live gen</strong>
57+
{reconnectAttempts > 0 && (
58+
<span data-testid="live-gen-panel-reconnects"
59+
style={{ color: "#d97706" }}>
60+
reconnects: {reconnectAttempts}
61+
</span>
62+
)}
63+
</div>
64+
{events.length === 0 ? (
65+
<div data-testid="live-gen-panel-empty" style={{ color: "#9ca3af" }}>
66+
waiting for first token…
67+
</div>
68+
) : (
69+
<>
70+
<div data-testid="live-gen-panel-pill"
71+
style={{ display: "flex", gap: 8, flexWrap: "wrap",
72+
marginBottom: 4 }}>
73+
<span data-testid="live-gen-panel-token-count">
74+
tokens {events.length}
75+
</span>
76+
<span data-testid="live-gen-panel-last-token">
77+
last id {last?.token_id ?? "?"}
78+
</span>
79+
{stalled > stallSeconds && (
80+
<span data-testid="live-gen-panel-stalled"
81+
style={{ color: "#dc2626" }}>
82+
⚠ stalled {stalled.toFixed(1)}s
83+
</span>
84+
)}
85+
</div>
86+
<div data-testid="live-gen-panel-tail"
87+
style={{ fontFamily: "monospace", color: "#374151",
88+
whiteSpace: "pre-wrap", maxHeight: 80,
89+
overflowY: "auto" }}>
90+
{window.map((e) => e.token_id).join(" ")}
91+
</div>
92+
</>
93+
)}
94+
{finishToast && (
95+
<div data-testid="live-gen-panel-toast"
96+
style={{ marginTop: 6, padding: 6,
97+
background: "#dcfce7", color: "#166534",
98+
borderRadius: 4, display: "flex",
99+
justifyContent: "space-between",
100+
alignItems: "center" }}>
101+
<span>✓ gen done</span>
102+
{onDismissToast && (
103+
<button data-testid="live-gen-panel-toast-dismiss"
104+
onClick={onDismissToast}
105+
style={{ background: "transparent", border: "none",
106+
color: "#166534", cursor: "pointer" }}>
107+
×
108+
</button>
109+
)}
110+
</div>
111+
)}
112+
</div>
113+
);
114+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// V7-H42: WebSocket subscriber for /ws/gen/{job_id}.
2+
//
3+
// Mirror of useLiveTrainStream for the gen.run token stream. Collects
4+
// per-token events into an in-memory buffer (consumer decides how to
5+
// render — LiveGenPanel just shows the rolling token tail). Backs off
6+
// on unexpected drops; treats {finish:'ok'} as a one-shot toast.
7+
//
8+
// Backend: cppmega_v4/runtime/gen_event_bus.publish per token and
9+
// /ws/gen/{job_id} endpoint in cppmega_v4/jsonrpc/server.py.
10+
11+
import { useEffect, useRef, useState } from "react";
12+
13+
export interface GenTokenEvent {
14+
step: number;
15+
token_id: number;
16+
finish_reason?: string | null;
17+
[k: string]: unknown;
18+
}
19+
20+
export interface UseGenerateStreamState {
21+
events: GenTokenEvent[];
22+
reconnectAttempts: number;
23+
finishToast: boolean;
24+
reset: () => void;
25+
dismissToast: () => void;
26+
}
27+
28+
export function useGenerateStream(
29+
baseUrl: string,
30+
jobId: string | null,
31+
active: boolean,
32+
): UseGenerateStreamState {
33+
const [events, setEvents] = useState<GenTokenEvent[]>([]);
34+
const [reconnectAttempts, setReconnectAttempts] = useState(0);
35+
const [finishToast, setFinishToast] = useState(false);
36+
const socketRef = useRef<WebSocket | null>(null);
37+
// Tracks normal completion so reconnect only fires on unexpected drops.
38+
const completedRef = useRef<boolean>(false);
39+
40+
const reset = () => {
41+
setEvents([]);
42+
setReconnectAttempts(0);
43+
setFinishToast(false);
44+
completedRef.current = false;
45+
};
46+
const dismissToast = () => setFinishToast(false);
47+
48+
useEffect(() => {
49+
if (!jobId || !active) return;
50+
let cancelled = false;
51+
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
52+
let attempt = 0;
53+
completedRef.current = false;
54+
55+
const wsUrl = `${baseUrl.replace(/^http/, "ws")}/ws/gen/${jobId}`;
56+
57+
const connect = () => {
58+
let socket: WebSocket;
59+
try {
60+
socket = new WebSocket(wsUrl);
61+
} catch {
62+
scheduleReconnect();
63+
return;
64+
}
65+
socketRef.current = socket;
66+
socket.onmessage = (msg) => {
67+
try {
68+
const frame = JSON.parse(msg.data) as
69+
{ event?: GenTokenEvent; finish?: string };
70+
if (frame.event) {
71+
setEvents((prev) => [...prev, frame.event!]);
72+
} else if (frame.finish === "ok") {
73+
completedRef.current = true;
74+
setFinishToast(true);
75+
try { socket.close(); } catch { /* noop */ }
76+
}
77+
} catch { /* ignore malformed */ }
78+
};
79+
socket.onclose = () => {
80+
if (cancelled) return;
81+
if (!completedRef.current && active) {
82+
scheduleReconnect();
83+
}
84+
};
85+
socket.onerror = () => { /* close handles reconnect */ };
86+
};
87+
88+
const scheduleReconnect = () => {
89+
attempt += 1;
90+
setReconnectAttempts(attempt);
91+
const delay = Math.min(5_000, 500 * 2 ** Math.min(attempt - 1, 4));
92+
reconnectTimer = setTimeout(connect, delay);
93+
};
94+
95+
connect();
96+
return () => {
97+
cancelled = true;
98+
if (reconnectTimer) clearTimeout(reconnectTimer);
99+
try { socketRef.current?.close(); } catch { /* noop */ }
100+
socketRef.current = null;
101+
};
102+
}, [baseUrl, jobId, active]);
103+
104+
return { events, reconnectAttempts, finishToast, reset, dismissToast };
105+
}

vbgui/tests/LiveGenPanel.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// V7-H42: LiveGenPanel renders rolling token tail + finish toast.
2+
3+
import { describe, it, expect, vi } from "vitest";
4+
import { render, screen } from "@testing-library/react";
5+
import { LiveGenPanel } from "@/components/LiveGenPanel";
6+
import type { GenTokenEvent } from "@/hooks/useGenerateStream";
7+
8+
const EVS: GenTokenEvent[] = [
9+
{ step: 0, token_id: 100 },
10+
{ step: 1, token_id: 200 },
11+
{ step: 2, token_id: 300 },
12+
];
13+
14+
describe("V7-H42 LiveGenPanel", () => {
15+
it("returns empty when nothing happened yet", () => {
16+
const { container } = render(
17+
<LiveGenPanel events={[]} genInFlight={false} />,
18+
);
19+
expect(container.innerHTML).toBe("");
20+
});
21+
22+
it("renders waiting-for-first-token placeholder when active + empty",
23+
() => {
24+
render(<LiveGenPanel events={[]} genInFlight={true} />);
25+
expect(screen.getByTestId("live-gen-panel-empty")).toBeTruthy();
26+
});
27+
28+
it("renders token count + last id + rolling tail", () => {
29+
render(<LiveGenPanel events={EVS} genInFlight={true} />);
30+
expect(screen.getByTestId("live-gen-panel-token-count").textContent)
31+
.toContain("3");
32+
expect(screen.getByTestId("live-gen-panel-last-token").textContent)
33+
.toContain("300");
34+
expect(screen.getByTestId("live-gen-panel-tail").textContent)
35+
.toBe("100 200 300");
36+
});
37+
38+
it("respects tail window — only last N tokens rendered", () => {
39+
const many: GenTokenEvent[] = Array.from({ length: 80 },
40+
(_, i) => ({ step: i, token_id: i }));
41+
render(<LiveGenPanel events={many} genInFlight={true} tail={5} />);
42+
// Last 5 tokens: 75..79.
43+
expect(screen.getByTestId("live-gen-panel-tail").textContent)
44+
.toBe("75 76 77 78 79");
45+
});
46+
47+
it("shows finish toast and dismiss button", () => {
48+
const onDismiss = vi.fn();
49+
render(<LiveGenPanel events={EVS} genInFlight={false}
50+
finishToast={true}
51+
onDismissToast={onDismiss} />);
52+
expect(screen.getByTestId("live-gen-panel-toast").textContent)
53+
.toContain("gen done");
54+
expect(screen.getByTestId("live-gen-panel-toast-dismiss"))
55+
.toBeTruthy();
56+
});
57+
58+
it("shows reconnect counter when reconnectAttempts > 0", () => {
59+
render(<LiveGenPanel events={EVS} genInFlight={true}
60+
reconnectAttempts={3} />);
61+
expect(screen.getByTestId("live-gen-panel-reconnects").textContent)
62+
.toContain("3");
63+
});
64+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// V7-H42: useGenerateStream opens /ws/gen/{job_id}, collects events,
2+
// surfaces finish toast, reconnects on unexpected drop.
3+
4+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
5+
import { renderHook, act, waitFor } from "@testing-library/react";
6+
import { useGenerateStream } from "@/hooks/useGenerateStream";
7+
8+
class MockSocket {
9+
static instances: MockSocket[] = [];
10+
static reset(): void { MockSocket.instances = []; }
11+
url: string;
12+
onmessage: ((e: MessageEvent) => void) | null = null;
13+
onclose: ((e: CloseEvent) => void) | null = null;
14+
onerror: ((e: Event) => void) | null = null;
15+
closed = false;
16+
constructor(url: string) {
17+
this.url = url;
18+
MockSocket.instances.push(this);
19+
}
20+
close(): void {
21+
this.closed = true;
22+
this.onclose?.(new CloseEvent("close"));
23+
}
24+
push(frame: unknown): void {
25+
this.onmessage?.(new MessageEvent("message",
26+
{ data: JSON.stringify(frame) }));
27+
}
28+
drop(): void {
29+
// Simulate unexpected disconnect (no finish frame).
30+
this.onclose?.(new CloseEvent("close"));
31+
}
32+
}
33+
34+
describe("V7-H42 useGenerateStream", () => {
35+
beforeEach(() => {
36+
MockSocket.reset();
37+
(globalThis as unknown as { WebSocket: typeof WebSocket })
38+
.WebSocket = MockSocket as unknown as typeof WebSocket;
39+
});
40+
afterEach(() => {
41+
(globalThis as unknown as { WebSocket: typeof WebSocket | null })
42+
.WebSocket = null as unknown as typeof WebSocket;
43+
});
44+
45+
it("opens /ws/gen/{job_id} URL with backend base", async () => {
46+
renderHook(() => useGenerateStream(
47+
"http://127.0.0.1:8765", "job-1", true));
48+
await waitFor(() => {
49+
expect(MockSocket.instances.length).toBe(1);
50+
});
51+
expect(MockSocket.instances[0]?.url)
52+
.toBe("ws://127.0.0.1:8765/ws/gen/job-1");
53+
});
54+
55+
it("does not open socket while inactive", () => {
56+
renderHook(() => useGenerateStream("http://x", "job-2", false));
57+
expect(MockSocket.instances).toEqual([]);
58+
});
59+
60+
it("collects token events and flips finishToast on finish frame",
61+
async () => {
62+
const { result } = renderHook(() => useGenerateStream(
63+
"http://x", "job-3", true));
64+
await waitFor(() => {
65+
expect(MockSocket.instances.length).toBe(1);
66+
});
67+
const sock = MockSocket.instances[0]!;
68+
act(() => {
69+
sock.push({ event: { step: 0, token_id: 42 } });
70+
sock.push({ event: { step: 1, token_id: 99 } });
71+
sock.push({ finish: "ok" });
72+
});
73+
await waitFor(() => {
74+
expect(result.current.finishToast).toBe(true);
75+
});
76+
expect(result.current.events.map((e) => e.token_id))
77+
.toEqual([42, 99]);
78+
expect(sock.closed).toBe(true);
79+
});
80+
81+
it("reset clears events + finishToast + reconnectAttempts",
82+
async () => {
83+
const { result } = renderHook(() => useGenerateStream(
84+
"http://x", "job-4", true));
85+
await waitFor(() => expect(MockSocket.instances.length).toBe(1));
86+
act(() => {
87+
MockSocket.instances[0]!.push({ event: { step: 0, token_id: 1 } });
88+
MockSocket.instances[0]!.push({ finish: "ok" });
89+
});
90+
await waitFor(() => expect(result.current.finishToast).toBe(true));
91+
act(() => result.current.reset());
92+
expect(result.current.events).toEqual([]);
93+
expect(result.current.finishToast).toBe(false);
94+
expect(result.current.reconnectAttempts).toBe(0);
95+
});
96+
97+
it("dismissToast clears the finish toast only", async () => {
98+
const { result } = renderHook(() => useGenerateStream(
99+
"http://x", "job-5", true));
100+
await waitFor(() => expect(MockSocket.instances.length).toBe(1));
101+
act(() => {
102+
MockSocket.instances[0]!.push({ event: { step: 0, token_id: 7 } });
103+
MockSocket.instances[0]!.push({ finish: "ok" });
104+
});
105+
await waitFor(() => expect(result.current.finishToast).toBe(true));
106+
act(() => result.current.dismissToast());
107+
expect(result.current.finishToast).toBe(false);
108+
// Events survive.
109+
expect(result.current.events).toHaveLength(1);
110+
});
111+
});

0 commit comments

Comments
 (0)