@tpitre/story-ui 4.13.0 → 4.13.1

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.
package/dist/cli/index.js CHANGED
File without changes
@@ -1 +1 @@
1
- {"version":3,"file":"VoiceCanvas.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/VoiceCanvas.tsx"],"names":[],"mappings":"AA0BA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAID,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,QAAQ,EACR,KAAK,EACL,MAAM,EACN,OAAO,GACR,EAAE,gBAAgB,2CAipBlB"}
1
+ {"version":3,"file":"VoiceCanvas.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/VoiceCanvas.tsx"],"names":[],"mappings":"AA0BA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAID,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,QAAQ,EACR,KAAK,EACL,MAAM,EACN,OAAO,GACR,EAAE,gBAAgB,2CA0rBlB"}
@@ -34,6 +34,7 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
34
34
  const [statusText, setStatusText] = useState('');
35
35
  const [errorMessage, setErrorMessage] = useState('');
36
36
  const [savedMessage, setSavedMessage] = useState('');
37
+ const [lastPrompt, setLastPrompt] = useState('');
37
38
  // ── Last prompt (used for auto-title on save) ─────────────────
38
39
  const lastPromptRef = useRef('');
39
40
  // ── Voice input ──────────────────────────────────────────────
@@ -52,6 +53,9 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
52
53
  const stopListeningRef = useRef(() => { });
53
54
  const currentCodeRef = useRef(currentCode);
54
55
  currentCodeRef.current = currentCode;
56
+ // Incremented on every new generation to prevent stale finally blocks from
57
+ // clobbering the state of a newer in-flight request.
58
+ const generationCounterRef = useRef(0);
55
59
  // Ref to the preview iframe element
56
60
  const iframeRef = useRef(null);
57
61
  // True after the iframe fires its onLoad event
@@ -75,11 +79,21 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
75
79
  const sendCanvasRequest = useCallback(async (transcript) => {
76
80
  if (abortRef.current)
77
81
  abortRef.current.abort();
82
+ // Stamp this generation so stale finally blocks from aborted requests
83
+ // don't clobber the state of a newer in-flight request.
84
+ const genId = ++generationCounterRef.current;
78
85
  setIsGenerating(true);
79
86
  setStatusText('Thinking...');
80
87
  setErrorMessage('');
81
88
  const controller = new AbortController();
82
89
  abortRef.current = controller;
90
+ // 120-second safety timeout — prevents infinite "Thinking…" when the
91
+ // MCP server accepts the connection but the LLM takes too long.
92
+ let timedOut = false;
93
+ const timeoutId = setTimeout(() => {
94
+ timedOut = true;
95
+ controller.abort();
96
+ }, 120000);
83
97
  try {
84
98
  const currentCode = currentCodeRef.current;
85
99
  const isEdit = currentCode.trim().length > 0;
@@ -115,6 +129,7 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
115
129
  setIframeKey(k => k + 1);
116
130
  }
117
131
  lastPromptRef.current = transcript;
132
+ setLastPrompt(transcript);
118
133
  try {
119
134
  localStorage.setItem(LS_PROMPT_KEY, transcript);
120
135
  }
@@ -130,16 +145,28 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
130
145
  setStatusText('');
131
146
  }
132
147
  catch (error) {
133
- if (error.name === 'AbortError')
148
+ if (error.name === 'AbortError') {
149
+ // Only surface a timeout error if this is still the active generation.
150
+ if (timedOut && generationCounterRef.current === genId) {
151
+ setErrorMessage('Request timed out — the LLM took too long. Please try again.');
152
+ setStatusText('');
153
+ }
134
154
  return;
135
- const msg = error instanceof Error ? error.message : String(error);
136
- setErrorMessage(msg);
137
- setStatusText('');
138
- onError?.(msg);
155
+ }
156
+ if (generationCounterRef.current === genId) {
157
+ const msg = error instanceof Error ? error.message : String(error);
158
+ setErrorMessage(msg);
159
+ setStatusText('');
160
+ onError?.(msg);
161
+ }
139
162
  }
140
163
  finally {
141
- setIsGenerating(false);
142
- abortRef.current = null;
164
+ clearTimeout(timeoutId);
165
+ // Only reset shared state if no newer generation has started since we began.
166
+ if (generationCounterRef.current === genId) {
167
+ setIsGenerating(false);
168
+ abortRef.current = null;
169
+ }
143
170
  }
144
171
  }, [apiBase, provider, model, storyReady, sendCodeToIframe, onError]);
