@tpitre/story-ui 4.13.3 → 4.14.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":"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;AAW7F,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,4FAktBtB,CAAC"}
@@ -14,14 +14,14 @@ 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
18
  // ── Constants ─────────────────────────────────────────────────
19
19
  const STORY_ID = 'generated-voice-canvas--default';
20
20
  const LS_KEY = '__voice_canvas_code__';
21
21
  const LS_PROMPT_KEY = '__voice_canvas_prompt__';
22
22
  const IFRAME_ORIGIN = window.location.origin;
23
23
  // ── Component ─────────────────────────────────────────────────
24
- export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
24
+ export const VoiceCanvas = React.forwardRef(function VoiceCanvas({ apiBase, provider, model, onSave, onError, }, ref) {
25
25
  // ── Code + history ───────────────────────────────────────────
26
26
  const [currentCode, setCurrentCode] = useState('');
27
27
  const [undoStack, setUndoStack] = useState([]);
@@ -194,6 +194,12 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
194
194
  const canRedo = redoStack.length > 0;
195
195
  // ── Clear ─────────────────────────────────────────────────────
196
196
  const clear = useCallback(() => {
197
+ // Abort any in-flight generation so it doesn't land after the reset
198
+ if (abortRef.current) {
199
+ abortRef.current.abort();
200
+ abortRef.current = null;
201
+ }
202
+ generationCounterRef.current += 1; // invalidate any pending finally-block
197
203
  const current = currentCodeRef.current;
198
204
  if (current.trim()) {
199
205
  setUndoStack(prev => [...prev.slice(-19), current]);
@@ -204,8 +210,12 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
204
210
  iframeLoadedRef.current = false;
205
211
  conversationRef.current = [];
206
212
  setErrorMessage('');
213
+ setIsGenerating(false);
214
+ setStatusText('');
207
215
  setPendingTranscript('');
208
216
  pendingTranscriptRef.current = '';
217
+ setLastPrompt('');
218
+ lastPromptRef.current = '';
209
219
  try {
210
220
  localStorage.removeItem(LS_KEY);
211
221
  }
@@ -214,6 +224,8 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
214
224
  localStorage.removeItem(LS_PROMPT_KEY);
215
225
  }
216
226
  catch { }
227
+ // Force the iframe to remount — it will read empty localStorage and show the placeholder
228
+ setIframeKey(k => k + 1);
217
229
  }, []);
218
230
  // ── Save ───────────────────────────────────────────────────────
219
231
  // No dialog — saves immediately using the last voice/text prompt as the title.
@@ -505,6 +517,8 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
505
517
  if (!speechSupported) {
506
518
  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
519
  }
520
+ // ── Imperative handle (for parent "New Canvas" button) ──────────
521
+ useImperativeHandle(ref, () => ({ clear }), [clear]);
508
522
  // ── Render ─────────────────────────────────────────────────────
509
523
  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
- }
524
+ });
@@ -13,7 +13,7 @@
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
17
 
18
18
  // ── Constants ─────────────────────────────────────────────────
19
19
 
@@ -34,15 +34,22 @@ export interface VoiceCanvasProps {
34
34
  onError?: (error: string) => void;
35
35
  }
36
36
 
37
+ /** Imperative handle exposed to parent via ref — used by "New Chat" button */
38
+ export interface VoiceCanvasHandle {
39
+ /** Clear the canvas: abort generation, reset all state, blank the iframe */
40
+ clear: () => void;
41
+ }
42
+
37
43
  // ── Component ─────────────────────────────────────────────────
38
44
 
