@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.
- package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -1
- package/dist/templates/StoryUI/StoryUIPanel.js +11 -3
- package/dist/templates/StoryUI/StoryUIPanel.tsx +12 -3
- package/dist/templates/StoryUI/voice/VoiceCanvas.d.ts +22 -1
- package/dist/templates/StoryUI/voice/VoiceCanvas.d.ts.map +1 -1
- package/dist/templates/StoryUI/voice/VoiceCanvas.js +17 -3
- package/dist/templates/StoryUI/voice/VoiceCanvas.tsx +29 -4
- package/package.json +1 -1
- package/templates/StoryUI/StoryUIPanel.tsx +12 -3
- package/templates/StoryUI/voice/VoiceCanvas.tsx +29 -4
|
@@ -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,
|
|
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 = () =>
|
|
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:
|
|
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 = () =>
|
|
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
|
-
|
|
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":"
|
|
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
|
|
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
|
@@ -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 = () =>
|
|
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
|
|
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
|
+
|