@tpitre/story-ui 4.13.3 → 4.15.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.
@@ -1 +1 @@
1
- {"version":3,"file":"canvasGenerate.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasGenerate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAW5C,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAmIvE,wBAAgB,eAAe,IAAI,IAAI,CAuBtC;AAID;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAU/D;AAuBD,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CAwEtE"}
1
+ {"version":3,"file":"canvasGenerate.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasGenerate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAW5C,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAmIvE,wBAAgB,eAAe,IAAI,IAAI,CAuBtC;AAID;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAU/D;AAyCD,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CAwEtE"}
@@ -192,21 +192,37 @@ export function ensureVoiceCanvasStory(storiesDir) {
192
192
  }
193
193
  }
194
194
  // ── Code extraction ───────────────────────────────────────────
195
+ /**
196
+ * If the LLM forgot to add a render() call (required by react-live noInline mode),
197
+ * detect the last defined PascalCase component and append render(<ComponentName />).
198
+ * This prevents the "No-Inline evaluations must call render" error when voice input
199
+ * is ambiguous or short and the LLM skips the final line.
200
+ */
201
+ function ensureRenderCall(code) {
202
+ if (/\brender\s*\(/.test(code))
203
+ return code;
204
+ // Find the last PascalCase component/const defined in the code
205
+ const matches = [...code.matchAll(/(?:const|function)\s+([A-Z][A-Za-z0-9]*)/g)];
206
+ const componentName = matches.at(-1)?.[1] ?? 'Canvas';
207
+ return `${code}\nrender(<${componentName} />);`;
208
+ }
195
209
  /**
196
210
  * Extract the canvas component code from the LLM response.
197
211
  * Handles markdown code fences and stray text.
198
212
  */
199
213
  function extractCanvasCode(response) {
214
+ let code;
200
215
  // Prefer explicit code fence
201
216
  const fenceMatch = response.match(/```(?:jsx|tsx|js|ts)?\n([\s\S]+?)\n```/);
202
- if (fenceMatch)
203
- return fenceMatch[1].trim();
204
- // Fall back: find the Canvas component block
205
- const canvasMatch = response.match(/(const Canvas\s*=[\s\S]+?render\s*\(<Canvas\s*\/>?\);?\s*$)/m);
206
- if (canvasMatch)
207
- return canvasMatch[1].trim();
208
- // Last resort: return the whole response trimmed
209
- return response.trim();
217
+ if (fenceMatch) {
218
+ code = fenceMatch[1].trim();
219
+ }
220
+ else {
221
+ // Fall back: find the Canvas component block
222
+ const canvasMatch = response.match(/(const Canvas\s*=[\s\S]+?render\s*\(<Canvas\s*\/>?\);?\s*$)/m);
223
+ code = canvasMatch ? canvasMatch[1].trim() : response.trim();
224
+ }
225
+ return ensureRenderCall(code);
210
226
  }
211
227
  // ── Handler ───────────────────────────────────────────────────
212
228
  export async function canvasGenerateHandler(req, res) {
@@ -1 +1 @@
1
- {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,oBAAoB,CAAC;AAskC5B,UAAU,iBAAiB;IACzB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED,iBAAS,YAAY,CAAC,EAAE,OAAO,EAAE,EAAE,iBAAiB,2CA03CnD;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,oBAAoB,CAAC;AAskC5B,UAAU,iBAAiB;IACzB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED,iBAAS,YAAY,CAAC,EAAE,OAAO,EAAE,EAAE,iBAAiB,2CAm4CnD;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
@@ -821,6 +821,7 @@ function StoryUIPanel({ mcpPort }) {
821
821
  const panelGeneratedStoryIds = useRef(new Set());
822
822
  const voiceModeActiveRef = useRef(false);
823
823
  const canvasModeRef = useRef(panelMode === 'canvas');
824
+ const voiceCanvasRef = useRef(null);
824
825
  const knownStoryIds = useRef(new Set());
825
826
  const isPollingInitialized = useRef(false);
826
827
  // Set port override if provided
@@ -1545,7 +1546,14 @@ function StoryUIPanel({ mcpPort }) {
1545
1546
  dispatch({ type: 'SET_CONVERSATION', payload: chat.conversation });
1546
1547
  dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
1547
1548
  };
1548
- const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
1549
+ const handleNewChat = () => {
1550
+ dispatch({ type: 'NEW_CHAT' });
1551
+ // When on Voice Canvas, also clear the canvas state (abort generation,
1552
+ // reset code, blank the iframe, clear conversation history)
1553
+ if (panelMode === 'canvas') {
1554
+ voiceCanvasRef.current?.clear();
1555
+ }
1556
+ };
1549
1557
  // Voice input handlers
1550
1558
  const handleVoiceTranscript = useCallback((text) => {
1551
1559
  // Append transcript to current input (user may be speaking in segments)
@@ -1779,7 +1787,7 @@ function StoryUIPanel({ mcpPort }) {
1779
1787
  // ============================================
1780
1788
  // Render
1781
1789
  // ============================================
1782
- return (_jsxs("div", { className: `sui-root ${state.isDarkMode ? 'dark' : ''}`, children: [_jsxs("aside", { className: `sui-sidebar ${state.sidebarOpen ? '' : 'collapsed'}`, "aria-label": "Chat history", children: [state.sidebarOpen && (_jsxs("div", { className: "sui-sidebar-content", children: [_jsxs("button", { className: "sui-button sui-button-ghost", onClick: () => dispatch({ type: 'TOGGLE_SIDEBAR' }), style: { width: '100%', marginBottom: '12px', justifyContent: 'flex-start' }, children: [Icons.panelLeft, _jsx("span", { style: { marginLeft: '8px' }, children: "Hide sidebar" })] }), _jsxs("button", { className: "sui-button sui-button-default", onClick: handleNewChat, style: { width: '100%', marginBottom: '16px' }, children: [Icons.plus, _jsx("span", { children: "New Chat" })] }), _jsx("div", { className: "sui-sidebar-chats", children: [...state.recentChats].sort((a, b) => {
1790
+ return (_jsxs("div", { className: `sui-root ${state.isDarkMode ? 'dark' : ''}`, children: [_jsxs("aside", { className: `sui-sidebar ${state.sidebarOpen ? '' : 'collapsed'}`, "aria-label": "Chat history", children: [state.sidebarOpen && (_jsxs("div", { className: "sui-sidebar-content", children: [_jsxs("button", { className: "sui-button sui-button-ghost", onClick: () => dispatch({ type: 'TOGGLE_SIDEBAR' }), style: { width: '100%', marginBottom: '12px', justifyContent: 'flex-start' }, children: [Icons.panelLeft, _jsx("span", { style: { marginLeft: '8px' }, children: "Hide sidebar" })] }), _jsxs("button", { className: "sui-button sui-button-default", onClick: handleNewChat, style: { width: '100%', marginBottom: '16px' }, children: [Icons.plus, _jsx("span", { children: panelMode === 'canvas' ? 'New Canvas' : 'New Chat' })] }), _jsx("div", { className: "sui-sidebar-chats", children: [...state.recentChats].sort((a, b) => {
1783
1791
  // Match Storybook sidebar order (from /index.json); alphabetical fallback
1784
1792
  const posA = storybookOrder.get(a.title.toLowerCase()) ?? Infinity;
1785
1793
  const posB = storybookOrder.get(b.title.toLowerCase()) ?? Infinity;
@@ -1807,7 +1815,7 @@ function StoryUIPanel({ mcpPort }) {
1807
1815
  const enabled = e.target.checked;
1808
1816
  dispatch({ type: 'SET_USE_STORYBOOK_MCP', payload: enabled });
1809
1817
  saveStorybookMcpPref(enabled);
1810
- }, "aria-label": "Use Storybook MCP context" }), _jsx("span", { className: "sui-toggle-slider" })] })] }) }))] })] }), panelMode === 'canvas' ? (_jsx(VoiceCanvas, { apiBase: getApiBase(), provider: state.selectedProvider, model: state.selectedModel, onSave: (result) => {
1818
+ }, "aria-label": "Use Storybook MCP context" }), _jsx("span", { className: "sui-toggle-slider" })] })] }) }))] })] }), panelMode === 'canvas' ? (_jsx(VoiceCanvas, { ref: voiceCanvasRef, apiBase: getApiBase(), provider: state.selectedProvider, model: state.selectedModel, onSave: (result) => {
1811
1819
  // Track the saved story — use fileName stem as chatId (consistent with manifest entry IDs)
1812
1820
  const chatId = result.fileName.replace(/\.stories\.[a-z]+$/, '') || Date.now().toString();
1813
1821
  const newSession = {
@@ -9,7 +9,7 @@
9
9
  import React, { useState, useEffect, useRef, useCallback, useReducer } from 'react';
10
10
  import './StoryUIPanel.css';
11
11
  import { VoiceControls } from './voice/VoiceControls.js';
12
- import { VoiceCanvas } from './voice/VoiceCanvas.js';
12
+ import { VoiceCanvas, type VoiceCanvasHandle } from './voice/VoiceCanvas.js';
13
13
  import type { VoiceCommand } from './voice/types.js';
14
14
 
15
15
  // ============================================
@@ -1182,6 +1182,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
1182
1182
  const panelGeneratedStoryIds = useRef<Set<string>>(new Set());
1183
1183
  const voiceModeActiveRef = useRef(false);
1184
1184
  const canvasModeRef = useRef(panelMode === 'canvas');
1185
+ const voiceCanvasRef = useRef<VoiceCanvasHandle>(null);
1185
1186
  const knownStoryIds = useRef<Set<string>>(new Set());
1186
1187
  const isPollingInitialized = useRef(false);
1187
1188
 
@@ -1913,7 +1914,14 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
1913
1914
  dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
1914
1915
  };
1915
1916
 
1916
- const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
1917
+ const handleNewChat = () => {
1918
+ dispatch({ type: 'NEW_CHAT' });
1919
+ // When on Voice Canvas, also clear the canvas state (abort generation,
1920
+ // reset code, blank the iframe, clear conversation history)
1921
+ if (panelMode === 'canvas') {
1922
+ voiceCanvasRef.current?.clear();
1923
+ }
1924
+ };
1917
1925
 
1918
1926
  // Voice input handlers
1919
1927
  const handleVoiceTranscript = useCallback((text: string) => {
@@ -2159,7 +2167,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
2159
2167
  {/* New Chat */}
2160
2168
  <button className="sui-button sui-button-default" onClick={handleNewChat} style={{ width: '100%', marginBottom: '16px' }}>
2161
2169
  {Icons.plus}
2162
- <span>New Chat</span>
2170
+ <span>{panelMode === 'canvas' ? 'New Canvas' : 'New Chat'}</span>
2163
2171
  </button>
2164
2172
 
2165
2173
  {/* Chat history */}
@@ -2368,6 +2376,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
2368
2376
 
2369
2377
  {panelMode === 'canvas' ? (
2370
2378
  <VoiceCanvas
2379
+ ref={voiceCanvasRef}
2371
2380
  apiBase={getApiBase()}
2372
2381
  provider={state.selectedProvider}
2373
2382
  model={state.selectedModel}
@@ -1,3 +1,19 @@
1
+ /**
2
+ * VoiceCanvas v5 — Storybook iframe + postMessage
3
+ *
4
+ * Architecture:
5
+ * - LLM generates JSX code string
6
+ * - Server writes a STATIC react-live story template ONCE on first use
7
+ * (voice-canvas.stories.tsx never changes after creation — no HMR cascade)
8
+ * - Preview renders in a Storybook iframe (full decorator chain = correct Mantine theme)
9
+ * - Code updates on generate / undo / redo are delivered via:
10
+ * 1. localStorage (persists across iframe reloads)
11
+ * 2. window.postMessage (instant in-place update, no iframe reload needed)
12
+ *
13
+ * This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
14
+ * StoryUIPanel is never accidentally reset.
15
+ */
16
+ import React from 'react';
1
17
  export interface VoiceCanvasProps {
2
18
  apiBase: string;
3
19
  provider?: string;
@@ -11,5 +27,10 @@ export interface VoiceCanvasProps {
11
27
  }) => void;
12
28
  onError?: (error: string) => void;
13
29
  }
14
- export declare function VoiceCanvas({ apiBase, provider, model, onSave, onError, }: VoiceCanvasProps): import("react/jsx-runtime").JSX.Element;
30
+ /** Imperative handle exposed to parent via ref used by "New Chat" button */
31
+ export interface VoiceCanvasHandle {
32
+ /** Clear the canvas: abort generation, reset all state, blank the iframe */
33
+ clear: () => void;
34
+ }
35
+ export declare const VoiceCanvas: React.ForwardRefExoticComponent<VoiceCanvasProps & React.RefAttributes<VoiceCanvasHandle>>;
15
36
  //# sourceMappingURL=VoiceCanvas.d.ts.map
@@ -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,2CA0rBlB"}
1
+ {"version":3,"file":"VoiceCanvas.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/VoiceCanvas.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAwE,MAAM,OAAO,CAAC;AAY7F,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;AAED,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB;IAChC,4EAA4E;IAC5E,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAID,eAAO,MAAM,WAAW,4FAytBtB,CAAC"}
@@ -14,14 +14,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  * This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
15
15
  * StoryUIPanel is never accidentally reset.
16
16
  */
17
- import { useCallback, useEffect, useRef, useState } from 'react';
17
+ import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
18
+ import { parseVoiceCommand } from './voiceCommands.js';
18
19
  // ── Constants ─────────────────────────────────────────────────
19
20
  const STORY_ID = 'generated-voice-canvas--default';
20
21
  const LS_KEY = '__voice_canvas_code__';
21
22
  const LS_PROMPT_KEY = '__voice_canvas_prompt__';
22
23
  const IFRAME_ORIGIN = window.location.origin;
23
24
  // ── Component ─────────────────────────────────────────────────
24
- export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
25
+ export const VoiceCanvas = React.forwardRef(function VoiceCanvas({ apiBase, provider, model, onSave, onError, }, ref) {
25
26
  // ── Code + history ───────────────────────────────────────────
26
27
  const [currentCode, setCurrentCode] = useState('');
27
28
  const [undoStack, setUndoStack] = useState([]);
@@ -194,6 +195,12 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
194
195
  const canRedo = redoStack.length > 0;
195
196
  // ── Clear ─────────────────────────────────────────────────────
196
197
  const clear = useCallback(() => {
198
+ // Abort any in-flight generation so it doesn't land after the reset
199
+ if (abortRef.current) {
200
+ abortRef.current.abort();
201
+ abortRef.current = null;
202
+ }
203
+ generationCounterRef.current += 1; // invalidate any pending finally-block
197
204
  const current = currentCodeRef.current;
198
205
  if (current.trim()) {
199
206
  setUndoStack(prev => [...prev.slice(-19), current]);
@@ -204,8 +211,12 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
204
211
  iframeLoadedRef.current = false;
205
212
  conversationRef.current = [];
206
213
  setErrorMessage('');
214
+ setIsGenerating(false);
215
+ setStatusText('');
207
216
  setPendingTranscript('');
208
217
  pendingTranscriptRef.current = '';
218
+ setLastPrompt('');
219
+ lastPromptRef.current = '';
209
220
  try {
210
221
  localStorage.removeItem(LS_KEY);
211
222
  }
@@ -214,6 +225,8 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
214
225
  localStorage.removeItem(LS_PROMPT_KEY);
215
226
  }
216
227
  catch { }
228
+ // Force the iframe to remount — it will read empty localStorage and show the placeholder
229
+ setIframeKey(k => k + 1);
217
230
  }, []);
218
231
  // ── Save ───────────────────────────────────────────────────────
219
232
  // No dialog — saves immediately using the last voice/text prompt as the title.
@@ -305,30 +318,43 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
305
318
  pendingTranscriptRef.current = accumulated;
306
319
  setPendingTranscript(accumulated);
307
320
  setInterimText('');
308
- const trimmed = final.trim().toLowerCase();
309
- if (trimmed.split(/\s+/).length <= 3) {
310
- if (trimmed === 'clear' || trimmed === 'start over') {
321
+ const command = parseVoiceCommand(final);
322
+ if (command) {
323
+ if (command.type === 'clear') {
311
324
  clear();
312
325
  pendingTranscriptRef.current = '';
313
326
  setPendingTranscript('');
314
327
  return;
315
328
  }
316
- if (trimmed === 'undo') {
329
+ if (command.type === 'undo') {
317
330
  undo();
318
331
  pendingTranscriptRef.current = '';
319
332
  setPendingTranscript('');
320
333
  return;
321
334
  }
322
- if (trimmed === 'redo') {
335
+ if (command.type === 'redo') {
323
336
  redo();
324
337
  pendingTranscriptRef.current = '';
325
338
  setPendingTranscript('');
326
339
  return;
327
340
  }
328
- if (trimmed === 'stop' || trimmed === 'stop listening') {
341
+ if (command.type === 'stop') {
329
342
  stopListeningRef.current();
330
343
  return;
331
344
  }
345
+ if (command.type === 'save') {
346
+ saveStory();
347
+ pendingTranscriptRef.current = '';
348
+ setPendingTranscript('');
349
+ return;
350
+ }
351
+ if (command.type === 'new-chat') {
352
+ clear();
353
+ pendingTranscriptRef.current = '';
354
+ setPendingTranscript('');
355
+ return;
356
+ }
357
+ // 'submit' falls through to schedule an LLM generation below
332
358
  }
333
359
  if (abortRef.current)
334
360
  abortRef.current.abort();
@@ -406,7 +432,7 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
406
432
  isListeningRef.current = false;
407
433
  setIsListening(false);
408
434
  }
409
- }, [clear, undo, redo, scheduleIntent]);
435
+ }, [clear, undo, redo, scheduleIntent, saveStory]);
410
436
  // ── Voice: stop ────────────────────────────────────────────────
411
437
  const stopListening = useCallback(() => {
412
438
  isListeningRef.current = false;
@@ -505,6 +531,8 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
505
531
  if (!speechSupported) {
506
532
  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." })] }) }));
507
533
  }
534
+ // ── Imperative handle (for parent "New Canvas" button) ──────────
535
+ useImperativeHandle(ref, () => ({ clear }), [clear]);
508
536
  // ── Render ─────────────────────────────────────────────────────
509
537
  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" })] }) }))] })] })] }));
510
- }
538
+ });
@@ -13,7 +13,8 @@
13
13
  * This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
14
14
  * StoryUIPanel is never accidentally reset.
15
15
  */
16
- import React, { useCallback, useEffect, useRef, useState } from 'react';
16
+ import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
17
+ import { parseVoiceCommand } from './voiceCommands.js';
17
18
 
18
19
  // ── Constants ─────────────────────────────────────────────────
19
20
 
@@ -34,15 +35,22 @@ export interface VoiceCanvasProps {
34
35
  onError?: (error: string) => void;
35
36
  }
36
37
 
38
+ /** Imperative handle exposed to parent via ref — used by "New Chat" button */
39
+ export interface VoiceCanvasHandle {
40
+ /** Clear the canvas: abort generation, reset all state, blank the iframe */
41
+ clear: () => void;
42
+ }
43
+
37
44
  // ── Component ─────────────────────────────────────────────────
38
45
 
39
- export function VoiceCanvas({
46
+ export const VoiceCanvas = React.forwardRef<VoiceCanvasHandle, VoiceCanvasProps>(
47
+ function VoiceCanvas({
40
48
  apiBase,
41
49
  provider,
42
50
  model,
43
51
  onSave,
44
52
  onError,
45
- }: VoiceCanvasProps) {
53
+ }: VoiceCanvasProps, ref) {
46
54
  // ── Code + history ───────────────────────────────────────────
47
55
  const [currentCode, setCurrentCode] = useState('');
48
56
  const [undoStack, setUndoStack] = useState<string[]>([]);
@@ -239,6 +247,13 @@ export function VoiceCanvas({
239
247
  // ── Clear ─────────────────────────────────────────────────────
240
248
 
241
249
  const clear = useCallback(() => {
250
+ // Abort any in-flight generation so it doesn't land after the reset
251
+ if (abortRef.current) {
252
+ abortRef.current.abort();
253
+ abortRef.current = null;
254
+ }
255
+ generationCounterRef.current += 1; // invalidate any pending finally-block
256
+
242
257
  const current = currentCodeRef.current;
243
258
  if (current.trim()) {
244
259
  setUndoStack(prev => [...prev.slice(-19), current]);
@@ -249,10 +264,16 @@ export function VoiceCanvas({
249
264
  iframeLoadedRef.current = false;
250
265
  conversationRef.current = [];
251
266
  setErrorMessage('');
267
+ setIsGenerating(false);
268
+ setStatusText('');
252
269
  setPendingTranscript('');
253
270
  pendingTranscriptRef.current = '';
271
+ setLastPrompt('');
272
+ lastPromptRef.current = '';
254
273
  try { localStorage.removeItem(LS_KEY); } catch {}
255
274
  try { localStorage.removeItem(LS_PROMPT_KEY); } catch {}
275
+ // Force the iframe to remount — it will read empty localStorage and show the placeholder
276
+ setIframeKey(k => k + 1);
256
277
  }, []);
257
278
 
258
279
  // ── Save ───────────────────────────────────────────────────────
@@ -354,20 +375,27 @@ export function VoiceCanvas({
354
375
  setPendingTranscript(accumulated);
355
376
  setInterimText('');
356
377
 
357
- const trimmed = final.trim().toLowerCase();
358
- if (trimmed.split(/\s+/).length <= 3) {
359
- if (trimmed === 'clear' || trimmed === 'start over') {
378
+ const command = parseVoiceCommand(final);
379
+ if (command) {
380
+ if (command.type === 'clear') {
360
381
  clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
361
382
  }
362
- if (trimmed === 'undo') {
383
+ if (command.type === 'undo') {
363
384
  undo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
364
385
  }
365
- if (trimmed === 'redo') {
386
+ if (command.type === 'redo') {
366
387
  redo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
367
388
  }
368
- if (trimmed === 'stop' || trimmed === 'stop listening') {
389
+ if (command.type === 'stop') {
369
390
  stopListeningRef.current(); return;
370
391
  }
392
+ if (command.type === 'save') {
393
+ saveStory(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
394
+ }
395
+ if (command.type === 'new-chat') {
396
+ clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
397
+ }
398
+ // 'submit' falls through to schedule an LLM generation below
371
399
  }
372
400
 
373
401
  if (abortRef.current) abortRef.current.abort();
@@ -441,7 +469,7 @@ export function VoiceCanvas({
441
469
  isListeningRef.current = false;
442
470
  setIsListening(false);
443
471
  }
444
- }, [clear, undo, redo, scheduleIntent]);
472
+ }, [clear, undo, redo, scheduleIntent, saveStory]);
445
473
 
446
474
  // ── Voice: stop ────────────────────────────────────────────────
447
475
 
@@ -556,6 +584,10 @@ export function VoiceCanvas({
556
584
  );
557
585
  }
558
586
 
587
+ // ── Imperative handle (for parent "New Canvas" button) ──────────
588
+
589
+ useImperativeHandle(ref, () => ({ clear }), [clear]);
590
+
559
591
  // ── Render ─────────────────────────────────────────────────────
560
592
 
561
593
  return (
@@ -740,4 +772,5 @@ export function VoiceCanvas({
740
772
  </div>
741
773
  </div>
742
774
  );
743
- }
775
+ });
776
+
@@ -30,7 +30,7 @@ export interface UseVoiceInputReturn {
30
30
  stop: () => void;
31
31
  toggle: () => void;
32
32
  }
33
- export type VoiceCommandType = 'undo' | 'redo' | 'clear' | 'stop' | 'new-chat' | 'submit';
33
+ export type VoiceCommandType = 'undo' | 'redo' | 'clear' | 'stop' | 'new-chat' | 'submit' | 'save';
34
34
  export interface VoiceCommand {
35
35
  type: VoiceCommandType;
36
36
  raw: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/types.ts"],"names":[],"mappings":"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"}
@@ -52,7 +52,8 @@ export type VoiceCommandType =
52
52
  | 'clear'
53
53
  | 'stop'
54
54
  | 'new-chat'
55
- | 'submit';
55
+ | 'submit'
56
+ | 'save';
56
57
 
57
58
  export interface VoiceCommand {
58
59
  type: VoiceCommandType;
@@ -2,7 +2,10 @@ import type { VoiceCommand } from './types.js';
2
2
  /**
3
3
  * Checks if a transcript matches a known voice command.
4
4
  * Returns the command if matched, null otherwise.
5
- * Only matches short, exact phrases — longer utterances are descriptions.
5
+ *
6
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
7
+ * Any-length utterances are also checked for save-intent substrings so natural
8
+ * phrases like "this is good, save it, stop listening" still trigger a save.
6
9
  */
7
10
  export declare function parseVoiceCommand(transcript: string): VoiceCommand | null;
8
11
  //# sourceMappingURL=voiceCommands.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"voiceCommands.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/voiceCommands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,YAAY,CAAC;AAoCjE;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAYzE"}
1
+ {"version":3,"file":"voiceCommands.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/voiceCommands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,YAAY,CAAC;AA8DjE;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAmBzE"}
@@ -27,20 +27,53 @@ const COMMAND_MAP = {
27
27
  'go': 'submit',
28
28
  'send it': 'submit',
29
29
  'generate that': 'submit',
30
+ // Save — short exact phrases
31
+ 'save': 'save',
32
+ 'save this': 'save',
33
+ 'save it': 'save',
34
+ 'save story': 'save',
35
+ 'save the story': 'save',
36
+ 'looks good': 'save',
37
+ 'that looks good': 'save',
38
+ 'this looks good': 'save',
39
+ 'this is good': 'save',
40
+ 'all good': 'save',
41
+ 'all done': 'save',
42
+ 'go ahead and save': 'save',
43
+ 'save and stop': 'save',
30
44
  };
45
+ // Save-intent phrases detected anywhere in a longer utterance.
46
+ // Lets natural speech like "this is good, save it, stop listening" trigger a
47
+ // save without the user needing to say an exact short phrase.
48
+ const SAVE_INTENT_PHRASES = [
49
+ 'save it',
50
+ 'save this',
51
+ 'save the story',
52
+ 'go ahead and save',
53
+ 'save and stop',
54
+ ];
31
55
  /**
32
56
  * Checks if a transcript matches a known voice command.
33
57
  * Returns the command if matched, null otherwise.
34
- * Only matches short, exact phrases — longer utterances are descriptions.
58
+ *
59
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
60
+ * Any-length utterances are also checked for save-intent substrings so natural
61
+ * phrases like "this is good, save it, stop listening" still trigger a save.
35
62
  */
36
63
  export function parseVoiceCommand(transcript) {
37
64
  const normalized = transcript.trim().toLowerCase().replace(/[.,!?]/g, '');
38
- // Only check short utterances (commands are 1-3 words)
39
- if (normalized.split(/\s+/).length > 4)
40
- return null;
41
- const commandType = COMMAND_MAP[normalized];
42
- if (commandType) {
43
- return { type: commandType, raw: transcript };
65
+ // Short exact-match commands (1-4 words)
66
+ if (normalized.split(/\s+/).length <= 4) {
67
+ const commandType = COMMAND_MAP[normalized];
68
+ if (commandType) {
69
+ return { type: commandType, raw: transcript };
70
+ }
71
+ }
72
+ // Save-intent phrases can appear anywhere in a longer utterance
73
+ for (const phrase of SAVE_INTENT_PHRASES) {
74
+ if (normalized.includes(phrase)) {
75
+ return { type: 'save', raw: transcript };
76
+ }
44
77
  }
45
78
  return null;
46
79
  }
@@ -32,22 +32,58 @@ const COMMAND_MAP: Record<string, VoiceCommandType> = {
32
32
  'go': 'submit',
33
33
  'send it': 'submit',
34
34
  'generate that': 'submit',
35
+
36
+ // Save — short exact phrases
37
+ 'save': 'save',
38
+ 'save this': 'save',
39
+ 'save it': 'save',
40
+ 'save story': 'save',
41
+ 'save the story': 'save',
42
+ 'looks good': 'save',
43
+ 'that looks good': 'save',
44
+ 'this looks good': 'save',
45
+ 'this is good': 'save',
46
+ 'all good': 'save',
47
+ 'all done': 'save',
48
+ 'go ahead and save': 'save',
49
+ 'save and stop': 'save',
35
50
  };
36
51
 
52
+ // Save-intent phrases detected anywhere in a longer utterance.
53
+ // Lets natural speech like "this is good, save it, stop listening" trigger a
54
+ // save without the user needing to say an exact short phrase.
55
+ const SAVE_INTENT_PHRASES = [
56
+ 'save it',
57
+ 'save this',
58
+ 'save the story',
59
+ 'go ahead and save',
60
+ 'save and stop',
61
+ ];
62
+
37
63
  /**
38
64
  * Checks if a transcript matches a known voice command.
39
65
  * Returns the command if matched, null otherwise.
40
- * Only matches short, exact phrases — longer utterances are descriptions.
66
+ *
67
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
68
+ * Any-length utterances are also checked for save-intent substrings so natural
69
+ * phrases like "this is good, save it, stop listening" still trigger a save.
41
70
  */
42
71
  export function parseVoiceCommand(transcript: string): VoiceCommand | null {
43
72
  const normalized = transcript.trim().toLowerCase().replace(/[.,!?]/g, '');
44
73
 
45
- // Only check short utterances (commands are 1-3 words)
46
- if (normalized.split(/\s+/).length > 4) return null;
74
+ // Short exact-match commands (1-4 words)
75
+ if (normalized.split(/\s+/).length <= 4) {
76
+ const commandType = COMMAND_MAP[normalized];
77
+ if (commandType) {
78
+ return { type: commandType, raw: transcript };
79
+ }
80
+ }
47
81
 
48
- const commandType = COMMAND_MAP[normalized];
49
- if (commandType) {
50
- return { type: commandType, raw: transcript };
82
+ // Save-intent phrases can appear anywhere in a longer utterance
83
+ for (const phrase of SAVE_INTENT_PHRASES) {
84
+ if (normalized.includes(phrase)) {
85
+ return { type: 'save', raw: transcript };
86
+ }
51
87
  }
52
88
 
53
89
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "4.13.3",
3
+ "version": "4.15.0",
4
4
  "description": "AI-powered Storybook story generator with dynamic component discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,7 +9,7 @@
9
9
  import React, { useState, useEffect, useRef, useCallback, useReducer } from 'react';
10
10
  import './StoryUIPanel.css';
11
11
  import { VoiceControls } from './voice/VoiceControls.js';
12
- import { VoiceCanvas } from './voice/VoiceCanvas.js';
12
+ import { VoiceCanvas, type VoiceCanvasHandle } from './voice/VoiceCanvas.js';
13
13
  import type { VoiceCommand } from './voice/types.js';
14
14
 
15
15
  // ============================================
@@ -1182,6 +1182,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
1182
1182
  const panelGeneratedStoryIds = useRef<Set<string>>(new Set());
1183
1183
  const voiceModeActiveRef = useRef(false);
1184
1184
  const canvasModeRef = useRef(panelMode === 'canvas');
1185
+ const voiceCanvasRef = useRef<VoiceCanvasHandle>(null);
1185
1186
  const knownStoryIds = useRef<Set<string>>(new Set());
1186
1187
  const isPollingInitialized = useRef(false);
1187
1188
 
@@ -1913,7 +1914,14 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
1913
1914
  dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
1914
1915
  };
1915
1916
 
1916
- const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
1917
+ const handleNewChat = () => {
1918
+ dispatch({ type: 'NEW_CHAT' });
1919
+ // When on Voice Canvas, also clear the canvas state (abort generation,
1920
+ // reset code, blank the iframe, clear conversation history)
1921
+ if (panelMode === 'canvas') {
1922
+ voiceCanvasRef.current?.clear();
1923
+ }
1924
+ };
1917
1925
 
1918
1926
  // Voice input handlers
1919
1927
  const handleVoiceTranscript = useCallback((text: string) => {
@@ -2159,7 +2167,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
2159
2167
  {/* New Chat */}
2160
2168
  <button className="sui-button sui-button-default" onClick={handleNewChat} style={{ width: '100%', marginBottom: '16px' }}>
2161
2169
  {Icons.plus}
2162
- <span>New Chat</span>
2170
+ <span>{panelMode === 'canvas' ? 'New Canvas' : 'New Chat'}</span>
2163
2171
  </button>
2164
2172
 
2165
2173
  {/* Chat history */}
@@ -2368,6 +2376,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
2368
2376
 
2369
2377
  {panelMode === 'canvas' ? (
2370
2378
  <VoiceCanvas
2379
+ ref={voiceCanvasRef}
2371
2380
  apiBase={getApiBase()}
2372
2381
  provider={state.selectedProvider}
2373
2382
  model={state.selectedModel}
@@ -13,7 +13,8 @@
13
13
  * This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
14
14
  * StoryUIPanel is never accidentally reset.
15
15
  */
16
- import React, { useCallback, useEffect, useRef, useState } from 'react';
16
+ import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
17
+ import { parseVoiceCommand } from './voiceCommands.js';
17
18
 
18
19
  // ── Constants ─────────────────────────────────────────────────
19
20
 
@@ -34,15 +35,22 @@ export interface VoiceCanvasProps {
34
35
  onError?: (error: string) => void;
35
36
  }
36
37
 
38
+ /** Imperative handle exposed to parent via ref — used by "New Chat" button */
39
+ export interface VoiceCanvasHandle {
40
+ /** Clear the canvas: abort generation, reset all state, blank the iframe */
41
+ clear: () => void;
42
+ }
43
+
37
44
  // ── Component ─────────────────────────────────────────────────
38
45
 
39
- export function VoiceCanvas({
46
+ export const VoiceCanvas = React.forwardRef<VoiceCanvasHandle, VoiceCanvasProps>(
47
+ function VoiceCanvas({
40
48
  apiBase,
41
49
  provider,
42
50
  model,
43
51
  onSave,
44
52
  onError,
45
- }: VoiceCanvasProps) {
53
+ }: VoiceCanvasProps, ref) {
46
54
  // ── Code + history ───────────────────────────────────────────
47
55
  const [currentCode, setCurrentCode] = useState('');
48
56
  const [undoStack, setUndoStack] = useState<string[]>([]);
@@ -239,6 +247,13 @@ export function VoiceCanvas({
239
247
  // ── Clear ─────────────────────────────────────────────────────
240
248
 
241
249
  const clear = useCallback(() => {
250
+ // Abort any in-flight generation so it doesn't land after the reset
251
+ if (abortRef.current) {
252
+ abortRef.current.abort();
253
+ abortRef.current = null;
254
+ }
255
+ generationCounterRef.current += 1; // invalidate any pending finally-block
256
+
242
257
  const current = currentCodeRef.current;
243
258
  if (current.trim()) {
244
259
  setUndoStack(prev => [...prev.slice(-19), current]);
@@ -249,10 +264,16 @@ export function VoiceCanvas({
249
264
  iframeLoadedRef.current = false;
250
265
  conversationRef.current = [];
251
266
  setErrorMessage('');
267
+ setIsGenerating(false);
268
+ setStatusText('');
252
269
  setPendingTranscript('');
253
270
  pendingTranscriptRef.current = '';
271
+ setLastPrompt('');
272
+ lastPromptRef.current = '';
254
273
  try { localStorage.removeItem(LS_KEY); } catch {}
255
274
  try { localStorage.removeItem(LS_PROMPT_KEY); } catch {}
275
+ // Force the iframe to remount — it will read empty localStorage and show the placeholder
276
+ setIframeKey(k => k + 1);
256
277
  }, []);
257
278
 
258
279
  // ── Save ───────────────────────────────────────────────────────
@@ -354,20 +375,27 @@ export function VoiceCanvas({
354
375
  setPendingTranscript(accumulated);
355
376
  setInterimText('');
356
377
 
357
- const trimmed = final.trim().toLowerCase();
358
- if (trimmed.split(/\s+/).length <= 3) {
359
- if (trimmed === 'clear' || trimmed === 'start over') {
378
+ const command = parseVoiceCommand(final);
379
+ if (command) {
380
+ if (command.type === 'clear') {
360
381
  clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
361
382
  }
362
- if (trimmed === 'undo') {
383
+ if (command.type === 'undo') {
363
384
  undo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
364
385
  }
365
- if (trimmed === 'redo') {
386
+ if (command.type === 'redo') {
366
387
  redo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
367
388
  }
368
- if (trimmed === 'stop' || trimmed === 'stop listening') {
389
+ if (command.type === 'stop') {
369
390
  stopListeningRef.current(); return;
370
391
  }
392
+ if (command.type === 'save') {
393
+ saveStory(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
394
+ }
395
+ if (command.type === 'new-chat') {
396
+ clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
397
+ }
398
+ // 'submit' falls through to schedule an LLM generation below
371
399
  }
372
400
 
373
401
  if (abortRef.current) abortRef.current.abort();
@@ -441,7 +469,7 @@ export function VoiceCanvas({
441
469
  isListeningRef.current = false;
442
470
  setIsListening(false);
443
471
  }
444
- }, [clear, undo, redo, scheduleIntent]);
472
+ }, [clear, undo, redo, scheduleIntent, saveStory]);
445
473
 
446
474
  // ── Voice: stop ────────────────────────────────────────────────
447
475
 
@@ -556,6 +584,10 @@ export function VoiceCanvas({
556
584
  );
557
585
  }
558
586
 
587
+ // ── Imperative handle (for parent "New Canvas" button) ──────────
588
+
589
+ useImperativeHandle(ref, () => ({ clear }), [clear]);
590
+
559
591
  // ── Render ─────────────────────────────────────────────────────
560
592
 
561
593
  return (
@@ -740,4 +772,5 @@ export function VoiceCanvas({
740
772
  </div>
741
773
  </div>
742
774
  );
743
- }
775
+ });
776
+
@@ -52,7 +52,8 @@ export type VoiceCommandType =
52
52
  | 'clear'
53
53
  | 'stop'
54
54
  | 'new-chat'
55
- | 'submit';
55
+ | 'submit'
56
+ | 'save';
56
57
 
57
58
  export interface VoiceCommand {
58
59
  type: VoiceCommandType;
@@ -32,22 +32,58 @@ const COMMAND_MAP: Record<string, VoiceCommandType> = {
32
32
  'go': 'submit',
33
33
  'send it': 'submit',
34
34
  'generate that': 'submit',
35
+
36
+ // Save — short exact phrases
37
+ 'save': 'save',
38
+ 'save this': 'save',
39
+ 'save it': 'save',
40
+ 'save story': 'save',
41
+ 'save the story': 'save',
42
+ 'looks good': 'save',
43
+ 'that looks good': 'save',
44
+ 'this looks good': 'save',
45
+ 'this is good': 'save',
46
+ 'all good': 'save',
47
+ 'all done': 'save',
48
+ 'go ahead and save': 'save',
49
+ 'save and stop': 'save',
35
50
  };
36
51
 
52
+ // Save-intent phrases detected anywhere in a longer utterance.
53
+ // Lets natural speech like "this is good, save it, stop listening" trigger a
54
+ // save without the user needing to say an exact short phrase.
55
+ const SAVE_INTENT_PHRASES = [
56
+ 'save it',
57
+ 'save this',
58
+ 'save the story',
59
+ 'go ahead and save',
60
+ 'save and stop',
61
+ ];
62
+
37
63
  /**
38
64
  * Checks if a transcript matches a known voice command.
39
65
  * Returns the command if matched, null otherwise.
40
- * Only matches short, exact phrases — longer utterances are descriptions.
66
+ *
67
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
68
+ * Any-length utterances are also checked for save-intent substrings so natural
69
+ * phrases like "this is good, save it, stop listening" still trigger a save.
41
70
  */
42
71
  export function parseVoiceCommand(transcript: string): VoiceCommand | null {
43
72
  const normalized = transcript.trim().toLowerCase().replace(/[.,!?]/g, '');
44
73
 
45
- // Only check short utterances (commands are 1-3 words)
46
- if (normalized.split(/\s+/).length > 4) return null;
74
+ // Short exact-match commands (1-4 words)
75
+ if (normalized.split(/\s+/).length <= 4) {
76
+ const commandType = COMMAND_MAP[normalized];
77
+ if (commandType) {
78
+ return { type: commandType, raw: transcript };
79
+ }
80
+ }
47
81
 
48
- const commandType = COMMAND_MAP[normalized];
49
- if (commandType) {
50
- return { type: commandType, raw: transcript };
82
+ // Save-intent phrases can appear anywhere in a longer utterance
83
+ for (const phrase of SAVE_INTENT_PHRASES) {
84
+ if (normalized.includes(phrase)) {
85
+ return { type: 'save', raw: transcript };
86
+ }
51
87
  }
52
88
 
53
89
  return null;