39
- export function VoiceCanvas({
45
+ export const VoiceCanvas = React.forwardRef<VoiceCanvasHandle, VoiceCanvasProps>(
46
+ function VoiceCanvas({
40
47
  apiBase,
41
48
  provider,
42
49
  model,
43
50
  onSave,
44
51
  onError,
45
- }: VoiceCanvasProps) {
52
+ }: VoiceCanvasProps, ref) {
46
53
  // ── Code + history ───────────────────────────────────────────
47
54
  const [currentCode, setCurrentCode] = useState('');
48
55
  const [undoStack, setUndoStack] = useState<string[]>([]);
@@ -239,6 +246,13 @@ export function VoiceCanvas({
239
246
  // ── Clear ─────────────────────────────────────────────────────
240
247
 
241
248
  const clear = useCallback(() => {
249
+ // Abort any in-flight generation so it doesn't land after the reset
250
+ if (abortRef.current) {
251
+ abortRef.current.abort();
252
+ abortRef.current = null;
253
+ }
254
+ generationCounterRef.current += 1; // invalidate any pending finally-block
255
+
242
256
  const current = currentCodeRef.current;
243
257
  if (current.trim()) {
244
258
  setUndoStack(prev => [...prev.slice(-19), current]);
@@ -249,10 +263,16 @@ export function VoiceCanvas({
249
263
  iframeLoadedRef.current = false;
250
264
  conversationRef.current = [];
251
265
  setErrorMessage('');
266
+ setIsGenerating(false);
267
+ setStatusText('');
252
268
  setPendingTranscript('');
253
269
  pendingTranscriptRef.current = '';
270
+ setLastPrompt('');
271
+ lastPromptRef.current = '';
254
272
  try { localStorage.removeItem(LS_KEY); } catch {}
255
273
  try { localStorage.removeItem(LS_PROMPT_KEY); } catch {}
274
+ // Force the iframe to remount — it will read empty localStorage and show the placeholder
275
+ setIframeKey(k => k + 1);
256
276
  }, []);
257
277
 
258
278
  // ── Save ───────────────────────────────────────────────────────
@@ -556,6 +576,10 @@ export function VoiceCanvas({
556
576
  );
557
577
  }
558
578
 
579
+ // ── Imperative handle (for parent "New Canvas" button) ──────────
580
+
581
+ useImperativeHandle(ref, () => ({ clear }), [clear]);
582
+
559
583
  // ── Render ─────────────────────────────────────────────────────
560
584
 
561
585
  return (
@@ -740,4 +764,5 @@ export function VoiceCanvas({
740
764
  </div>
741
765
  </div>
742
766
  );
743
- }
767
+ });
768
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "4.13.3",
3
+ "version": "4.14.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,7 @@
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
17
 
18
18
  // ── Constants ─────────────────────────────────────────────────
19
19
 
@@ -34,15 +34,22 @@ export interface VoiceCanvasProps {
34
34
  onError?: (error: string) => void;
35
35
  }
36
36
 
37
+ /** Imperative handle exposed to parent via ref — used by "New Chat" button */
38
+ export interface VoiceCanvasHandle {
39
+ /** Clear the canvas: abort generation, reset all state, blank the iframe */
40
+ clear: () => void;
41
+ }
42
+
37
43
  // ── Component ─────────────────────────────────────────────────
38
44
 
39
- export function VoiceCanvas({
45
+ export const VoiceCanvas = React.forwardRef<VoiceCanvasHandle, VoiceCanvasProps>(
46
+ function VoiceCanvas({
40
47
  apiBase,
41
48
  provider,
42
49
  model,
43
50
  onSave,
44
51
  onError,
45
- }: VoiceCanvasProps) {
52
+ }: VoiceCanvasProps, ref) {
46
53
  // ── Code + history ───────────────────────────────────────────
47
54
  const [currentCode, setCurrentCode] = useState('');
48
55
  const [undoStack, setUndoStack] = useState<string[]>([]);
@@ -239,6 +246,13 @@ export function VoiceCanvas({
239
246
  // ── Clear ─────────────────────────────────────────────────────
240
247
 
241
248
  const clear = useCallback(() => {
249
+ // Abort any in-flight generation so it doesn't land after the reset
250
+ if (abortRef.current) {
251
+ abortRef.current.abort();
252
+ abortRef.current = null;
253
+ }
254
+ generationCounterRef.current += 1; // invalidate any pending finally-block
255
+
242
256
  const current = currentCodeRef.current;
243
257
  if (current.trim()) {
244
258
  setUndoStack(prev => [...prev.slice(-19), current]);
@@ -249,10 +263,16 @@ export function VoiceCanvas({
249
263
  iframeLoadedRef.current = false;
250
264
  conversationRef.current = [];
251
265
  setErrorMessage('');
266
+ setIsGenerating(false);
267
+ setStatusText('');
252
268
  setPendingTranscript('');
253
269
  pendingTranscriptRef.current = '';
270
+ setLastPrompt('');
271
+ lastPromptRef.current = '';
254
272
  try { localStorage.removeItem(LS_KEY); } catch {}
255
273
  try { localStorage.removeItem(LS_PROMPT_KEY); } catch {}
274
+ // Force the iframe to remount — it will read empty localStorage and show the placeholder
275
+ setIframeKey(k => k + 1);
256
276
  }, []);
257
277
 
258
278
  // ── Save ───────────────────────────────────────────────────────
@@ -556,6 +576,10 @@ export function VoiceCanvas({
556
576
  );
557
577
  }
558
578
 
579
+ // ── Imperative handle (for parent "New Canvas" button) ──────────
580
+
581
+ useImperativeHandle(ref, () => ({ clear }), [clear]);
582
+
559
583
  // ── Render ─────────────────────────────────────────────────────
560
584
 
561
585
  return (
@@ -740,4 +764,5 @@ export function VoiceCanvas({
740
764
  </div>
741
765
  </div>
742
766
  );
743
- }
767
+ });
768
+