145
172
  // ── Undo ──────────────────────────────────────────────────────
@@ -234,8 +261,13 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
234
261
  clearTimeout(autoSubmitRef.current);
235
262
  autoSubmitRef.current = setTimeout(() => {
236
263
  const prompt = transcript.trim();
237
- if (prompt)
264
+ if (prompt) {
265
+ // Clear the pending transcript BEFORE sending so that stopListening
266
+ // (if pressed moments later) doesn't fire a duplicate request.
267
+ pendingTranscriptRef.current = '';
268
+ setPendingTranscript('');
238
269
  sendCanvasRequest(prompt);
270
+ }
239
271
  autoSubmitRef.current = null;
240
272
  }, 1200);
241
273
  }, [sendCanvasRequest]);
@@ -444,6 +476,7 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
444
476
  const savedPrompt = localStorage.getItem(LS_PROMPT_KEY);
445
477
  if (savedPrompt) {
446
478
  lastPromptRef.current = savedPrompt;
479
+ setLastPrompt(savedPrompt);
447
480
  }
448
481
  }
449
482
  catch { /* localStorage unavailable */ }
@@ -473,5 +506,5 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
473
506
  return (_jsx("div", { className: "sui-canvas-container", children: _jsxs("div", { className: "sui-canvas-unsupported", children: [_jsxs("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" }), _jsx("line", { x1: "2", x2: "22", y1: "2", y2: "22", stroke: "currentColor", strokeWidth: "1.5" })] }), _jsx("h2", { className: "sui-canvas-unsupported-title", children: "Voice not available" }), _jsx("p", { className: "sui-canvas-unsupported-desc", children: "Voice Canvas requires the Web Speech API, which isn't supported in this browser. Try Chrome or Edge for the full experience." })] }) }));
474
507
  }
475
508
  // ── Render ─────────────────────────────────────────────────────
