techfreakworm commited on
Commit
afe44d6
·
unverified ·
1 Parent(s): 3dd591f

feat(web): progress subscriber + useProgress hook

Browse files
web/src/lib/progress.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type ProgressState =
2
+ | { phase: "idle" }
3
+ | {
4
+ phase: "running";
5
+ kind: "single" | "dialog";
6
+ turn: number;
7
+ total: number;
8
+ elapsedS: number;
9
+ }
10
+ | { phase: "done"; elapsedS: number }
11
+ | { phase: "error"; message: string };
12
+
13
+ type ProgressEvent = {
14
+ type: "start" | "tick" | "turn_complete" | "done" | "error";
15
+ elapsed_s: number;
16
+ kind?: "single" | "dialog";
17
+ turn?: number;
18
+ total_turns?: number;
19
+ message?: string;
20
+ seed_used?: number | null;
21
+ };
22
+
23
+ export function subscribeProgress(
24
+ onState: (s: ProgressState) => void,
25
+ ): () => void {
26
+ const es = new EventSource("/api/progress");
27
+ let doneTimer: number | null = null;
28
+
29
+ es.onmessage = (m: MessageEvent) => {
30
+ if (doneTimer !== null) {
31
+ window.clearTimeout(doneTimer);
32
+ doneTimer = null;
33
+ }
34
+ let evt: ProgressEvent;
35
+ try {
36
+ evt = JSON.parse(m.data) as ProgressEvent;
37
+ } catch {
38
+ return;
39
+ }
40
+ if (evt.type === "start" || evt.type === "tick" || evt.type === "turn_complete") {
41
+ onState({
42
+ phase: "running",
43
+ kind: (evt.kind ?? "single"),
44
+ turn: evt.turn ?? 0,
45
+ total: evt.total_turns ?? 1,
46
+ elapsedS: evt.elapsed_s ?? 0,
47
+ });
48
+ return;
49
+ }
50
+ if (evt.type === "done") {
51
+ onState({ phase: "done", elapsedS: evt.elapsed_s });
52
+ doneTimer = window.setTimeout(() => onState({ phase: "idle" }), 1000);
53
+ return;
54
+ }
55
+ if (evt.type === "error") {
56
+ onState({ phase: "error", message: evt.message ?? "Generation failed" });
57
+ }
58
+ };
59
+
60
+ return () => {
61
+ if (doneTimer !== null) window.clearTimeout(doneTimer);
62
+ es.close();
63
+ };
64
+ }
65
+
66
+ import { useEffect, useState } from "react";
67
+
68
+ export function useProgress(): ProgressState {
69
+ const [state, setState] = useState<ProgressState>({ phase: "idle" });
70
+ useEffect(() => {
71
+ const close = subscribeProgress(setState);
72
+ return close;
73
+ }, []);
74
+ return state;
75
+ }
web/src/test/progress.test.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { subscribeProgress, type ProgressState } from "@/lib/progress";
3
+
4
+ class MockEventSource {
5
+ url: string;
6
+ onmessage: ((m: { data: string }) => void) | null = null;
7
+ closed = false;
8
+ static last: MockEventSource;
9
+ constructor(url: string) {
10
+ this.url = url;
11
+ MockEventSource.last = this;
12
+ }
13
+ emit(data: object) {
14
+ this.onmessage?.({ data: JSON.stringify(data) });
15
+ }
16
+ close() {
17
+ this.closed = true;
18
+ }
19
+ }
20
+
21
+ beforeEach(() => {
22
+ vi.stubGlobal("EventSource", MockEventSource);
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ });
28
+
29
+ describe("subscribeProgress", () => {
30
+ it("emits running on start", () => {
31
+ const states: ProgressState[] = [];
32
+ subscribeProgress((s) => states.push(s));
33
+ MockEventSource.last.emit({
34
+ type: "start", elapsed_s: 0, kind: "dialog", total_turns: 3, turn: 0,
35
+ });
36
+ expect(states[0]).toMatchObject({ phase: "running", kind: "dialog", total: 3 });
37
+ });
38
+
39
+ it("updates turn on turn_complete", () => {
40
+ const states: ProgressState[] = [];
41
+ subscribeProgress((s) => states.push(s));
42
+ MockEventSource.last.emit({
43
+ type: "start", elapsed_s: 0, kind: "dialog", total_turns: 3, turn: 0,
44
+ });
45
+ MockEventSource.last.emit({
46
+ type: "turn_complete", elapsed_s: 1.2, kind: "dialog", total_turns: 3, turn: 2,
47
+ });
48
+ const last = states[states.length - 1];
49
+ expect(last).toMatchObject({ phase: "running", turn: 2, total: 3 });
50
+ });
51
+
52
+ it("transitions to done then idle", async () => {
53
+ vi.useFakeTimers();
54
+ const states: ProgressState[] = [];
55
+ subscribeProgress((s) => states.push(s));
56
+ MockEventSource.last.emit({ type: "done", elapsed_s: 4.5 });
57
+ expect(states[states.length - 1]).toMatchObject({ phase: "done", elapsedS: 4.5 });
58
+ vi.advanceTimersByTime(1100);
59
+ expect(states[states.length - 1]).toMatchObject({ phase: "idle" });
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ it("emits error", () => {
64
+ const states: ProgressState[] = [];
65
+ subscribeProgress((s) => states.push(s));
66
+ MockEventSource.last.emit({
67
+ type: "error", elapsed_s: 2, message: "boom",
68
+ });
69
+ expect(states[states.length - 1]).toMatchObject({ phase: "error", message: "boom" });
70
+ });
71
+
72
+ it("close() shuts down EventSource", () => {
73
+ const close = subscribeProgress(() => {});
74
+ close();
75
+ expect(MockEventSource.last.closed).toBe(true);
76
+ });
77
+ });