Skip to content

Commit 428c4b7

Browse files
prosdevclaude
andcommitted
fix(canvas): address code review findings for run panel
- Replace recursive reconnection with iterative loop + .catch() safety net - Fix _handleStreamError type signature (void | Promise<void>) - Add stale-state guards (terminalReceived/reconnecting) after each await - Truncate node output display at 2000 chars to prevent DOM bloat - Reset RunInputDialog state on reopen - Clear ResumeForm input after submit - Update gw-frontend skill with Phase 2 patterns and accurate RunSlice shape Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a3ed340 commit 428c4b7

6 files changed

Lines changed: 129 additions & 77 deletions

File tree

.claude/gw-plans/canvas/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ React 19 + React Flow frontend phases. Depends on execution phases 3-4 for API s
77
| Phase | Plan | Status |
88
|-------|------|--------|
99
| 1 | [Canvas core](phase-1-canvas-core/overview.md) -- Home view, Start/LLM/End nodes, edge wiring, config panel, save/load | Complete |
10-
| 2 | [SSE run panel](phase-2-sse-run-panel/overview.md) -- SSE streaming, run panel, node highlighting, reconnection, resume | Planned |
10+
| 2 | [SSE run panel](phase-2-sse-run-panel/overview.md) -- SSE streaming, run panel, node highlighting, reconnection, resume | In progress |
1111
| 3 | Full node set -- Tool/Condition/HumanInput nodes, settings page | Not started |
1212
| 4 | Validation, run input modal, state panel | Not started |
1313
| 5 | Error handling, run history, debug panel, JSON schema panel | Not started |

.claude/skills/gw-frontend/SKILL.md

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ packages/canvas/src/
5757
├── api/
5858
│ ├── client.ts # base fetch wrapper + ApiError class
5959
│ ├── graphs.ts # graph CRUD
60-
│ └── runs.ts # run start + SSE stream (stub — Phase 2)
60+
│ └── runs.ts # run start/cancel/resume + SSE stream
6161
├── components/
6262
│ ├── canvas/ # GraphCanvas, FloatingToolbar, StampGhost, CanvasHint,
63-
│ │ │ CanvasHeader, SnapConnectionLine, CanvasRoute
63+
│ │ │ CanvasHeader, SnapConnectionLine, CanvasRoute,
64+
│ │ │ RunButton, RunInputDialog
6465
│ │ └── nodes/ # BaseNodeShell, StartNode, LLMNode, EndNode, nodeTypes
6566
│ ├── home/ # HomeView, GraphCard, NewGraphDialog
66-
│ ├── panels/ # NodeConfigPanel
67+
│ ├── panels/ # NodeConfigPanel, RunPanel, RunEventItem, ResumeForm
6768
│ │ └── config/ # StartNodeConfig, LLMNodeConfig, EndNodeConfig
6869
│ └── ui/ # Button, Card, Dialog, DropdownMenu, IconButton,
6970
│ Input, Select, Sheet, Textarea, Toast, Tooltip
@@ -72,7 +73,7 @@ packages/canvas/src/
7273
├── hooks/ # useNodePlacement, useNodeDrop, useBeforeUnload
7374
├── store/
7475
│ ├── graphSlice.ts # graph CRUD, nodes/edges, spliceEdge, save/load
75-
│ ├── runSlice.ts # SSE lifecycle (stub — Phase 2)
76+
│ ├── runSlice.ts # SSE lifecycle, reconnection, start/cancel/resume
7677
│ └── uiSlice.ts # darkMode, panelLayout, lastOpenedGraphId,
7778
│ newGraphDialogOpen, toast (message + variant)
7879
├── styles/ # tokens.ts (color/spacing design tokens)
@@ -89,13 +90,17 @@ interface RunSlice {
8990
runStatus: "idle" | "running" | "paused" | "reconnecting"
9091
| "completed" | "error" | "connection_lost"
9192
activeNodeId: string | null
92-
stateHistory: unknown[]
9393
runOutput: GraphEvent[]
9494
reconnectAttempts: number
95-
startRun: (input: unknown) => Promise<void>
96-
resumeRun: (input: string) => Promise<void>
97-
cancelRun: () => void
98-
handleConnectionLost: () => void
95+
lastEventId: number
96+
finalState: unknown | null
97+
durationMs: number | null
98+
errorMessage: string | null
99+
pausedPrompt: string | null
100+
startRun: (graphId: string, input?: Record<string, unknown>) => Promise<void>
101+
cancelRun: () => Promise<void>
102+
resumeRun: (input: unknown) => Promise<void>
103+
resetRun: () => void
99104
}
100105