476
- return (_jsxs("div", { className: "sui-canvas-container", children: [_jsxs("div", { className: "sui-canvas-preview", children: [!storyReady && !isGenerating && (_jsxs("div", { className: "sui-canvas-empty", children: [_jsx("div", { className: "sui-canvas-empty-icon", children: _jsxs("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" })] }) }), _jsx("h2", { className: "sui-canvas-empty-title", children: "Voice Canvas" }), _jsx("p", { className: "sui-canvas-empty-desc", children: "Speak to build interfaces live with your design system components." }), _jsx("p", { className: "sui-canvas-empty-hint", children: "Try: \"Create a product card with an image, title, price, and buy button\"" })] })), !storyReady && isGenerating && (_jsxs("div", { className: "sui-canvas-progress", children: [_jsx("div", { className: "sui-canvas-progress-spinner" }), _jsx("span", { className: "sui-canvas-progress-text", children: statusText || 'Building...' })] })), storyReady && (_jsxs("div", { className: "sui-canvas-live-wrapper", children: [isGenerating && (_jsxs("div", { className: "sui-canvas-regen-overlay", children: [_jsx("div", { className: "sui-canvas-progress-spinner sui-canvas-progress-spinner--sm" }), _jsx("span", { children: statusText || 'Regenerating...' })] })), _jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Voice Canvas Preview", className: "sui-canvas-iframe", onLoad: handleIframeLoad }, iframeKey)] })), !isGenerating && errorMessage && (_jsxs("div", { className: "sui-canvas-error", children: [_jsx("span", { children: errorMessage }), _jsx("button", { type: "button", className: "sui-canvas-error-dismiss", onClick: () => setErrorMessage(''), "aria-label": "Dismiss error", children: "\u00D7" })] })), savedMessage && (_jsxs("div", { className: "sui-canvas-saved-toast", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) }), _jsxs("span", { children: ["Saved: ", savedMessage] })] }))] }), statusText && !isGenerating && (_jsx("div", { className: "sui-canvas-status-bar", children: _jsx("span", { className: "sui-canvas-explanation", children: statusText }) })), _jsxs("div", { className: `sui-canvas-bar ${isListening ? 'sui-canvas-bar--active' : ''}`, children: [_jsxs("div", { className: "sui-canvas-bar-left", children: [_jsxs("button", { type: "button", className: `sui-canvas-mic ${isListening ? 'sui-canvas-mic--active' : ''}`, onClick: toggleListening, "aria-label": isListening ? 'Stop voice input' : 'Start voice input', children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" })] }), isListening && _jsx("span", { className: "sui-canvas-mic-pulse" })] }), _jsx("div", { className: "sui-canvas-transcript", children: isGenerating ? (_jsx("span", { className: "sui-canvas-status-rendering", children: statusText || 'Building interface...' })) : interimText ? (_jsxs("span", { className: "sui-canvas-status-interim", children: [pendingTranscript ? pendingTranscript + ' ' : '', interimText] })) : pendingTranscript ? (_jsx("span", { className: "sui-canvas-status-final", children: pendingTranscript })) : isListening ? (_jsx("span", { className: "sui-canvas-status-listening", children: "Listening... describe what you want to build" })) : (_jsx("span", { className: "sui-canvas-status-hint", children: "Click the mic and describe what to build" })) })] }), _jsxs("div", { className: "sui-canvas-bar-right", children: [canUndo && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: undo, title: "Undo (Cmd+Z)", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("polyline", { points: "1 4 1 10 7 10" }), _jsx("path", { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" })] }) })), canRedo && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: redo, title: "Redo (Cmd+Shift+Z)", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("polyline", { points: "23 4 23 10 17 10" }), _jsx("path", { d: "M20.49 15a9 9 0 1 1-2.13-9.36L23 10" })] }) })), hasContent && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: saveStory, title: "Save as story", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" }), _jsx("polyline", { points: "17 21 17 13 7 13 7 21" }), _jsx("polyline", { points: "7 3 7 8 15 8" })] }) })), hasContent && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: clear, title: "Clear canvas", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M3 6h18" }), _jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }), _jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })] }) }))] })] })] }));
509
+ return (_jsxs("div", { className: "sui-canvas-container", children: [_jsxs("div", { className: "sui-canvas-preview", children: [!storyReady && !isGenerating && (_jsxs("div", { className: "sui-canvas-empty", children: [_jsx("div", { className: "sui-canvas-empty-icon", children: _jsxs("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" })] }) }), _jsx("h2", { className: "sui-canvas-empty-title", children: "Voice Canvas" }), _jsx("p", { className: "sui-canvas-empty-desc", children: "Speak to build interfaces live with your design system components." }), _jsx("p", { className: "sui-canvas-empty-hint", children: "Try: \"Create a product card with an image, title, price, and buy button\"" })] })), !storyReady && isGenerating && (_jsxs("div", { className: "sui-canvas-progress", children: [_jsx("div", { className: "sui-canvas-progress-spinner" }), _jsx("span", { className: "sui-canvas-progress-text", children: statusText || 'Building...' })] })), storyReady && (_jsxs("div", { className: "sui-canvas-live-wrapper", children: [isGenerating && (_jsxs("div", { className: "sui-canvas-regen-overlay", children: [_jsx("div", { className: "sui-canvas-progress-spinner sui-canvas-progress-spinner--sm" }), _jsx("span", { children: statusText || 'Regenerating...' })] })), _jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Voice Canvas Preview", className: "sui-canvas-iframe", onLoad: handleIframeLoad }, iframeKey)] })), !isGenerating && errorMessage && (_jsxs("div", { className: "sui-canvas-error", children: [_jsx("span", { children: errorMessage }), _jsx("button", { type: "button", className: "sui-canvas-error-dismiss", onClick: () => setErrorMessage(''), "aria-label": "Dismiss error", children: "\u00D7" })] })), savedMessage && (_jsxs("div", { className: "sui-canvas-saved-toast", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) }), _jsxs("span", { children: ["Saved: ", savedMessage] })] }))] }), statusText && !isGenerating && (_jsx("div", { className: "sui-canvas-status-bar", children: _jsx("span", { className: "sui-canvas-explanation", children: statusText }) })), _jsxs("div", { className: `sui-canvas-bar ${isListening ? 'sui-canvas-bar--active' : ''}`, children: [_jsxs("div", { className: "sui-canvas-bar-left", children: [_jsxs("button", { type: "button", className: `sui-canvas-mic ${isListening ? 'sui-canvas-mic--active' : ''}`, onClick: toggleListening, "aria-label": isListening ? 'Stop voice input' : 'Start voice input', children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" })] }), isListening && _jsx("span", { className: "sui-canvas-mic-pulse" })] }), _jsx("div", { className: "sui-canvas-transcript", children: isGenerating ? (_jsx("span", { className: "sui-canvas-status-rendering", children: statusText || 'Building interface...' })) : interimText ? (_jsxs("span", { className: "sui-canvas-status-interim", children: [pendingTranscript ? pendingTranscript + ' ' : '', interimText] })) : pendingTranscript ? (_jsx("span", { className: "sui-canvas-status-final", children: pendingTranscript })) : isListening ? (_jsx("span", { className: "sui-canvas-status-listening", children: "Listening... describe what you want to build" })) : lastPrompt ? (_jsxs("span", { className: "sui-canvas-status-hint", title: lastPrompt, children: ["\u2713 ", lastPrompt.length > 72 ? lastPrompt.slice(0, 69) + '…' : lastPrompt] })) : (_jsx("span", { className: "sui-canvas-status-hint", children: "Click the mic and describe what to build" })) })] }), _jsxs("div", { className: "sui-canvas-bar-right", children: [canUndo && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: undo, title: "Undo (Cmd+Z)", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("polyline", { points: "1 4 1 10 7 10" }), _jsx("path", { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" })] }) })), canRedo && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: redo, title: "Redo (Cmd+Shift+Z)", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("polyline", { points: "23 4 23 10 17 10" }), _jsx("path", { d: "M20.49 15a9 9 0 1 1-2.13-9.36L23 10" })] }) })), hasContent && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: saveStory, title: "Save as story", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" }), _jsx("polyline", { points: "17 21 17 13 7 13 7 21" }), _jsx("polyline", { points: "7 3 7 8 15 8" })] }) })), hasContent && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: clear, title: "Clear canvas", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M3 6h18" }), _jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }), _jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })] }) }))] })] })] }));
477
510
  }
@@ -57,6 +57,7 @@ export function VoiceCanvas({
57
57
  const [statusText, setStatusText] = useState('');
58
58
  const [errorMessage, setErrorMessage] = useState('');
59
59
  const [savedMessage, setSavedMessage] = useState('');
60
+ const [lastPrompt, setLastPrompt] = useState('');
60
61
 
61
62
  // ── Last prompt (used for auto-title on save) ─────────────────
62
63
  const lastPromptRef = useRef('');
@@ -78,6 +79,9 @@ export function VoiceCanvas({
78
79
  const stopListeningRef = useRef<() => void>(() => {});
79
80
  const currentCodeRef = useRef(currentCode);
80
81
  currentCodeRef.current = currentCode;
82
+ // Incremented on every new generation to prevent stale finally blocks from
83
+ // clobbering the state of a newer in-flight request.
84
+ const generationCounterRef = useRef(0);
81
85
  // Ref to the preview iframe element
82
86
  const iframeRef = useRef<HTMLIFrameElement>(null);
83
87
  // True after the iframe fires its onLoad event
@@ -105,6 +109,10 @@ export function VoiceCanvas({
105
109
  const sendCanvasRequest = useCallback(async (transcript: string) => {
106
110
  if (abortRef.current) abortRef.current.abort();
107
111
 
112
+ // Stamp this generation so stale finally blocks from aborted requests
113
+ // don't clobber the state of a newer in-flight request.
114
+ const genId = ++generationCounterRef.current;
115
+
108
116
  setIsGenerating(true);
109
117
  setStatusText('Thinking...');
110
118
  setErrorMessage('');
@@ -112,6 +120,14 @@ export function VoiceCanvas({
112
120
  const controller = new AbortController();
113
121
  abortRef.current = controller;
114
122
 
123
+ // 120-second safety timeout — prevents infinite "Thinking…" when the
124
+ // MCP server accepts the connection but the LLM takes too long.
125
+ let timedOut = false;
126
+ const timeoutId = setTimeout(() => {
127
+ timedOut = true;
128
+ controller.abort();
129
+ }, 120_000);
130
+
115
131
  try {
116
132
  const currentCode = currentCodeRef.current;
117
133
  const isEdit = currentCode.trim().length > 0;
@@ -155,6 +171,7 @@ export function VoiceCanvas({
155
171
  }
156
172
 
157
173
  lastPromptRef.current = transcript;
174
+ setLastPrompt(transcript);
158
175
  try { localStorage.setItem(LS_PROMPT_KEY, transcript); } catch {}
159
176
  conversationRef.current.push(
160
177
  { role: 'user', content: transcript },
@@ -169,14 +186,27 @@ export function VoiceCanvas({
169
186
 
170
187
  setStatusText('');
171
188
  } catch (error) {
172
- if ((error as Error).name === 'AbortError') return;
173
- const msg = error instanceof Error ? error.message : String(error);
174
- setErrorMessage(msg);
175
- setStatusText('');
176
- onError?.(msg);
189
+ if ((error as Error).name === 'AbortError') {
190
+ // Only surface a timeout error if this is still the active generation.
191
+ if (timedOut && generationCounterRef.current === genId) {
192
+ setErrorMessage('Request timed out — the LLM took too long. Please try again.');
193
+ setStatusText('');
194
+ }
195
+ return;
196
+ }
197
+ if (generationCounterRef.current === genId) {
198
+ const msg = error instanceof Error ? error.message : String(error);
199
+ setErrorMessage(msg);
200
+ setStatusText('');
201
+ onError?.(msg);
202
+ }
177
203
  } finally {
178
- setIsGenerating(false);
179
- abortRef.current = null;
204
+ clearTimeout(timeoutId);
205
+ // Only reset shared state if no newer generation has started since we began.
206
+ if (generationCounterRef.current === genId) {
207
+ setIsGenerating(false);
208
+ abortRef.current = null;
209
+ }
180
210
  }
181
211
  }, [apiBase, provider, model, storyReady, sendCodeToIframe, onError]);
182
212
 
@@ -275,7 +305,13 @@ export function VoiceCanvas({
275
305
  if (autoSubmitRef.current) clearTimeout(autoSubmitRef.current);
276
306
  autoSubmitRef.current = setTimeout(() => {
277
307
  const prompt = transcript.trim();
278
- if (prompt) sendCanvasRequest(prompt);
308
+ if (prompt) {
309
+ // Clear the pending transcript BEFORE sending so that stopListening
310
+ // (if pressed moments later) doesn't fire a duplicate request.
311
+ pendingTranscriptRef.current = '';
312
+ setPendingTranscript('');
313
+ sendCanvasRequest(prompt);
314
+ }
279
315
  autoSubmitRef.current = null;
280
316
  }, 1200);
281
317
  }, [sendCanvasRequest]);
@@ -471,6 +507,7 @@ export function VoiceCanvas({
471
507
  const savedPrompt = localStorage.getItem(LS_PROMPT_KEY);
472
508
  if (savedPrompt) {
473
509
  lastPromptRef.current = savedPrompt;
510
+ setLastPrompt(savedPrompt);
474
511
  }
475
512
  } catch { /* localStorage unavailable */ }
476
513
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -642,6 +679,10 @@ export function VoiceCanvas({
642
679
  <span className="sui-canvas-status-final">{pendingTranscript}</span>
643
680
  ) : isListening ? (
644
681
  <span className="sui-canvas-status-listening">Listening... describe what you want to build</span>
682
+ ) : lastPrompt ? (
683
+ <span className="sui-canvas-status-hint" title={lastPrompt}>
684
+ ✓ {lastPrompt.length > 72 ? lastPrompt.slice(0, 69) + '…' : lastPrompt}
685
+ </span>
645
686
  ) : (
646
687
  <span className="sui-canvas-status-hint">Click the mic and describe what to build</span>
647
688
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "4.13.0",
3
+ "version": "4.13.1",
4
4
  "description": "AI-powered Storybook story generator with dynamic component discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -57,6 +57,7 @@ export function VoiceCanvas({
57
57
  const [statusText, setStatusText] = useState('');
58
58
  const [errorMessage, setErrorMessage] = useState('');
59
59
  const [savedMessage, setSavedMessage] = useState('');
60
+ const [lastPrompt, setLastPrompt] = useState('');
60
61
 
61
62
  // ── Last prompt (used for auto-title on save) ─────────────────
62
63
  const lastPromptRef = useRef('');
@@ -78,6 +79,9 @@ export function VoiceCanvas({
78
79
  const stopListeningRef = useRef<() => void>(() => {});
79
80
  const currentCodeRef = useRef(currentCode);
80
81
  currentCodeRef.current = currentCode;
82
+ // Incremented on every new generation to prevent stale finally blocks from
83
+ // clobbering the state of a newer in-flight request.
84
+ const generationCounterRef = useRef(0);
81
85
  // Ref to the preview iframe element
82
86
  const iframeRef = useRef<HTMLIFrameElement>(null);
83
87
  // True after the iframe fires its onLoad event
@@ -105,6 +109,10 @@ export function VoiceCanvas({
105
109
  const sendCanvasRequest = useCallback(async (transcript: string) => {
106
110
  if (abortRef.current) abortRef.current.abort();
107
111
 
112
+ // Stamp this generation so stale finally blocks from aborted requests
113
+ // don't clobber the state of a newer in-flight request.
114
+ const genId = ++generationCounterRef.current;
115
+
108
116
  setIsGenerating(true);
109
117
  setStatusText('Thinking...');
110
118
  setErrorMessage('');
@@ -112,6 +120,14 @@ export function VoiceCanvas({
112
120
  const controller = new AbortController();
113
121
  abortRef.current = controller;
114
122
 
123
+ // 120-second safety timeout — prevents infinite "Thinking…" when the
124
+ // MCP server accepts the connection but the LLM takes too long.
125
+ let timedOut = false;
126
+ const timeoutId = setTimeout(() => {
127
+ timedOut = true;
128
+ controller.abort();
129
+ }, 120_000);
130
+
115
131
  try {
116
132
  const currentCode = currentCodeRef.current;
117
133
  const isEdit = currentCode.trim().length > 0;
@@ -155,6 +171,7 @@ export function VoiceCanvas({
155
171
  }
156
172
 
157
173
  lastPromptRef.current = transcript;
174
+ setLastPrompt(transcript);
158
175
  try { localStorage.setItem(LS_PROMPT_KEY, transcript); } catch {}
159
176
  conversationRef.current.push(
160
177
  { role: 'user', content: transcript },
@@ -169,14 +186,27 @@ export function VoiceCanvas({
169
186
 
170
187
  setStatusText('');
171
188
  } catch (error) {
172
- if ((error as Error).name === 'AbortError') return;
173
- const msg = error instanceof Error ? error.message : String(error);
174
- setErrorMessage(msg);
175
- setStatusText('');
176
- onError?.(msg);
189
+ if ((error as Error).name === 'AbortError') {
190
+ // Only surface a timeout error if this is still the active generation.
191
+ if (timedOut && generationCounterRef.current === genId) {
192
+ setErrorMessage('Request timed out — the LLM took too long. Please try again.');
193
+ setStatusText('');
194
+ }
195
+ return;
196
+ }
197
+ if (generationCounterRef.current === genId) {
198
+ const msg = error instanceof Error ? error.message : String(error);
199
+ setErrorMessage(msg);
200
+ setStatusText('');
201
+ onError?.(msg);
202
+ }
177
203
  } finally {
178
- setIsGenerating(false);
179
- abortRef.current = null;
204
+ clearTimeout(timeoutId);
205
+ // Only reset shared state if no newer generation has started since we began.
206
+ if (generationCounterRef.current === genId) {
207
+ setIsGenerating(false);
208
+ abortRef.current = null;
209
+ }
180
210
  }
181
211
  }, [apiBase, provider, model, storyReady, sendCodeToIframe, onError]);
182
212
 
@@ -275,7 +305,13 @@ export function VoiceCanvas({
275
305
  if (autoSubmitRef.current) clearTimeout(autoSubmitRef.current);
276
306
  autoSubmitRef.current = setTimeout(() => {
277
307
  const prompt = transcript.trim();
278
- if (prompt) sendCanvasRequest(prompt);
308
+ if (prompt) {
309
+ // Clear the pending transcript BEFORE sending so that stopListening
310
+ // (if pressed moments later) doesn't fire a duplicate request.
311
+ pendingTranscriptRef.current = '';
312
+ setPendingTranscript('');
313
+ sendCanvasRequest(prompt);
314
+ }
279
315
  autoSubmitRef.current = null;
280
316
  }, 1200);
281
317
  }, [sendCanvasRequest]);
@@ -471,6 +507,7 @@ export function VoiceCanvas({
471
507
  const savedPrompt = localStorage.getItem(LS_PROMPT_KEY);
472
508
  if (savedPrompt) {
473
509
  lastPromptRef.current = savedPrompt;
510
+ setLastPrompt(savedPrompt);
474
511
  }
475
512
  } catch { /* localStorage unavailable */ }
476
513
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -642,6 +679,10 @@ export function VoiceCanvas({
642
679
  <span className="sui-canvas-status-final">{pendingTranscript}</span>
643
680
  ) : isListening ? (
644
681
  <span className="sui-canvas-status-listening">Listening... describe what you want to build</span>
682
+ ) : lastPrompt ? (
683
+ <span className="sui-canvas-status-hint" title={lastPrompt}>
684
+ ✓ {lastPrompt.length > 72 ? lastPrompt.slice(0, 69) + '…' : lastPrompt}
685
+ </span>
645
686
  ) : (
646
687
  <span className="sui-canvas-status-hint">Click the mic and describe what to build</span>
647
688
  )}
@@ -1,17 +0,0 @@
1
- /**
2
- * Convert Voice Canvas HTML to Storybook Story (Hybrid Mode)
3
- *
4
- * Generates a hybrid story file with two exports:
5
- * - Reference: iframe-based render of the exact voice canvas HTML (100% visual fidelity, no LLM)
6
- * - Default: LLM-generated component-based implementation using the project's design system
7
- *
8
- * Unlike dumping HTML into the prompt field, this endpoint:
9
- * - Parses the HTML structure and extracts semantic intent
10
- * - Maps inline styles to design system tokens/props
11
- * - Generates component-based code using actual imports
12
- * - Preserves visual fidelity through structural constraints
13
- * - Provides a pixel-perfect reference via iframe for comparison
14
- */
15
- import { Request, Response } from 'express';
16
- export declare function convertToStory(req: Request, res: Response): Promise<void>;
17
- //# sourceMappingURL=convertToStory.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"convertToStory.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/convertToStory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AA2jB5C,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuP/E"}