101106
// uiSlice.ts — UI preferences only, no credentials
@@ -150,6 +155,16 @@ POST /resume POST /resume
150155
POST /resume call returns. The server has a 2-second timeout — if no SSE
151156
arrives, execution continues anyway (events stored in run history).
152157

158+
## Phase 2 patterns
159+
160+
Patterns from Canvas Phase 2 — follow these in subsequent phases:
161+
162+
- **`NodeMapEntry`**`RunPanel` builds a `Map<string, { label, type, config }>` from graph nodes and passes it to `RunEventItem` for UUID→label resolution
163+
- **Iterative reconnection**`_handleStreamError` uses a for-loop (not recursion) with exponential backoff, wrapped in `.catch()` to guarantee landing in a terminal state
164+
- **`terminalReceived` guard** — module-level flag set before disconnecting on terminal events, prevents `onerror` → reconnection race after `graph_completed`
165+
- **Output truncation**`RunEventItem` caps output display at 2000 chars to prevent DOM bloat from large LLM responses
166+
- **Dialog state reset**`RunInputDialog` resets form state via `useEffect` when `open` transitions to `true`
167+
153168
## Settings panel — read-only, no key input
154169

155170
The settings panel shows provider status only. It never has key input fields.

packages/canvas/src/components/canvas/RunInputDialog.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Button } from "@ui/Button";
22
import { Dialog } from "@ui/Dialog";
3-
import { useState } from "react";
3+
import { useEffect, useState } from "react";
44

55
interface RunInputDialogProps {
66
open: boolean;
@@ -16,6 +16,14 @@ export function RunInputDialog({
1616
const [value, setValue] = useState("{}");
1717
const [parseError, setParseError] = useState<string | null>(null);
1818

19+
// Reset to clean state when dialog opens
20+
useEffect(() => {
21+
if (open) {
22+
setValue("{}");
23+
setParseError(null);
24+
}
25+
}, [open]);
26+
1927
const handleSubmit = () => {
2028
try {
2129
const parsed: unknown = JSON.parse(value);

packages/canvas/src/components/panels/ResumeForm.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ export function ResumeForm({ prompt, onSubmit }: ResumeFormProps) {
2222
/>
2323
<Button
2424
variant="primary"
25-
onClick={() => onSubmit(value)}
25+
onClick={() => {
26+
onSubmit(value);
27+
setValue("");
28+
}}
2629
disabled={!value.trim()}
2730
>
2831
Resume

packages/canvas/src/components/panels/RunEventItem.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
} from "lucide-react";
1212
import { formatDuration, formatTime } from "../../utils/format";
1313

14+
const MAX_OUTPUT_LENGTH = 2000;
15+
1416
export interface NodeMapEntry {
1517
label: string;
1618
type: string;
@@ -69,11 +71,15 @@ export function RunEventItem({
6971
const label = resolveLabel(event.data.node_id, nodeMap);
7072
const entry = nodeMap?.get(event.data.node_id);
7173
const output = event.data.output as Record<string, unknown> | null;
72-
const outputText = output
74+
const rawOutput = output
7375
? Object.values(output)
7476
.map((v) => (typeof v === "string" ? v : JSON.stringify(v)))
7577
.join("\n")
7678
: null;
79+
const outputTruncated = rawOutput && rawOutput.length > MAX_OUTPUT_LENGTH;
80+
const outputText = outputTruncated
81+
? `${rawOutput.slice(0, MAX_OUTPUT_LENGTH)}…`
82+
: rawOutput;
7783

7884
const showProviderModel: boolean =
7985
entry?.type === "llm" &&

packages/canvas/src/store/runSlice.ts

Lines changed: 84 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface RunSlice {
4343
/** @internal — called by SSE event handlers */
4444
_handleEvent: (event: GraphEvent, eventId: number | null) => void;
4545
/** @internal — called on SSE connection error */
46-
_handleStreamError: (error: Error) => void;
46+
_handleStreamError: (error: Error) => void | Promise<void>;
4747
/** @internal — close the current SSE connection */
4848
_disconnect: () => void;
4949
}
@@ -228,7 +228,7 @@ export const useRunStore = create<RunSlice>((set) => ({
228228
});
229229
},
230230

231-
_handleStreamError: async (_error) => {
231+
_handleStreamError: (_error) => {
232232
if (terminalReceived) return;
233233
if (reconnecting) return;
234234

@@ -239,75 +239,95 @@ export const useRunStore = create<RunSlice>((set) => ({
239239
}
240240
if (!state.activeRunId) return;
241241

242-
const attempt = state.reconnectAttempts + 1;
243-
if (attempt > MAX_RECONNECT_ATTEMPTS) {
242+
const runId = state.activeRunId;
243+
reconnecting = true;
244+
245+
// Iterative reconnection loop with exponential backoff
246+
(async () => {
247+
for (
248+
let attempt = state.reconnectAttempts + 1;
249+
attempt <= MAX_RECONNECT_ATTEMPTS;
250+
attempt++
251+
) {
252+
// Bail if the run was cancelled/reset while we were waiting
253+
if (terminalReceived || !reconnecting) return;
254+
255+
set({ runStatus: "reconnecting", reconnectAttempts: attempt });
256+
257+
// Exponential backoff: 1s, 2s, 4s
258+
await sleep(1000 * 2 ** (attempt - 1));
259+
260+
// Re-check after sleep — run may have been cancelled/reset
261+
if (terminalReceived || !reconnecting) return;
262+
263+
try {
264+
const status = await getRunStatus(runId);
265+
266+
// Run was cancelled/reset during the fetch
267+
if (terminalReceived || !reconnecting) return;
268+
269+
switch (status.status) {
270+
case "completed":
271+
reconnecting = false;
272+
set({
273+
runStatus: "completed",
274+
finalState: status.final_state,
275+
durationMs: status.duration_ms,
276+
activeNodeId: null,
277+
});
278+
return;
279+
280+
case "running": {
281+
reconnecting = false;
282+
const { _handleEvent, _handleStreamError, lastEventId } =
283+
useRunStore.getState();
284+
cleanup = connectStream(
285+
runId,
286+
{ onEvent: _handleEvent, onError: _handleStreamError },
287+
lastEventId,
288+
);
289+
set({ runStatus: "running", reconnectAttempts: 0 });
290+
return;
291+
}
292+
293+
case "paused":
294+
reconnecting = false;
295+
set({
296+
runStatus: "paused",
297+
activeNodeId: status.node_id,
298+
pausedPrompt: status.prompt,
299+
});
300+
return;
301+
302+
case "error":
303+
reconnecting = false;
304+
set({
305+
runStatus: "error",
306+
errorMessage: status.error ?? "Run failed on server",
307+
activeNodeId: null,
308+
});
309+
return;
310+
}
311+
} catch {
312+
// Status check failed — continue to next attempt
313+
}
314+
}
315+
316+
// All attempts exhausted
244317
reconnecting = false;
245318
set({
246319
runStatus: "connection_lost",
247320
errorMessage: "Connection lost after 3 attempts",
248321
});
249322
showToast("Connection lost — run may still be executing on the server");
250-
return;
251-
}
252-
253-
reconnecting = true;
254-
set({ runStatus: "reconnecting", reconnectAttempts: attempt });
255-
256-
// Exponential backoff: 1s, 2s, 4s
257-
await sleep(1000 * 2 ** (attempt - 1));
258-
259-
try {
260-
const status = await getRunStatus(state.activeRunId);
261-
262-
switch (status.status) {
263-
case "completed":
264-
reconnecting = false;
265-
set({
266-
runStatus: "completed",
267-
finalState: status.final_state,
268-
durationMs: status.duration_ms,
269-
activeNodeId: null,
270-
});
271-
break;
272-
273-
case "running": {
274-
reconnecting = false;
275-
const { _handleEvent, _handleStreamError, lastEventId } =
276-
useRunStore.getState();
277-
cleanup = connectStream(
278-
state.activeRunId,
279-
{ onEvent: _handleEvent, onError: _handleStreamError },
280-
lastEventId,
281-
);
282-
set({ runStatus: "running", reconnectAttempts: 0 });
283-
break;
284-
}
285-
286-
case "paused":
287-
reconnecting = false;
288-
set({
289-
runStatus: "paused",
290-
activeNodeId: status.node_id,
291-
pausedPrompt: status.prompt,
292-
});
293-
break;
294-
295-
case "error":
296-
reconnecting = false;
297-
set({
298-
runStatus: "error",
299-
errorMessage: status.error ?? "Run failed on server",
300-
activeNodeId: null,
301-
});
302-
break;
303-
}
304-
} catch {
305-
// Status check failed — retry
323+
})().catch(() => {
324+
// Safety net — ensure we always land in a terminal state
306325
reconnecting = false;
307-
useRunStore
308-
.getState()
309-
._handleStreamError(new Error("Status check failed"));
310-
}
326+
set({
327+
runStatus: "connection_lost",
328+
errorMessage: "Connection lost unexpectedly",
329+
});
330+
});
311331
},
312332

313333
_disconnect: () => {

0 commit comments

Comments
 (0)