@tpitre/story-ui 3.9.0 → 3.10.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,1284 +1,595 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useRef, useEffect, useCallback } from 'react';
3
- // Simple markdown renderer for AI messages with icon marker support
4
- const renderMarkdown = (text) => {
5
- // Split by double newlines to get paragraphs
6
- const paragraphs = text.split(/\n\n+/);
7
- // Parse inline formatting within text
8
- const parseInline = (str, paragraphIndex) => {
9
- const parts = [];
10
- let remaining = str;
11
- let keyIndex = 0;
12
- while (remaining.length > 0) {
13
- // Icon markers: [SUCCESS], [ERROR], [TIP], [WRENCH]
14
- const iconMatch = remaining.match(/^\[(SUCCESS|ERROR|TIP|WRENCH)\]/);
15
- if (iconMatch) {
16
- const iconType = iconMatch[1].toLowerCase();
17
- parts.push(_jsx("span", { children: StatusIcons[iconType] }, `icon-${paragraphIndex}-${keyIndex++}`));
18
- remaining = remaining.slice(iconMatch[0].length);
19
- continue;
20
- }
21
- // Bold: **text**
22
- const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
23
- if (boldMatch) {
24
- parts.push(_jsx("strong", { children: boldMatch[1] }, `b-${paragraphIndex}-${keyIndex++}`));
25
- remaining = remaining.slice(boldMatch[0].length);
26
- continue;
27
- }
28
- // Italic: _text_
29
- const italicMatch = remaining.match(/^_(.+?)_/);
30
- if (italicMatch) {
31
- parts.push(_jsx("em", { style: { opacity: 0.7, fontSize: '0.9em' }, children: italicMatch[1] }, `i-${paragraphIndex}-${keyIndex++}`));
32
- remaining = remaining.slice(italicMatch[0].length);
33
- continue;
34
- }
35
- // Code: `text`
36
- const codeMatch = remaining.match(/^`([^`]+)`/);
37
- if (codeMatch) {
38
- parts.push(_jsx("code", { style: {
39
- background: 'rgba(0,0,0,0.08)',
40
- padding: '1px 4px',
41
- borderRadius: '4px',
42
- fontFamily: 'ui-monospace, monospace',
43
- fontSize: '0.88em'
44
- }, children: codeMatch[1] }, `c-${paragraphIndex}-${keyIndex++}`));
45
- remaining = remaining.slice(codeMatch[0].length);
46
- continue;
47
- }
48
- // Single newline within paragraph - convert to space or line break
49
- if (remaining.startsWith('\n')) {
50
- parts.push(' ');
51
- remaining = remaining.slice(1);
52
- continue;
53
- }
54
- // Regular text - consume until next special character or bracket
55
- const nextSpecial = remaining.search(/[*_`\[\n]/);
56
- if (nextSpecial === -1) {
57
- parts.push(remaining);
58
- remaining = '';
59
- }
60
- else if (nextSpecial === 0) {
61
- // Special char that didn't match a pattern, treat as regular text
62
- parts.push(remaining[0]);
63
- remaining = remaining.slice(1);
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * StoryUIPanel - AI-powered Storybook story generator
4
+ *
5
+ * ShadCN-inspired design with Gemini-style layout.
6
+ * Self-contained React component with no external UI dependencies.
7
+ * Supports light and dark modes based on Storybook theme.
8
+ */
9
+ import React, { useState, useEffect, useRef, useCallback, useReducer } from 'react';
10
+ import './StoryUIPanel.css';
11
+ const initialState = {
12
+ sidebarOpen: true,
13
+ showCode: false,
14
+ isDragging: false,
15
+ loading: false,
16
+ isBulkDeleting: false,
17
+ conversation: [],
18
+ recentChats: [],
19
+ orphanStories: [],
20
+ activeChatId: null,
21
+ activeTitle: '',
22
+ input: '',
23
+ attachedImages: [],
24
+ selectedStoryIds: new Set(),
25
+ availableProviders: [],
26
+ selectedProvider: '',
27
+ selectedModel: '',
28
+ connectionStatus: { connected: false },
29
+ streamingState: null,
30
+ error: null,
31
+ considerations: '',
32
+ isDarkMode: false,
33
+ };
34
+ function panelReducer(state, action) {
35
+ switch (action.type) {
36
+ case 'TOGGLE_SIDEBAR':
37
+ return { ...state, sidebarOpen: !state.sidebarOpen };
38
+ case 'SET_SIDEBAR':
39
+ return { ...state, sidebarOpen: action.payload };
40
+ case 'TOGGLE_CODE':
41
+ return { ...state, showCode: !state.showCode };
42
+ case 'SET_DRAGGING':
43
+ return { ...state, isDragging: action.payload };
44
+ case 'SET_LOADING':
45
+ return { ...state, loading: action.payload };
46
+ case 'SET_BULK_DELETING':
47
+ return { ...state, isBulkDeleting: action.payload };
48
+ case 'SET_CONVERSATION':
49
+ return { ...state, conversation: action.payload };
50
+ case 'ADD_MESSAGE':
51
+ return { ...state, conversation: [...state.conversation, action.payload] };
52
+ case 'SET_RECENT_CHATS':
53
+ return { ...state, recentChats: action.payload };
54
+ case 'SET_ORPHAN_STORIES':
55
+ return { ...state, orphanStories: action.payload };
56
+ case 'SET_ACTIVE_CHAT':
57
+ return { ...state, activeChatId: action.payload.id, activeTitle: action.payload.title };
58
+ case 'SET_INPUT':
59
+ return { ...state, input: action.payload };
60
+ case 'SET_ATTACHED_IMAGES':
61
+ return { ...state, attachedImages: action.payload };
62
+ case 'ADD_ATTACHED_IMAGE':
63
+ return { ...state, attachedImages: [...state.attachedImages, action.payload] };
64
+ case 'REMOVE_ATTACHED_IMAGE':
65
+ return {
66
+ ...state,
67
+ attachedImages: state.attachedImages.filter(img => img.id !== action.payload),
68
+ };
69
+ case 'CLEAR_ATTACHED_IMAGES':
70
+ return { ...state, attachedImages: [] };
71
+ case 'SET_SELECTED_STORY_IDS':
72
+ return { ...state, selectedStoryIds: action.payload };
73
+ case 'TOGGLE_STORY_SELECTION': {
74
+ const newSet = new Set(state.selectedStoryIds);
75
+ if (newSet.has(action.payload)) {
76
+ newSet.delete(action.payload);
64
77
  }
65
78
  else {
66
- parts.push(remaining.slice(0, nextSpecial));
67
- remaining = remaining.slice(nextSpecial);
79
+ newSet.add(action.payload);
68
80
  }
81
+ return { ...state, selectedStoryIds: newSet };
69
82
  }
70
- return parts;
71
- };
72
- return (_jsx(_Fragment, { children: paragraphs.map((paragraph, index) => (_jsx("div", { style: { marginBottom: index < paragraphs.length - 1 ? '8px' : 0 }, children: parseInline(paragraph.trim(), index) }, `p-${index}`))) }));
73
- };
74
- // Inline SVG icons for status indicators (avoiding emojis)
75
- const StatusIcons = {
76
- success: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { color: '#22c55e', verticalAlign: 'middle', marginRight: '6px' }, children: [_jsx("path", { d: "M22 11.08V12a10 10 0 1 1-5.93-9.14" }), _jsx("polyline", { points: "22,4 12,14.01 9,11.01" })] })),
77
- error: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { color: '#ef4444', verticalAlign: 'middle', marginRight: '6px' }, children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("line", { x1: "15", y1: "9", x2: "9", y2: "15" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15" })] })),
78
- tip: (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { color: '#f59e0b', verticalAlign: 'middle', marginRight: '4px' }, children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("line", { x1: "12", y1: "16", x2: "12", y2: "12" }), _jsx("line", { x1: "12", y1: "8", x2: "12.01", y2: "8" })] })),
79
- wrench: (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { color: '#6366f1', verticalAlign: 'middle', marginRight: '4px' }, children: _jsx("path", { d: "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" }) }))
80
- };
81
- // Model display names for friendly UI presentation
82
- // Maps API model IDs to human-readable names
83
- const MODEL_DISPLAY_NAMES = {
84
- // Claude models
85
- 'claude-opus-4-5-20251101': 'Claude Opus 4.5',
86
- 'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5',
87
- 'claude-haiku-4-5-20251001': 'Claude Haiku 4.5',
88
- 'claude-sonnet-4-20250514': 'Claude Sonnet 4',
89
- 'claude-opus-4-20250514': 'Claude Opus 4',
90
- 'claude-3-7-sonnet-20250219': 'Claude 3.7 Sonnet',
91
- 'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
92
- 'claude-3-5-haiku-20241022': 'Claude 3.5 Haiku',
93
- // OpenAI models
94
- 'gpt-5.1': 'GPT-5.1',
95
- 'gpt-5.1-thinking': 'GPT-5.1 Thinking',
96
- 'gpt-5': 'GPT-5',
97
- 'gpt-4o': 'GPT-4o',
98
- 'gpt-4o-mini': 'GPT-4o Mini',
99
- 'o1': 'o1',
100
- 'o1-mini': 'o1 Mini',
101
- // Gemini models
102
- 'gemini-3-pro': 'Gemini 3 Pro',
103
- 'gemini-3-pro-preview': 'Gemini 3 Pro Preview',
104
- 'gemini-2.0-flash-exp': 'Gemini 2.0 Flash Exp',
105
- 'gemini-2.0-flash': 'Gemini 2.0 Flash',
106
- 'gemini-1.5-pro': 'Gemini 1.5 Pro',
107
- 'gemini-1.5-flash': 'Gemini 1.5 Flash',
108
- };
109
- // Get friendly display name for a model, falling back to the API name if not found
110
- const getModelDisplayName = (modelId) => {
111
- return MODEL_DISPLAY_NAMES[modelId] || modelId;
112
- };
113
- // Determine the MCP API base URL.
114
- // Priority order:
115
- // 1. VITE_STORY_UI_EDGE_URL - Edge Worker URL for cloud deployments
116
- // 2. window.__STORY_UI_EDGE_URL__ - Runtime override for edge URL
117
- // 3. Production domains (railway.app, render.com, pages.dev) - use same origin
118
- // 4. VITE_STORY_UI_PORT - Custom port for localhost
119
- // 5. window.__STORY_UI_PORT__ - Legacy port override
120
- // 6. window.STORY_UI_MCP_PORT - MCP port override
121
- // 7. Default to localhost:4001
122
- const getApiBaseUrl = () => {
123
- // Check for Edge Worker URL (cloud deployment)
124
- const edgeUrl = import.meta.env?.VITE_STORY_UI_EDGE_URL;
125
- if (edgeUrl)
126
- return edgeUrl.replace(/\/$/, ''); // Remove trailing slash
127
- // Check for window override for edge URL (support both naming conventions)
128
- const windowEdgeUrl = window.__STORY_UI_EDGE_URL__ || window.STORY_UI_EDGE_URL;
129
- if (windowEdgeUrl)
130
- return windowEdgeUrl.replace(/\/$/, '');
131
- // Check if we're running on Railway production domain
132
- // In this case, the MCP server is proxied through the same origin
83
+ case 'SET_PROVIDERS':
84
+ return { ...state, availableProviders: action.payload };
85
+ case 'SET_SELECTED_PROVIDER':
86
+ return { ...state, selectedProvider: action.payload };
87
+ case 'SET_SELECTED_MODEL':
88
+ return { ...state, selectedModel: action.payload };
89
+ case 'SET_CONNECTION_STATUS':
90
+ return { ...state, connectionStatus: action.payload };
91
+ case 'SET_STREAMING_STATE':
92
+ return { ...state, streamingState: action.payload };
93
+ case 'UPDATE_STREAMING_STATE':
94
+ return { ...state, streamingState: { ...state.streamingState, ...action.payload } };
95
+ case 'SET_ERROR':
96
+ return { ...state, error: action.payload };
97
+ case 'SET_CONSIDERATIONS':
98
+ return { ...state, considerations: action.payload };
99
+ case 'SET_DARK_MODE':
100
+ return { ...state, isDarkMode: action.payload };
101
+ case 'NEW_CHAT':
102
+ return { ...state, conversation: [], activeChatId: null, activeTitle: '' };
103
+ default:
104
+ return state;
105
+ }
106
+ }
107
+ // ============================================
108
+ // Constants
109
+ // ============================================
110
+ const USE_STREAMING = true;
111
+ const MAX_RECENT_CHATS = 20;
112
+ const CHAT_STORAGE_KEY = 'story-ui-chats';
113
+ const MAX_IMAGES = 4;
114
+ const MAX_IMAGE_SIZE_MB = 20;
115
+ // ============================================
116
+ // Helper Functions
117
+ // ============================================
118
+ function getApiBaseUrl() {
119
+ if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_STORY_UI_EDGE_URL) {
120
+ return import.meta.env.VITE_STORY_UI_EDGE_URL;
121
+ }
133
122
  if (typeof window !== 'undefined') {
134
- const hostname = window.location.hostname;
135
- if (hostname.includes('.railway.app')) {
136
- // Use same-origin requests (empty string means relative URLs)
137
- return '';
123
+ if (window.__STORY_UI_EDGE_URL__) {
124
+ return window.__STORY_UI_EDGE_URL__;
125
+ }
126
+ if (window.location.hostname.includes('railway.app')) {
127
+ return window.location.origin;
138
128
  }
139
129
  }
140
- // Check for Vite port environment variable
141
- const vitePort = import.meta.env?.VITE_STORY_UI_PORT;
142
- if (vitePort)
143
- return `http://localhost:${vitePort}`;
144
- // Check for window override (legacy support)
145
- const windowOverride = window.__STORY_UI_PORT__;
146
- if (windowOverride)
147
- return `http://localhost:${windowOverride}`;
148
- // Check for MCP port override set by stories file
149
- const mcpOverride = window.STORY_UI_MCP_PORT;
150
- if (mcpOverride)
151
- return `http://localhost:${mcpOverride}`;
152
- return 'http://localhost:4001';
153
- };
154
- // Helper to check if we're using Edge mode (cloud deployment)
155
- const isEdgeMode = () => {
156
- const baseUrl = getApiBaseUrl();
157
- return baseUrl.includes('workers.dev') || baseUrl.includes('pages.dev') ||
158
- baseUrl.startsWith('https://') && !baseUrl.includes('localhost');
159
- };
160
- // Helper to convert story title to Storybook URL format
161
- // e.g., "Simple Card With Image" -> "generated-simple-card-with-image--default"
162
- const titleToStoryPath = (title) => {
163
- const kebabTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
164
- return `generated-${kebabTitle}--default`;
165
- };
166
- // Helper to navigate to a newly created story after generation completes
167
- // In dev mode with HMR, this prevents the "Couldn't find story after HMR" error
168
- // In all modes, this provides a better UX by auto-navigating to the new story
169
- const navigateToNewStory = (title, _code, delayMs = 1500) => {
170
- const storyPath = titleToStoryPath(title);
171
- console.log(`[Story UI] Will navigate to story "${storyPath}" in ${delayMs}ms...`);
172
- setTimeout(() => {
173
- // Navigate the TOP window (parent Storybook UI), not the iframe
174
- // The Story UI panel runs inside an iframe, so we need window.top to escape it
175
- const topWindow = window.top || window;
176
- const newUrl = `${topWindow.location.origin}/?path=/story/${storyPath}`;
177
- console.log(`[Story UI] Navigating parent window to: ${newUrl}`);
178
- topWindow.location.href = newUrl;
179
- }, delayMs);
180
- };
181
- // Legacy helper for backwards compatibility
182
- const getApiPort = () => {
183
- const baseUrl = getApiBaseUrl();
184
- const match = baseUrl.match(/:(\d+)$/);
185
- return match ? match[1] : '4001';
186
- };
187
- // Get connection display text
188
- const getConnectionDisplayText = () => {
189
- const baseUrl = getApiBaseUrl();
190
- if (isEdgeMode()) {
191
- // Extract domain for Edge URL
192
- try {
193
- const url = new URL(baseUrl);
194
- return `Edge Worker (${url.hostname})`;
130
+ let port = '4001';
131
+ if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_STORY_UI_PORT) {
132
+ port = import.meta.env.VITE_STORY_UI_PORT;
133
+ }
134
+ else if (typeof window !== 'undefined') {
135
+ if (window.__STORY_UI_PORT__) {
136
+ port = window.__STORY_UI_PORT__;
195
137
  }
196
- catch {
197
- return 'Edge Worker';
138
+ else if (window.STORY_UI_MCP_PORT) {
139
+ port = window.STORY_UI_MCP_PORT;
198
140
  }
199
141
  }
200
- return `MCP server (port ${getApiPort()})`;
201
- };
142
+ return `http://localhost:${port}`;
143
+ }
202
144
  const API_BASE = getApiBaseUrl();
203
- const MCP_API = `${API_BASE}/story-ui/generate`;
204
- const MCP_STREAM_API = `${API_BASE}/story-ui/generate-stream`;
145
+ const MCP_API = `${API_BASE}/mcp/generate-story`;
146
+ const MCP_STREAM_API = `${API_BASE}/mcp/generate-story-stream`;
147
+ const PROVIDERS_API = `${API_BASE}/mcp/providers`;
205
148
  const STORIES_API = `${API_BASE}/story-ui/stories`;
206
- const DELETE_API_BASE = `${API_BASE}/story-ui/stories`;
207
- const PROVIDERS_API = `${API_BASE}/story-ui/providers`;
208
- // Considerations API URL - includes storybookOrigin param for Edge mode
209
- const getConsiderationsApiUrl = () => {
210
- const baseUrl = `${API_BASE}/story-ui/considerations`;
211
- if (isEdgeMode()) {
212
- // In Edge mode, tell the Edge Worker where to fetch considerations from
213
- // The Storybook origin is where the panel is running (window.location.origin)
214
- const storybookOrigin = window.location.origin;
215
- return `${baseUrl}?storybookOrigin=${encodeURIComponent(storybookOrigin)}`;
216
- }
217
- return baseUrl;
218
- };
219
- const CONSIDERATIONS_API = getConsiderationsApiUrl();
220
- const STORAGE_KEY = `story-ui-chats-${window.location.port}`;
221
- const MAX_RECENT_CHATS = 20;
222
- // Feature flag: Enable streaming mode (can be toggled for testing)
223
- const USE_STREAMING = true;
224
- // Load from localStorage
225
- const loadChats = () => {
149
+ const CONSIDERATIONS_API = `${API_BASE}/mcp/considerations`;
150
+ function isEdgeMode() {
151
+ const baseUrl = getApiBaseUrl();
152
+ return baseUrl.includes('railway.app') || baseUrl.includes('workers.dev');
153
+ }
154
+ function getConnectionDisplayText() {
155
+ const baseUrl = getApiBaseUrl();
156
+ if (baseUrl.includes('railway.app'))
157
+ return 'Railway Cloud';
158
+ if (baseUrl.includes('workers.dev'))
159
+ return 'Cloudflare Edge';
160
+ const port = baseUrl.match(/:(\d+)/)?.[1] || '4001';
161
+ return `localhost:${port}`;
162
+ }
163
+ function loadChats() {
226
164
  try {
227
- const stored = localStorage.getItem(STORAGE_KEY);
228
- if (!stored)
229
- return [];
230
- const chats = JSON.parse(stored);
231
- // Sort by lastUpdated and limit
232
- return chats
233
- .sort((a, b) => b.lastUpdated - a.lastUpdated)
234
- .slice(0, MAX_RECENT_CHATS);
165
+ const stored = localStorage.getItem(CHAT_STORAGE_KEY);
166
+ if (stored)
167
+ return JSON.parse(stored);
235
168
  }
236
169
  catch (e) {
237
170
  console.error('Failed to load chats:', e);
238
- return [];
239
171
  }
240
- };
241
- // Save to localStorage
242
- const saveChats = (chats) => {
172
+ return [];
173
+ }
174
+ function saveChats(chats) {
243
175
  try {
244
- // Keep only the most recent chats
245
- const toSave = chats
246
- .sort((a, b) => b.lastUpdated - a.lastUpdated)
247
- .slice(0, MAX_RECENT_CHATS);
248
- localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
176
+ localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(chats));
249
177
  }
250
178
  catch (e) {
251
179
  console.error('Failed to save chats:', e);
252
180
  }
253
- };
254
- // Sync with memory stories from backend
255
- const syncWithActualStories = async () => {
256
- try {
257
- const response = await fetch(STORIES_API);
258
- if (!response.ok) {
259
- console.error('Failed to fetch stories from backend');
260
- return loadChats();
261
- }
262
- // Check if response is JSON
263
- const contentType = response.headers.get('content-type');
264
- if (!contentType || !contentType.includes('application/json')) {
265
- console.error('Server returned non-JSON response, likely server not running or wrong port');
266
- return loadChats();
267
- }
268
- const data = await response.json();
269
- const memoryStories = data.stories || [];
270
- // Load existing chats
271
- const existingChats = loadChats();
272
- // Create a map for quick lookup - using chat.id as the primary key
273
- const chatMap = new Map();
274
- existingChats.forEach(chat => {
275
- chatMap.set(chat.id, chat);
276
- });
277
- // Update or add memory stories
278
- memoryStories.forEach((story) => {
279
- const storyId = story.storyId || story.fileName;
280
- // Look for existing chat by ID or by matching fileName
281
- let existingChat = chatMap.get(storyId);
282
- // If not found by ID, search by fileName
283
- if (!existingChat && story.fileName) {
284
- for (const [id, chat] of chatMap.entries()) {
285
- if (chat.fileName === story.fileName) {
286
- existingChat = chat;
287
- break;
288
- }
289
- }
290
- }
291
- if (existingChat) {
292
- // Update existing chat with latest info
293
- existingChat.title = story.title || existingChat.title;
294
- existingChat.fileName = story.fileName || existingChat.fileName;
295
- existingChat.lastUpdated = new Date(story.updatedAt || story.createdAt).getTime();
296
- }
297
- else {
298
- // Create new chat from memory story
299
- const newChat = {
300
- id: storyId,
301
- title: story.title || story.fileName,
302
- fileName: story.fileName,
303
- conversation: [{
304
- role: 'user',
305
- content: story.prompt || `Generate ${story.title}`
306
- }, {
307
- role: 'ai',
308
- content: `[SUCCESS] Created story: "${story.title}"\n\nThis story was recovered from memory. You can continue updating it or view it in Storybook.`
309
- }],
310
- lastUpdated: new Date(story.updatedAt || story.createdAt).getTime()
311
- };
312
- chatMap.set(storyId, newChat);
313
- }
314
- });
315
- // Convert back to array and save
316
- const syncedChats = Array.from(chatMap.values());
317
- saveChats(syncedChats);
318
- return syncedChats;
319
- }
320
- catch (error) {
321
- console.error('Error syncing with backend:', error);
322
- return loadChats();
323
- }
324
- };
325
- // Delete story and chat
326
- const deleteStoryAndChat = async (chatId) => {
327
- try {
328
- // Remove .stories.tsx extension if present to get the actual story ID
329
- const storyId = chatId.replace(/\.stories\.tsx$/, '');
330
- console.log(`Attempting to delete story: chatId="${chatId}", storyId="${storyId}"`);
331
- let serverDeleteSucceeded = false;
332
- // First try to delete from backend
333
- try {
334
- const response = await fetch(`${DELETE_API_BASE}/${storyId}`, {
335
- method: 'DELETE',
336
- headers: { 'Content-Type': 'application/json' }
337
- });
338
- // 404 means story doesn't exist on server - that's OK, we can still clean up localStorage
339
- if (response.ok || response.status === 404) {
340
- serverDeleteSucceeded = true;
341
- if (response.status === 404) {
342
- console.log('Story not found on server (may have been a failed generation), cleaning up localStorage');
343
- }
344
- }
345
- else {
346
- console.warn(`Backend delete returned ${response.status}, trying legacy endpoint`);
347
- }
348
- }
349
- catch (fetchError) {
350
- console.warn('Backend delete request failed, trying legacy endpoint:', fetchError);
351
- }
352
- // Try legacy endpoint as fallback only if primary didn't succeed
353
- if (!serverDeleteSucceeded) {
354
- try {
355
- const legacyResponse = await fetch(`${API_BASE}/story-ui/delete`, {
356
- method: 'POST',
357
- headers: { 'Content-Type': 'application/json' },
358
- body: JSON.stringify({
359
- chatId: storyId,
360
- storyId: storyId
361
- })
362
- });
363
- // 404 is also OK for legacy endpoint
364
- if (legacyResponse.ok || legacyResponse.status === 404) {
365
- serverDeleteSucceeded = true;
366
- }
367
- else {
368
- console.warn('Legacy delete endpoint also returned non-success status');
369
- }
370
- }
371
- catch (legacyError) {
372
- console.warn('Legacy delete request failed:', legacyError);
373
- }
374
- }
375
- // Always clean up localStorage - the chat/story data is primarily client-side
376
- // Even if server delete failed, we should allow users to clean up their chat history
377
- const chats = loadChats().filter(chat => chat.id !== chatId);
378
- saveChats(chats);
379
- console.log('Cleaned up localStorage chat entry');
380
- return true;
381
- }
382
- catch (error) {
383
- console.error('Error deleting story:', error);
384
- // Still try to clean up localStorage even on error
385
- try {
386
- const chats = loadChats().filter(chat => chat.id !== chatId);
387
- saveChats(chats);
388
- console.log('Cleaned up localStorage despite error');
389
- return true;
390
- }
391
- catch (localError) {
392
- console.error('Failed to clean up localStorage:', localError);
393
- return false;
394
- }
395
- }
396
- };
397
- // Test connection to MCP server
398
- const testMCPConnection = async () => {
181
+ }
182
+ async function testMCPConnection() {
399
183
  try {
400
- const response = await fetch(STORIES_API, {
401
- method: 'GET',
402
- headers: { 'Content-Type': 'application/json' },
403
- });
404
- if (!response.ok) {
405
- return { connected: false, error: `HTTP ${response.status}: ${response.statusText}` };
406
- }
407
- const contentType = response.headers.get('content-type');
408
- if (!contentType || !contentType.includes('application/json')) {
409
- return { connected: false, error: 'Server returned non-JSON response (likely wrong port or server not running)' };
410
- }
411
- return { connected: true };
184
+ const response = await fetch(PROVIDERS_API, { method: 'GET' });
185
+ if (response.ok)
186
+ return { connected: true };
187
+ return { connected: false, error: `Server returned ${response.status}` };
412
188
  }
413
- catch (error) {
414
- return { connected: false, error: error instanceof Error ? error.message : 'Unknown error' };
189
+ catch (e) {
190
+ return { connected: false, error: 'Cannot connect to MCP server' };
415
191
  }
416
- };
417
- // Fetch orphan stories (stories on disk without corresponding chat history)
418
- const fetchOrphanStories = async () => {
192
+ }
193
+ // Simply load chats from localStorage - don't filter based on server state
194
+ // Chats should persist independently of whether story files exist
195
+ async function syncWithActualStories() {
196
+ return loadChats();
197
+ }
198
+ async function fetchOrphanStories() {
419
199
  try {
420
200
  const response = await fetch(STORIES_API);
421
- if (!response.ok) {
422
- console.error('Failed to fetch stories from backend for orphan detection');
201
+ if (!response.ok)
423
202
  return [];
424
- }
425
- const contentType = response.headers.get('content-type');
426
- if (!contentType || !contentType.includes('application/json')) {
427
- console.error('Server returned non-JSON response for orphan detection');
428
- return [];
429
- }
430
203
  const data = await response.json();
431
204
  const serverStories = data.stories || [];
432
- // Load current chats from localStorage
433
- const existingChats = loadChats();
434
- const chatIds = new Set(existingChats.map(chat => chat.id));
435
- const chatFileNames = new Set(existingChats.map(chat => chat.fileName).filter(Boolean));
436
- // Find stories that don't have a matching chat
437
- const orphans = [];
438
- serverStories.forEach((story) => {
439
- const storyId = story.id || story.storyId || story.fileName;
440
- const fileName = story.fileName || '';
441
- // Check if this story has a corresponding chat
442
- const hasMatchingChat = chatIds.has(storyId) || chatFileNames.has(fileName);
443
- if (!hasMatchingChat && fileName) {
444
- orphans.push({
445
- id: storyId,
446
- fileName: fileName,
447
- title: story.title || fileName.replace(/\.stories\.(tsx|ts|jsx|js)$/, ''),
448
- createdAt: new Date(story.createdAt || Date.now()).getTime(),
449
- });
450
- }
451
- });
452
- // Sort by creation date, newest first
453
- return orphans.sort((a, b) => b.createdAt - a.createdAt);
205
+ const localChats = loadChats();
206
+ const chatIds = new Set(localChats.map(c => c.id));
207
+ return serverStories
208
+ .filter((s) => !chatIds.has(s.id))
209
+ .map((s) => ({ id: s.id, title: s.title, fileName: s.fileName }));
454
210
  }
455
- catch (error) {
456
- console.error('Error fetching orphan stories:', error);
211
+ catch (e) {
457
212
  return [];
458
213
  }
459
- };
460
- // Component styles
461
- const STYLES = {
462
- container: {
463
- display: 'flex',
464
- flexDirection: 'row',
465
- fontFamily: '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif',
466
- height: '100vh',
467
- overflow: 'hidden',
468
- background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
469
- color: '#e2e8f0',
470
- fontSize: '14px',
471
- lineHeight: '1.5',
472
- },
473
- // Sidebar
474
- sidebar: {
475
- width: '240px',
476
- background: 'rgba(255, 255, 255, 0.03)',
477
- borderRight: '1px solid rgba(255, 255, 255, 0.08)',
478
- display: 'flex',
479
- flexDirection: 'column',
480
- backdropFilter: 'blur(10px)',
481
- transition: 'width 0.3s ease',
482
- position: 'relative',
483
- },
484
- sidebarCollapsed: {
485
- width: '56px',
486
- },
487
- sidebarToggle: {
488
- width: '100%',
489
- padding: '10px 14px',
490
- background: 'rgba(59, 130, 246, 0.15)',
491
- color: '#e2e8f0',
492
- border: '1px solid rgba(59, 130, 246, 0.3)',
493
- borderRadius: '8px',
494
- fontSize: '14px',
495
- fontWeight: '600',
496
- cursor: 'pointer',
497
- marginBottom: '8px',
498
- transition: 'all 0.2s ease',
499
- boxShadow: 'none',
500
- display: 'flex',
501
- alignItems: 'center',
502
- justifyContent: 'flex-start',
503
- gap: '10px',
504
- lineHeight: '1',
505
- },
506
- newChatButton: {
507
- width: '100%',
508
- padding: '10px 14px',
509
- background: '#3b82f6',
510
- color: 'white',
511
- border: 'none',
512
- borderRadius: '8px',
513
- fontSize: '14px',
514
- fontWeight: '600',
515
- cursor: 'pointer',
516
- marginBottom: '16px',
517
- transition: 'all 0.2s ease',
518
- boxShadow: '0 2px 8px rgba(59, 130, 246, 0.25)',
519
- display: 'flex',
520
- alignItems: 'center',
521
- justifyContent: 'flex-start',
522
- gap: '10px',
523
- lineHeight: '1',
524
- },
525
- chatItem: {
526
- padding: '8px 12px',
527
- marginBottom: '4px',
528
- background: 'rgba(255, 255, 255, 0.05)',
529
- borderRadius: '6px',
530
- cursor: 'pointer',
531
- transition: 'all 0.15s ease',
532
- position: 'relative',
533
- paddingRight: '32px',
534
- },
535
- chatItemActive: {
536
- background: 'rgba(59, 130, 246, 0.15)',
537
- borderLeft: '2px solid #3b82f6',
538
- },
539
- chatItemTitle: {
540
- fontSize: '14px',
541
- fontWeight: '500',
542
- marginBottom: '2px',
543
- whiteSpace: 'nowrap',
544
- overflow: 'hidden',
545
- textOverflow: 'ellipsis',
546
- },
547
- chatItemTime: {
548
- fontSize: '12px',
549
- color: '#94a3b8',
550
- },
551
- deleteButton: {
552
- position: 'absolute',
553
- right: '8px',
554
- top: '50%',
555
- transform: 'translateY(-50%)',
556
- background: 'rgba(239, 68, 68, 0.8)',
557
- color: 'white',
558
- border: 'none',
559
- borderRadius: '4px',
560
- padding: '4px 8px',
561
- fontSize: '12px',
562
- cursor: 'pointer',
563
- opacity: 0,
564
- transition: 'opacity 0.2s ease',
565
- },
566
- // Main content
567
- mainContent: {
568
- flex: 1,
569
- display: 'flex',
570
- flexDirection: 'column',
571
- overflow: 'hidden',
572
- },
573
- chatHeader: {
574
- padding: '12px 16px',
575
- borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
576
- background: 'rgba(255, 255, 255, 0.03)',
577
- backdropFilter: 'blur(10px)',
578
- },
579
- chatContainer: {
580
- flex: 1,
581
- padding: '16px',
582
- overflowY: 'auto',
583
- scrollBehavior: 'smooth',
584
- },
585
- emptyState: {
586
- color: '#94a3b8',
587
- textAlign: 'center',
588
- marginTop: '60px',
589
- },
590
- emptyStateTitle: {
591
- fontSize: '15px',
592
- fontWeight: '500',
593
- marginBottom: '8px',
594
- color: '#cbd5e1',
595
- },
596
- emptyStateSubtitle: {
597
- fontSize: '13px',
598
- color: '#64748b',
599
- },
600
- // Message bubbles
601
- messageContainer: {
602
- display: 'flex',
603
- marginBottom: '8px',
604
- },
605
- userMessage: {
606
- background: 'rgba(59, 130, 246, 0.12)',
607
- color: '#e2e8f0',
608
- borderRadius: '16px 16px 4px 16px',
609
- padding: '10px 14px',
610
- maxWidth: '85%',
611
- marginLeft: 'auto',
612
- fontSize: '14px',
613
- lineHeight: '1.45',
614
- boxShadow: 'none',
615
- wordWrap: 'break-word',
616
- border: '1px solid rgba(59, 130, 246, 0.2)',
617
- },
618
- aiMessage: {
619
- background: 'rgba(255, 255, 255, 0.95)',
620
- color: '#1f2937',
621
- borderRadius: '16px 16px 16px 4px',
622
- padding: '10px 14px',
623
- maxWidth: '90%',
624
- fontSize: '14px',
625
- lineHeight: '1.45',
626
- boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
627
- border: '1px solid rgba(0, 0, 0, 0.08)',
628
- wordWrap: 'break-word',
629
- whiteSpace: 'pre-wrap',
630
- },
631
- loadingMessage: {
632
- background: 'rgba(255, 255, 255, 0.95)',
633
- color: '#4b5563',
634
- borderRadius: '16px 16px 16px 4px',
635
- padding: '10px 14px',
636
- fontSize: '14px',
637
- lineHeight: '1.45',
638
- display: 'flex',
639
- alignItems: 'center',
640
- gap: '8px',
641
- border: '1px solid rgba(0, 0, 0, 0.08)',
642
- boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
643
- },
644
- // Input form
645
- inputForm: {
646
- display: 'flex',
647
- alignItems: 'center',
648
- gap: '12px',
649
- margin: '0 16px 16px 16px',
650
- padding: '12px',
651
- background: 'rgba(255, 255, 255, 0.03)',
652
- borderRadius: '12px',
653
- border: '1px solid rgba(255, 255, 255, 0.08)',
654
- backdropFilter: 'blur(10px)',
655
- },
656
- textInput: {
657
- font: 'inherit',
658
- flex: 1,
659
- padding: '12px 16px',
660
- borderRadius: '8px',
661
- border: '1px solid rgba(255, 255, 255, 0.15)',
662
- fontSize: '13px',
663
- color: '#1f2937',
664
- background: '#ffffff',
665
- outline: 'none',
666
- transition: 'all 0.15s ease',
667
- boxSizing: 'border-box',
668
- },
669
- sendButton: {
670
- font: 'inherit',
671
- padding: '10px 16px',
672
- borderRadius: '10px',
673
- border: 'none',
674
- background: '#3b82f6',
675
- color: 'white',
676
- fontSize: '14px',
677
- fontWeight: '600',
678
- cursor: 'pointer',
679
- display: 'flex',
680
- alignItems: 'center',
681
- gap: '6px',
682
- transition: 'all 0.2s ease',
683
- boxShadow: '0 2px 8px rgba(59, 130, 246, 0.35)',
684
- flexShrink: 0,
685
- },
686
- errorMessage: {
687
- background: 'rgba(248, 113, 113, 0.1)',
688
- color: '#f87171',
689
- padding: '8px 12px',
690
- borderRadius: '6px',
691
- fontSize: '13px',
692
- marginBottom: '8px',
693
- border: '1px solid rgba(248, 113, 113, 0.2)',
694
- },
695
- loadingDots: {
696
- display: 'inline-block',
697
- animation: 'loadingDots 1.4s infinite',
698
- },
699
- '@keyframes loadingDots': {
700
- '0%': { content: '""' },
701
- '25%': { content: '"."' },
702
- '50%': { content: '".."' },
703
- '75%': { content: '"..."' },
704
- },
705
- codeBlock: {
706
- background: '#1e293b',
707
- padding: '12px',
708
- borderRadius: '8px',
709
- fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
710
- fontSize: '12px',
711
- lineHeight: '1.5',
712
- overflowX: 'auto',
713
- marginTop: '8px',
714
- border: '1px solid rgba(255, 255, 255, 0.08)',
715
- },
716
- // Streaming progress styles
717
- streamingContainer: {
718
- background: 'rgba(255, 255, 255, 0.95)',
719
- borderRadius: '16px 16px 16px 4px',
720
- padding: '10px 14px',
721
- maxWidth: '90%',
722
- boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
723
- border: '1px solid rgba(0, 0, 0, 0.08)',
724
- fontSize: '14px',
725
- lineHeight: '1.45',
726
- },
727
- intentPreview: {
728
- background: 'rgba(59, 130, 246, 0.06)',
729
- borderRadius: '8px',
730
- padding: '10px 12px',
731
- marginBottom: '10px',
732
- border: '1px solid rgba(59, 130, 246, 0.12)',
733
- },
734
- intentTitle: {
735
- fontSize: '13px',
736
- fontWeight: '600',
737
- color: '#1e40af',
738
- marginBottom: '8px',
739
- display: 'flex',
740
- alignItems: 'center',
741
- gap: '4px',
742
- },
743
- intentStrategy: {
744
- fontSize: '12px',
745
- color: '#4b5563',
746
- marginBottom: '4px',
747
- },
748
- intentComponents: {
749
- display: 'flex',
750
- flexWrap: 'wrap',
751
- gap: '4px',
752
- marginTop: '8px',
753
- },
754
- componentTag: {
755
- background: 'rgba(59, 130, 246, 0.12)',
756
- color: '#1d4ed8',
757
- fontSize: '11px',
758
- padding: '2px 8px',
759
- borderRadius: '10px',
760
- fontWeight: '500',
761
- },
762
- progressBar: {
763
- background: 'rgba(0, 0, 0, 0.08)',
764
- borderRadius: '4px',
765
- height: '4px',
766
- marginTop: '12px',
767
- marginBottom: '8px',
768
- overflow: 'hidden',
769
- },
770
- progressFill: {
771
- background: '#3b82f6',
772
- height: '100%',
773
- borderRadius: '3px',
774
- transition: 'width 0.3s ease',
775
- },
776
- progressPhase: {
777
- fontSize: '14px',
778
- color: '#4b5563',
779
- display: 'flex',
780
- alignItems: 'center',
781
- gap: '6px',
782
- fontWeight: '500',
783
- lineHeight: '1.45',
784
- },
785
- phaseIcon: {
786
- fontSize: '14px',
787
- },
788
- validationBox: {
789
- marginTop: '8px',
790
- padding: '8px',
791
- borderRadius: '6px',
792
- fontSize: '11px',
793
- },
794
- validationSuccess: {
795
- background: 'rgba(16, 185, 129, 0.08)',
796
- border: '1px solid rgba(16, 185, 129, 0.15)',
797
- color: '#047857',
798
- },
799
- validationWarning: {
800
- background: 'rgba(245, 158, 11, 0.08)',
801
- border: '1px solid rgba(245, 158, 11, 0.15)',
802
- color: '#b45309',
803
- },
804
- validationError: {
805
- background: 'rgba(239, 68, 68, 0.08)',
806
- border: '1px solid rgba(239, 68, 68, 0.15)',
807
- color: '#dc2626',
808
- },
809
- retryBadge: {
810
- background: 'rgba(245, 158, 11, 0.12)',
811
- color: '#b45309',
812
- fontSize: '11px',
813
- padding: '2px 8px',
814
- borderRadius: '10px',
815
- display: 'inline-flex',
816
- alignItems: 'center',
817
- gap: '4px',
818
- marginTop: '8px',
819
- },
820
- completionSummary: {
821
- marginTop: '10px',
822
- paddingTop: '10px',
823
- borderTop: '1px solid rgba(0, 0, 0, 0.06)',
824
- },
825
- summaryTitle: {
826
- fontSize: '14px',
827
- fontWeight: '600',
828
- color: '#111827',
829
- marginBottom: '6px',
830
- display: 'flex',
831
- alignItems: 'center',
832
- gap: '6px',
833
- lineHeight: '1.45',
834
- },
835
- summaryDescription: {
836
- fontSize: '14px',
837
- color: '#4b5563',
838
- lineHeight: '1.45',
839
- },
840
- metricsRow: {
841
- display: 'flex',
842
- gap: '10px',
843
- marginTop: '6px',
844
- fontSize: '13px',
845
- color: '#6b7280',
846
- },
847
- metric: {
848
- display: 'flex',
849
- alignItems: 'center',
850
- gap: '4px',
851
- },
852
- // Code viewer styles for generated stories
853
- codeViewerContainer: {
854
- marginTop: '12px',
855
- borderTop: '1px solid rgba(0, 0, 0, 0.08)',
856
- paddingTop: '12px',
857
- },
858
- codeViewerToggle: {
859
- display: 'flex',
860
- alignItems: 'center',
861
- justifyContent: 'space-between',
862
- padding: '8px 12px',
863
- background: 'rgba(59, 130, 246, 0.08)',
864
- borderRadius: '6px',
865
- cursor: 'pointer',
866
- border: '1px solid rgba(59, 130, 246, 0.15)',
867
- transition: 'all 0.2s ease',
868
- fontSize: '13px',
869
- fontWeight: '500',
870
- color: '#1e40af',
871
- },
872
- codeViewerToggleHover: {
873
- background: 'rgba(59, 130, 246, 0.15)',
874
- },
875
- codeViewerContent: {
876
- marginTop: '12px',
877
- background: '#1e293b',
878
- borderRadius: '8px',
879
- overflow: 'hidden',
880
- border: '1px solid rgba(255, 255, 255, 0.08)',
881
- },
882
- codeViewerHeader: {
883
- display: 'flex',
884
- alignItems: 'center',
885
- justifyContent: 'space-between',
886
- padding: '8px 12px',
887
- background: 'rgba(0, 0, 0, 0.2)',
888
- borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
889
- },
890
- codeViewerFileName: {
891
- fontSize: '12px',
892
- color: '#94a3b8',
893
- fontFamily: 'ui-monospace, monospace',
894
- },
895
- copyButton: {
896
- padding: '4px 12px',
897
- fontSize: '11px',
898
- fontWeight: '500',
899
- color: '#e2e8f0',
900
- background: 'rgba(59, 130, 246, 0.3)',
901
- border: '1px solid rgba(59, 130, 246, 0.5)',
902
- borderRadius: '4px',
903
- cursor: 'pointer',
904
- transition: 'all 0.2s ease',
905
- },
906
- copyButtonSuccess: {
907
- background: 'rgba(34, 197, 94, 0.3)',
908
- borderColor: 'rgba(34, 197, 94, 0.5)',
909
- color: '#86efac',
910
- },
911
- codeViewerPre: {
912
- margin: 0,
913
- padding: '12px',
914
- fontSize: '11px',
915
- lineHeight: '1.5',
916
- fontFamily: 'ui-monospace, Consolas, Monaco, monospace',
917
- color: '#e2e8f0',
918
- overflowX: 'auto',
919
- maxHeight: '400px',
920
- overflowY: 'auto',
921
- },
922
- // Image upload styles
923
- uploadButton: {
924
- display: 'flex',
925
- alignItems: 'center',
926
- justifyContent: 'center',
927
- width: '36px',
928
- height: '36px',
929
- borderRadius: '6px',
930
- border: '1px solid rgba(255, 255, 255, 0.15)',
931
- background: 'rgba(255, 255, 255, 0.08)',
932
- color: '#e2e8f0',
933
- cursor: 'pointer',
934
- transition: 'all 0.2s ease',
935
- flexShrink: 0,
936
- },
937
- uploadButtonHover: {
938
- background: 'rgba(59, 130, 246, 0.2)',
939
- borderColor: 'rgba(59, 130, 246, 0.5)',
940
- },
941
- imagePreviewContainer: {
942
- display: 'flex',
943
- flexWrap: 'wrap',
944
- gap: '8px',
945
- padding: '8px 12px',
946
- background: 'rgba(255, 255, 255, 0.03)',
947
- borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
948
- margin: '0 16px',
949
- borderRadius: '8px 8px 0 0',
950
- },
951
- imagePreviewItem: {
952
- position: 'relative',
953
- width: '56px',
954
- height: '56px',
955
- borderRadius: '6px',
956
- overflow: 'hidden',
957
- border: '1px solid rgba(255, 255, 255, 0.15)',
958
- background: '#1e293b',
959
- },
960
- imagePreviewImg: {
961
- width: '100%',
962
- height: '100%',
963
- objectFit: 'cover',
964
- },
965
- imageRemoveButton: {
966
- position: 'absolute',
967
- top: '2px',
968
- right: '2px',
969
- width: '18px',
970
- height: '18px',
971
- borderRadius: '50%',
972
- background: 'rgba(239, 68, 68, 0.9)',
973
- color: 'white',
974
- border: 'none',
975
- fontSize: '12px',
976
- cursor: 'pointer',
977
- display: 'flex',
978
- alignItems: 'center',
979
- justifyContent: 'center',
980
- lineHeight: 1,
981
- },
982
- imagePreviewLabel: {
983
- display: 'flex',
984
- alignItems: 'center',
985
- gap: '8px',
986
- fontSize: '12px',
987
- color: '#94a3b8',
988
- marginRight: 'auto',
989
- },
990
- userMessageImages: {
991
- display: 'flex',
992
- gap: '8px',
993
- marginTop: '8px',
994
- flexWrap: 'wrap',
995
- },
996
- userMessageImage: {
997
- width: '40px',
998
- height: '40px',
999
- borderRadius: '6px',
1000
- objectFit: 'cover',
1001
- border: '1px solid rgba(255, 255, 255, 0.25)',
1002
- },
1003
- // Drag and drop overlay
1004
- dropOverlay: {
1005
- position: 'absolute',
1006
- top: 0,
1007
- left: 0,
1008
- right: 0,
1009
- bottom: 0,
1010
- background: 'rgba(59, 130, 246, 0.12)',
1011
- border: '2px dashed rgba(59, 130, 246, 0.4)',
1012
- borderRadius: '10px',
1013
- display: 'flex',
1014
- alignItems: 'center',
1015
- justifyContent: 'center',
1016
- zIndex: 100,
1017
- backdropFilter: 'blur(3px)',
1018
- },
1019
- dropOverlayText: {
1020
- background: 'rgba(59, 130, 246, 0.85)',
1021
- color: 'white',
1022
- padding: '12px 24px',
1023
- borderRadius: '8px',
1024
- fontSize: '14px',
1025
- fontWeight: '500',
1026
- display: 'flex',
1027
- alignItems: 'center',
1028
- gap: '8px',
1029
- boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
1030
- },
1031
- };
1032
- // Add custom style for loading animation and IBM Plex Sans font
1033
- // Use a unique ID to prevent duplicate stylesheets during HMR
1034
- const STYLESHEET_ID = 'story-ui-panel-styles';
1035
- if (!document.getElementById(STYLESHEET_ID)) {
1036
- // Load IBM Plex Sans font
1037
- const fontLink = document.createElement('link');
1038
- fontLink.rel = 'stylesheet';
1039
- fontLink.href = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap';
1040
- document.head.appendChild(fontLink);
1041
- const styleSheet = document.createElement('style');
1042
- styleSheet.id = STYLESHEET_ID;
1043
- styleSheet.textContent = `
1044
- @keyframes loadingDots {
1045
- 0%, 20% { content: "."; }
1046
- 40% { content: ".."; }
1047
- 60%, 100% { content: "..."; }
1048
- }
1049
-
1050
- .loading-dots::after {
1051
- content: ".";
1052
- animation: loadingDots 1.4s infinite;
1053
- }
1054
-
1055
- /* Override Storybook's default styles with !important */
1056
- .story-ui-panel,
1057
- .story-ui-panel * {
1058
- font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
1059
- }
1060
-
1061
- .story-ui-panel {
1062
- font-size: 14px !important;
1063
- line-height: 1.5 !important;
1064
- }
1065
-
1066
- /* Message bubbles - consistent styling */
1067
- .story-ui-message {
1068
- font-size: 16px !important;
1069
- line-height: 1.45 !important;
1070
- padding: 12px 16px !important;
1071
- }
1072
-
1073
- .story-ui-user-message {
1074
- background: rgba(59, 130, 246, 0.12) !important;
1075
- color: #e2e8f0 !important;
1076
- border-radius: 18px 18px 4px 18px !important;
1077
- border: 1px solid rgba(59, 130, 246, 0.2) !important;
1078
- }
1079
-
1080
- .story-ui-ai-message {
1081
- background: rgba(255, 255, 255, 0.97) !important;
1082
- color: #1f2937 !important;
1083
- border-radius: 18px 18px 18px 4px !important;
1084
- border: 1px solid rgba(0, 0, 0, 0.08) !important;
1085
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
1086
- }
1087
-
1088
- /* Override nested elements in AI messages (from renderMarkdown)
1089
- .story-ui-ai-message p,
1090
- .story-ui-ai-message span,
1091
- .story-ui-ai-message strong,
1092
- .story-ui-ai-message em,
1093
- .story-ui-ai-message li,
1094
- .story-ui-ai-message ul,
1095
- .story-ui-ai-message ol {
1096
- font-size: 14px !important;
1097
- line-height: 1.45 !important;
1098
- margin: 0 !important;
1099
- } */
1100
-
1101
- .story-ui-ai-message p + p {
1102
- margin-top: 8px !important;
1103
- }
1104
-
1105
- /* Status text */
1106
- .story-ui-status {
1107
- font-size: 13px !important;
1108
- font-weight: 400 !important;
1109
- }
1110
-
1111
- .story-ui-status-connected {
1112
- color: #10b981 !important;
1113
- }
1114
-
1115
- .story-ui-status-disconnected {
1116
- color: #ef4444 !important;
1117
- }
1118
-
1119
- /* Sidebar buttons */
1120
- .story-ui-sidebar button {
1121
- font-size: 14px !important;
1122
- font-weight: 600 !important;
1123
- }
1124
-
1125
- /* Header text */
1126
- .story-ui-header h1 {
1127
- font-size: 24px !important;
1128
- font-weight: 700 !important;
1129
- }
1130
-
1131
- .story-ui-header p {
1132
- font-size: 14px !important;
1133
- color: #94a3b8 !important;
1134
- }
1135
-
1136
- /* Input field */
1137
- .story-ui-input {
1138
- font-size: 14px !important;
1139
- font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
1140
- }
1141
-
1142
- /* Code blocks in messages */
1143
- .story-ui-ai-message code {
1144
- font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace !important;
1145
- font-size: 13px !important;
1146
- background: rgba(0, 0, 0, 0.06) !important;
1147
- padding: 2px 6px !important;
1148
- border-radius: 4px !important;
1149
- }
1150
- `;
1151
- document.head.appendChild(styleSheet);
1152
214
  }
1153
- // Helper function to format timestamp
1154
- const formatTime = (timestamp) => {
1155
- // Handle invalid timestamps
1156
- if (!timestamp || isNaN(timestamp) || timestamp <= 0) {
1157
- return '';
215
+ async function deleteStoryAndChat(chatId) {
216
+ try {
217
+ const response = await fetch(`${STORIES_API}/${chatId}`, { method: 'DELETE' });
218
+ // Delete chat from localStorage if:
219
+ // - Story was successfully deleted (200/204)
220
+ // - Story doesn't exist (404) - orphan chat case
221
+ if (response.ok || response.status === 404) {
222
+ const chats = loadChats().filter(c => c.id !== chatId);
223
+ saveChats(chats);
224
+ return true;
225
+ }
226
+ return false;
1158
227
  }
1159
- const date = new Date(timestamp);
1160
- // Check if the date is valid
1161
- if (isNaN(date.getTime())) {
1162
- return '';
228
+ catch (e) {
229
+ // On network error, still allow removing the chat from localStorage
230
+ // since the story file may not exist anyway
231
+ const chats = loadChats().filter(c => c.id !== chatId);
232
+ saveChats(chats);
233
+ return true;
1163
234
  }
1164
- const now = new Date();
1165
- const diffMs = now.getTime() - date.getTime();
1166
- const diffMins = Math.floor(diffMs / 60000);
1167
- const diffHours = Math.floor(diffMs / 3600000);
1168
- const diffDays = Math.floor(diffMs / 86400000);
1169
- if (diffMins < 1)
1170
- return 'just now';
1171
- if (diffMins < 60)
1172
- return `${diffMins}m ago`;
1173
- if (diffHours < 24)
1174
- return `${diffHours}h ago`;
1175
- if (diffDays < 7)
1176
- return `${diffDays}d ago`;
1177
- return date.toLocaleDateString();
1178
- };
1179
- // Helper to get phase text (no icons - cleaner UI)
1180
- const getPhaseInfo = (phase) => {
1181
- const phases = {
1182
- config_loaded: { text: 'Loading configuration' },
1183
- components_discovered: { text: 'Discovering components' },
1184
- prompt_built: { text: 'Building prompt' },
1185
- llm_thinking: { text: 'AI is thinking' },
1186
- code_extracted: { text: 'Extracting code' },
1187
- validating: { text: 'Validating output' },
1188
- post_processing: { text: 'Processing' },
1189
- saving: { text: 'Saving story' },
235
+ }
236
+ function formatTime(timestamp) {
237
+ const now = Date.now();
238
+ const diff = now - timestamp;
239
+ const minutes = Math.floor(diff / 60000);
240
+ const hours = Math.floor(diff / 3600000);
241
+ const days = Math.floor(diff / 86400000);
242
+ if (minutes < 1)
243
+ return 'Just now';
244
+ if (minutes < 60)
245
+ return `${minutes}m ago`;
246
+ if (hours < 24)
247
+ return `${hours}h ago`;
248
+ if (days < 7)
249
+ return `${days}d ago`;
250
+ return new Date(timestamp).toLocaleDateString();
251
+ }
252
+ function getModelDisplayName(model) {
253
+ const displayNames = {
254
+ 'claude-opus-4-5-20251101': 'Claude Opus 4.5',
255
+ 'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5',
256
+ 'claude-haiku-4-5-20251001': 'Claude Haiku 4.5',
257
+ 'gpt-4o': 'GPT-4o',
258
+ 'gpt-4o-mini': 'GPT-4o Mini',
259
+ 'o1': 'o1',
260
+ 'gemini-2.0-flash': 'Gemini 2.0 Flash',
261
+ 'gemini-1.5-pro': 'Gemini 1.5 Pro',
1190
262
  };
1191
- return phases[phase] || { text: 'Working' };
263
+ return displayNames[model] || model;
264
+ }
265
+ // ============================================
266
+ // Icons (Lucide-style SVG)
267
+ // ============================================
268
+ const Icons = {
269
+ plus: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M5 12h14" }), _jsx("path", { d: "M12 5v14" })] })),
270
+ messageSquare: (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }) })),
271
+ panelLeft: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }), _jsx("path", { d: "M9 3v18" })] })),
272
+ x: (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M18 6 6 18" }), _jsx("path", { d: "m6 6 12 12" })] })),
273
+ image: (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }), _jsx("circle", { cx: "9", cy: "9", r: "2" }), _jsx("path", { d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" })] })),
274
+ send: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "m22 2-7 20-4-9-9-4Z" }), _jsx("path", { d: "M22 2 11 13" })] })),
275
+ chevronDown: (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m6 9 6 6 6-6" }) })),
276
+ trash: (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", 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" })] })),
277
+ sparkles: (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" }), _jsx("path", { d: "M5 3v4" }), _jsx("path", { d: "M19 17v4" }), _jsx("path", { d: "M3 5h4" }), _jsx("path", { d: "M17 19h4" })] })),
278
+ moreVertical: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "12", cy: "12", r: "1" }), _jsx("circle", { cx: "12", cy: "5", r: "1" }), _jsx("circle", { cx: "12", cy: "19", r: "1" })] })),
279
+ pencil: (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" }), _jsx("path", { d: "m15 5 4 4" })] })),
280
+ check: (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M20 6 9 17l-5-5" }) })),
281
+ checkCircle: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M22 11.08V12a10 10 0 1 1-5.93-9.14" }), _jsx("path", { d: "M22 4 12 14.01l-3-3" })] })),
282
+ xCircle: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("path", { d: "m15 9-6 6" }), _jsx("path", { d: "m9 9 6 6" })] })),
283
+ lightbulb: (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" }), _jsx("path", { d: "M9 18h6" }), _jsx("path", { d: "M10 22h4" })] })),
284
+ wrench: (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" }) })),
1192
285
  };
1193
- // Streaming Progress Message Component
1194
- const StreamingProgressMessage = ({ streamingData }) => {
1195
- const { intent, progress, validation, retry, completion, error } = streamingData;
1196
- const [showCode, setShowCode] = useState(true); // Show code by default for better UX
1197
- const [copyStatus, setCopyStatus] = useState('idle');
1198
- // Handle copy to clipboard
1199
- const handleCopyCode = async (code) => {
1200
- try {
1201
- await navigator.clipboard.writeText(code);
1202
- setCopyStatus('copied');
1203
- setTimeout(() => setCopyStatus('idle'), 2000);
286
+ // ============================================
287
+ // Markdown Renderer
288
+ // ============================================
289
+ function renderMarkdown(content) {
290
+ const elements = [];
291
+ let key = 0;
292
+ // Split content into blocks (paragraphs, lists, headings)
293
+ const blocks = content.split(/\n\n+/);
294
+ blocks.forEach(block => {
295
+ if (!block.trim())
296
+ return;
297
+ // Check for headings (# ## ###)
298
+ const headingMatch = block.match(/^(#{1,6})\s+(.+)$/);
299
+ if (headingMatch) {
300
+ const level = headingMatch[1].length;
301
+ const text = headingMatch[2];
302
+ const inlineContent = parseInline(text);
303
+ switch (level) {
304
+ case 1:
305
+ elements.push(_jsx("h1", { children: inlineContent }, key++));
306
+ break;
307
+ case 2:
308
+ elements.push(_jsx("h2", { children: inlineContent }, key++));
309
+ break;
310
+ case 3:
311
+ elements.push(_jsx("h3", { children: inlineContent }, key++));
312
+ break;
313
+ case 4:
314
+ elements.push(_jsx("h4", { children: inlineContent }, key++));
315
+ break;
316
+ case 5:
317
+ elements.push(_jsx("h5", { children: inlineContent }, key++));
318
+ break;
319
+ case 6:
320
+ elements.push(_jsx("h6", { children: inlineContent }, key++));
321
+ break;
322
+ }
323
+ return;
1204
324
  }
1205
- catch (err) {
1206
- console.error('Failed to copy:', err);
325
+ // Check for ordered lists (1. 2. 3.)
326
+ const orderedListMatch = block.match(/^(\d+\.\s+.+)$/m);
327
+ if (orderedListMatch) {
328
+ const items = block.split('\n').filter(line => /^\d+\.\s+/.test(line));
329
+ const listItems = items.map((item, i) => {
330
+ const text = item.replace(/^\d+\.\s+/, '');
331
+ return _jsx("li", { children: parseInline(text) }, i);
332
+ });
333
+ elements.push(_jsx("ol", { children: listItems }, key++));
334
+ return;
1207
335
  }
1208
- };
1209
- // If completed, show completion summary
1210
- if (completion) {
1211
- return (_jsx("div", { style: STYLES.streamingContainer, children: _jsxs("div", { style: STYLES.completionSummary, children: [_jsxs("div", { style: STYLES.summaryTitle, children: [completion.success ? StatusIcons.success : StatusIcons.error, " ", completion.title] }), _jsx("div", { style: STYLES.summaryDescription, children: completion.summary.description }), completion.componentsUsed.length > 0 && (_jsxs("div", { style: { marginTop: '12px' }, children: [_jsx("div", { style: { fontSize: '12px', color: '#6b7280', marginBottom: '8px' }, children: "Components used:" }), _jsx("div", { style: STYLES.intentComponents, children: completion.componentsUsed.map((comp, i) => (_jsx("span", { style: STYLES.componentTag, children: comp.name }, i))) })] })), completion.layoutChoices.length > 0 && (_jsxs("div", { style: { marginTop: '12px' }, children: [_jsx("div", { style: { fontSize: '12px', color: '#6b7280', marginBottom: '8px' }, children: "Layout:" }), _jsx("div", { style: { fontSize: '12px', color: '#4b5563' }, children: completion.layoutChoices.map(l => l.pattern).join(', ') })] })), completion.validation && !completion.validation.isValid && (_jsx("div", { style: { ...STYLES.validationBox, ...STYLES.validationWarning }, children: completion.validation.autoFixApplied ? 'Auto-fixed issues' : 'Minor issues detected' })), completion.suggestions && completion.suggestions.length > 0 && (_jsxs("div", { style: { marginTop: '12px', fontSize: '12px', color: '#6b7280', display: 'flex', alignItems: 'flex-start', gap: '6px' }, children: [StatusIcons.tip, " ", _jsx("span", { children: completion.suggestions[0] })] })), completion.metrics && (_jsxs("div", { style: STYLES.metricsRow, children: [_jsxs("span", { style: STYLES.metric, children: [(completion.metrics.totalTimeMs / 1000).toFixed(1), "s"] }), _jsxs("span", { style: STYLES.metric, children: [completion.metrics.llmCallsCount, " LLM calls"] })] })), completion.code && (_jsxs("div", { style: STYLES.codeViewerContainer, children: [_jsxs("div", { style: STYLES.codeViewerToggle, onClick: () => setShowCode(!showCode), role: "button", tabIndex: 0, onKeyDown: (e) => e.key === 'Enter' && setShowCode(!showCode), children: [_jsxs("span", { children: [showCode ? '▼' : '▶', " View Generated Code"] }), _jsx("span", { style: { fontSize: '11px', color: '#6366f1' }, children: completion.fileName })] }), showCode && (_jsxs("div", { style: STYLES.codeViewerContent, children: [_jsxs("div", { style: STYLES.codeViewerHeader, children: [_jsx("span", { style: STYLES.codeViewerFileName, children: completion.fileName }), _jsx("button", { style: {
1212
- ...STYLES.copyButton,
1213
- ...(copyStatus === 'copied' ? STYLES.copyButtonSuccess : {})
1214
- }, onClick: () => handleCopyCode(completion.code), children: copyStatus === 'copied' ? 'Copied' : 'Copy' })] }), _jsx("pre", { style: STYLES.codeViewerPre, children: _jsx("code", { children: completion.code }) })] }))] }))] }) }));
1215
- }
1216
- // If error, show error
336
+ // Check for unordered lists (- or *)
337
+ const unorderedListMatch = block.match(/^[-*]\s+.+$/m);
338
+ if (unorderedListMatch) {
339
+ const items = block.split('\n').filter(line => /^[-*]\s+/.test(line));
340
+ const listItems = items.map((item, i) => {
341
+ const text = item.replace(/^[-*]\s+/, '');
342
+ return _jsx("li", { children: parseInline(text) }, i);
343
+ });
344
+ elements.push(_jsx("ul", { children: listItems }, key++));
345
+ return;
346
+ }
347
+ // Regular paragraph with line breaks preserved
348
+ const lines = block.split('\n');
349
+ const paragraphElements = lines.map((line, i) => (_jsxs(React.Fragment, { children: [parseInline(line), i < lines.length - 1 && _jsx("br", {})] }, i)));
350
+ elements.push(_jsx("p", { children: paragraphElements }, key++));
351
+ });
352
+ return _jsx("div", { className: "sui-markdown", children: elements });
353
+ }
354
+ // Parse inline markdown elements and status icons
355
+ function parseInline(text) {
356
+ const parts = [];
357
+ let remaining = text;
358
+ // Replace status markers with icon components
359
+ // Use {{ICON:n}} format to avoid conflict with markdown underscore patterns
360
+ const iconReplacements = [
361
+ { pattern: /\[SUCCESS\]/g, index: 0, icon: _jsx("span", { className: "sui-icon-inline sui-icon-success", "aria-label": "Success", children: Icons.checkCircle }, "icon-0") },
362
+ { pattern: /\[ERROR\]/g, index: 1, icon: _jsx("span", { className: "sui-icon-inline sui-icon-error", "aria-label": "Error", children: Icons.xCircle }, "icon-1") },
363
+ { pattern: /\[TIP\]/g, index: 2, icon: _jsx("span", { className: "sui-icon-inline sui-icon-tip", "aria-label": "Tip", children: Icons.lightbulb }, "icon-2") },
364
+ { pattern: /\[WRENCH\]/g, index: 3, icon: _jsx("span", { className: "sui-icon-inline sui-icon-wrench", "aria-label": "Auto-fixed", children: Icons.wrench }, "icon-3") },
365
+ ];
366
+ iconReplacements.forEach(({ pattern, index, icon }) => {
367
+ remaining = remaining.replace(pattern, `{{ICON:${index}}}`);
368
+ parts[index] = icon;
369
+ });
370
+ // Parse bold, code, italic, and icon placeholders
371
+ // Icon placeholder {{ICON:n}} uses curly braces to avoid markdown conflicts
372
+ const regex = /(\*\*[^*]+\*\*|`[^`]+`|_[^_]+_|\{\{ICON:\d+\}\})/g;
373
+ const tokens = remaining.split(regex);
374
+ return tokens.map((token, i) => {
375
+ if (token.startsWith('**') && token.endsWith('**')) {
376
+ return _jsx("strong", { children: token.slice(2, -2) }, `inline-${i}`);
377
+ }
378
+ if (token.startsWith('`') && token.endsWith('`')) {
379
+ return _jsx("code", { children: token.slice(1, -1) }, `inline-${i}`);
380
+ }
381
+ if (token.startsWith('_') && token.endsWith('_') && !token.startsWith('{{')) {
382
+ return _jsx("em", { children: token.slice(1, -1) }, `inline-${i}`);
383
+ }
384
+ if (token.startsWith('{{ICON:')) {
385
+ const iconIndex = parseInt(token.match(/\{\{ICON:(\d+)\}\}/)?.[1] || '0');
386
+ return parts[iconIndex] || token;
387
+ }
388
+ return token;
389
+ }).filter(Boolean);
390
+ }
391
+ const Badge = ({ variant = 'default', children, className = '' }) => (_jsx("span", { className: `sui-badge sui-badge-${variant} ${className}`, children: children }));
392
+ const ProgressIndicator = ({ streamingState }) => {
393
+ const { progress, retry, completion, error } = streamingState;
1217
394
  if (error) {
1218
- return (_jsx("div", { style: STYLES.streamingContainer, children: _jsxs("div", { style: { ...STYLES.validationBox, ...STYLES.validationError }, children: [_jsxs("strong", { style: { display: 'flex', alignItems: 'center', gap: '6px' }, children: [StatusIcons.error, " ", error.message] }), error.details && _jsx("div", { style: { marginTop: '4px' }, children: error.details }), error.suggestion && _jsxs("div", { style: { marginTop: '8px', display: 'flex', alignItems: 'flex-start', gap: '6px' }, children: [StatusIcons.tip, " ", _jsx("span", { children: error.suggestion })] })] }) }));
395
+ return (_jsxs("div", { className: "sui-error", role: "alert", children: [_jsx("strong", { children: error.message }), error.details && _jsx("div", { children: error.details }), error.suggestion && _jsx("div", { children: error.suggestion })] }));
396
+ }
397
+ if (completion) {
398
+ return (_jsxs("div", { className: "sui-completion", children: [_jsxs("div", { className: "sui-completion-header", children: [_jsx("span", { children: completion.success ? '\u2705' : '\u274C' }), _jsxs("span", { children: [completion.summary.action, ": ", completion.title] })] }), completion.componentsUsed.length > 0 && (_jsx("div", { className: "sui-completion-components", children: completion.componentsUsed.map((comp, i) => (_jsx("span", { className: "sui-completion-tag", children: comp.name }, i))) })), completion.metrics && (_jsxs("div", { className: "sui-completion-metrics", children: [_jsxs("span", { children: [(completion.metrics.totalTimeMs / 1000).toFixed(1), "s"] }), _jsxs("span", { children: [completion.metrics.llmCallsCount, " LLM calls"] })] }))] }));
1219
399
  }
1220
- // Show progress - simplified to just show status without verbose details
1221
- return (_jsxs("div", { style: STYLES.streamingContainer, children: [_jsxs("div", { style: STYLES.intentPreview, children: [_jsxs("div", { style: STYLES.progressPhase, children: [_jsx("span", { children: "Generating story..." }), progress && (_jsxs("span", { style: { marginLeft: 'auto', color: '#9ca3af' }, children: [progress.step, "/", progress.totalSteps] }))] }), progress && (_jsx("div", { style: { ...STYLES.progressBar, marginTop: '8px' }, children: _jsx("div", { style: {
1222
- ...STYLES.progressFill,
1223
- width: `${(progress.step / progress.totalSteps) * 100}%`
1224
- } }) }))] }), retry && (_jsxs("div", { style: STYLES.retryBadge, children: ["Retry ", retry.attempt, "/", retry.maxAttempts, ": ", retry.reason] })), !progress && !intent && (_jsx("div", { style: STYLES.progressPhase, children: _jsx("span", { className: "loading-dots", children: "Connecting" }) }))] }));
400
+ return (_jsxs("div", { className: "sui-progress", role: "progressbar", "aria-valuenow": progress?.step, "aria-valuemax": progress?.totalSteps, children: [_jsxs("div", { className: "sui-progress-header", children: [_jsx("span", { className: "sui-progress-label", children: progress?.message || 'Generating story...' }), progress && _jsxs("span", { className: "sui-progress-step", children: [progress.step, "/", progress.totalSteps] })] }), progress && (_jsx("div", { className: "sui-progress-bar", children: _jsx("div", { className: "sui-progress-fill", style: { width: `${(progress.step / progress.totalSteps) * 100}%` } }) })), retry && _jsxs("div", { className: "sui-progress-retry", children: ["Retry ", retry.attempt, "/", retry.maxAttempts, ": ", retry.reason] })] }));
1225
401
  };
1226
- // Main component
1227
- function StoryUIPanel() {
1228
- const [input, setInput] = useState('');
1229
- const [conversation, setConversation] = useState([]);
1230
- const [loading, setLoading] = useState(false);
1231
- const [error, setError] = useState(null);
1232
- const [recentChats, setRecentChats] = useState([]);
1233
- const [activeChatId, setActiveChatId] = useState(null);
1234
- const [activeTitle, setActiveTitle] = useState('');
1235
- const [sidebarOpen, setSidebarOpen] = useState(true);
1236
- const [connectionStatus, setConnectionStatus] = useState({ connected: false });
1237
- const [availableProviders, setAvailableProviders] = useState([]);
1238
- const [selectedProvider, setSelectedProvider] = useState('');
1239
- const [selectedModel, setSelectedModel] = useState('');
1240
- const [streamingState, setStreamingState] = useState(null);
1241
- const [attachedImages, setAttachedImages] = useState([]);
1242
- const [considerations, setConsiderations] = useState('');
1243
- const [orphanStories, setOrphanStories] = useState([]);
1244
- const [selectedStoryIds, setSelectedStoryIds] = useState(new Set());
1245
- const [isBulkDeleting, setIsBulkDeleting] = useState(false);
402
+ function StoryUIPanel({ mcpPort }) {
403
+ const [state, dispatch] = useReducer(panelReducer, initialState);
404
+ const [contextMenuId, setContextMenuId] = useState(null);
405
+ const [renamingChatId, setRenamingChatId] = useState(null);
406
+ const [renameValue, setRenameValue] = useState('');
1246
407
  const chatEndRef = useRef(null);
1247
408
  const inputRef = useRef(null);
1248
409
  const fileInputRef = useRef(null);
1249
410
  const abortControllerRef = useRef(null);
1250
- // Maximum images allowed
1251
- const MAX_IMAGES = 4;
1252
- const MAX_IMAGE_SIZE_MB = 20;
1253
- // Helper to convert file to base64
411
+ const hasShownRefreshHint = useRef(false);
412
+ // Set port override if provided
413
+ useEffect(() => {
414
+ if (mcpPort && typeof window !== 'undefined') {
415
+ window.STORY_UI_MCP_PORT = String(mcpPort);
416
+ }
417
+ }, [mcpPort]);
418
+ // Detect Storybook theme
419
+ useEffect(() => {
420
+ const detectTheme = () => {
421
+ const body = document.body;
422
+ const html = document.documentElement;
423
+ // Check URL parameters for Storybook background setting
424
+ const urlParams = new URLSearchParams(window.location.search);
425
+ const globals = urlParams.get('globals') || '';
426
+ const hasStorybookLightBg = globals.includes('backgrounds.value:light');
427
+ const hasStorybookDarkBg = globals.includes('backgrounds.value:dark') ||
428
+ globals.includes('backgrounds.value:%23') || // Hex colors starting with #
429
+ globals.includes('backgrounds.value:!hex');
430
+ // Check parent frame URL if we're in an iframe (Storybook 8+)
431
+ let parentHasDarkBg = false;
432
+ let parentHasLightBg = false;
433
+ let parentHasDarkClass = false;
434
+ try {
435
+ if (window.parent !== window) {
436
+ const parentUrl = new URL(window.parent.location.href);
437
+ const parentGlobals = parentUrl.searchParams.get('globals') || '';
438
+ parentHasLightBg = parentGlobals.includes('backgrounds.value:light');
439
+ parentHasDarkBg = parentGlobals.includes('backgrounds.value:dark') ||
440
+ parentGlobals.includes('backgrounds.value:%23');
441
+ // Check parent document for Storybook dark theme classes
442
+ const parentBody = window.parent.document.body;
443
+ const parentHtml = window.parent.document.documentElement;
444
+ parentHasDarkClass = parentBody.classList.contains('sb-dark') ||
445
+ parentHtml.classList.contains('dark') ||
446
+ parentHtml.getAttribute('data-theme') === 'dark' ||
447
+ parentBody.getAttribute('data-theme') === 'dark';
448
+ // Check Storybook 8+ manager theme
449
+ const sbMainEl = window.parent.document.querySelector('.sb-main-padded, .sb-show-main');
450
+ if (sbMainEl) {
451
+ const sbBgColor = window.getComputedStyle(sbMainEl).backgroundColor;
452
+ const sbRgb = sbBgColor.match(/\d+/g);
453
+ if (sbRgb && sbRgb.length >= 3) {
454
+ const sbLuminance = (0.299 * parseInt(sbRgb[0]) + 0.587 * parseInt(sbRgb[1]) + 0.114 * parseInt(sbRgb[2])) / 255;
455
+ if (sbLuminance < 0.5)
456
+ parentHasDarkClass = true;
457
+ }
458
+ }
459
+ }
460
+ }
461
+ catch {
462
+ // Cross-origin access not allowed, ignore
463
+ }
464
+ // Check the actual background color of the body
465
+ const bgColor = window.getComputedStyle(body).backgroundColor;
466
+ const rgb = bgColor.match(/\d+/g);
467
+ let isBackgroundDark = false;
468
+ if (rgb && rgb.length >= 3) {
469
+ const luminance = (0.299 * parseInt(rgb[0]) + 0.587 * parseInt(rgb[1]) + 0.114 * parseInt(rgb[2])) / 255;
470
+ isBackgroundDark = luminance < 0.5;
471
+ }
472
+ // Check system preference as fallback
473
+ const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
474
+ // Explicit light mode takes precedence - if user selected "light" in Storybook, respect that
475
+ const hasExplicitLightMode = hasStorybookLightBg || parentHasLightBg;
476
+ // Explicit dark mode indicators
477
+ const hasExplicitDarkMode = body.classList.contains('sb-dark') ||
478
+ html.classList.contains('dark') ||
479
+ html.getAttribute('data-theme') === 'dark' ||
480
+ body.getAttribute('data-theme') === 'dark' ||
481
+ hasStorybookDarkBg ||
482
+ parentHasDarkBg;
483
+ // Determine dark mode: explicit light mode forces light, otherwise check dark indicators
484
+ const isDark = hasExplicitLightMode
485
+ ? false
486
+ : (hasExplicitDarkMode || parentHasDarkClass || isBackgroundDark || systemPrefersDark);
487
+ dispatch({ type: 'SET_DARK_MODE', payload: isDark });
488
+ };
489
+ detectTheme();
490
+ // Listen for URL changes (Storybook uses popstate for navigation)
491
+ window.addEventListener('popstate', detectTheme);
492
+ // Poll for changes since Storybook might change background without popstate
493
+ const intervalId = setInterval(detectTheme, 500);
494
+ const observer = new MutationObserver(detectTheme);
495
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] });
496
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] });
497
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
498
+ mediaQuery.addEventListener('change', detectTheme);
499
+ return () => {
500
+ window.removeEventListener('popstate', detectTheme);
501
+ clearInterval(intervalId);
502
+ observer.disconnect();
503
+ mediaQuery.removeEventListener('change', detectTheme);
504
+ };
505
+ }, []);
506
+ // Close context menu when clicking outside
507
+ useEffect(() => {
508
+ if (!contextMenuId)
509
+ return;
510
+ const handleClickOutside = (e) => {
511
+ const target = e.target;
512
+ if (!target.closest('.sui-context-menu') && !target.closest('.sui-chat-item-menu')) {
513
+ setContextMenuId(null);
514
+ }
515
+ };
516
+ document.addEventListener('click', handleClickOutside);
517
+ return () => document.removeEventListener('click', handleClickOutside);
518
+ }, [contextMenuId]);
519
+ // Initialize on mount
520
+ useEffect(() => {
521
+ const initialize = async () => {
522
+ const connectionTest = await testMCPConnection();
523
+ dispatch({ type: 'SET_CONNECTION_STATUS', payload: connectionTest });
524
+ if (connectionTest.connected) {
525
+ try {
526
+ const res = await fetch(PROVIDERS_API);
527
+ if (res.ok) {
528
+ const data = await res.json();
529
+ dispatch({ type: 'SET_PROVIDERS', payload: data.providers.filter(p => p.configured) });
530
+ if (data.current) {
531
+ dispatch({ type: 'SET_SELECTED_PROVIDER', payload: data.current.provider.toLowerCase() });
532
+ dispatch({ type: 'SET_SELECTED_MODEL', payload: data.current.model });
533
+ }
534
+ }
535
+ }
536
+ catch (e) {
537
+ console.error('Failed to fetch providers:', e);
538
+ }
539
+ try {
540
+ const res = await fetch(CONSIDERATIONS_API);
541
+ if (res.ok) {
542
+ const data = await res.json();
543
+ if (data.hasConsiderations && data.considerations) {
544
+ dispatch({ type: 'SET_CONSIDERATIONS', payload: data.considerations });
545
+ }
546
+ }
547
+ }
548
+ catch (e) {
549
+ console.error('Failed to fetch considerations:', e);
550
+ }
551
+ const syncedChats = await syncWithActualStories();
552
+ const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
553
+ dispatch({ type: 'SET_RECENT_CHATS', payload: sortedChats });
554
+ if (sortedChats.length > 0) {
555
+ dispatch({ type: 'SET_CONVERSATION', payload: sortedChats[0].conversation });
556
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: sortedChats[0].id, title: sortedChats[0].title } });
557
+ }
558
+ }
559
+ else {
560
+ const localChats = loadChats();
561
+ dispatch({ type: 'SET_RECENT_CHATS', payload: localChats });
562
+ }
563
+ };
564
+ initialize();
565
+ }, []);
566
+ // Scroll to bottom on new messages
567
+ useEffect(() => {
568
+ chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
569
+ }, [state.conversation, state.loading]);
570
+ // File handling
1254
571
  const fileToBase64 = (file) => {
1255
572
  return new Promise((resolve, reject) => {
1256
573
  const reader = new FileReader();
1257
574
  reader.readAsDataURL(file);
1258
575
  reader.onload = () => {
1259
576
  const result = reader.result;
1260
- // Extract base64 data (remove data:image/...;base64, prefix)
1261
- const base64 = result.split(',')[1];
1262
- resolve(base64);
577
+ resolve(result.split(',')[1]);
1263
578
  };
1264
579
  reader.onerror = error => reject(error);
1265
580
  });
1266
581
  };
1267
- // Handle file selection
1268
582
  const handleFileSelect = async (e) => {
1269
583
  const files = e.target.files;
1270
584
  if (!files)
1271
585
  return;
1272
- const newImages = [];
1273
586
  const errors = [];
1274
- for (let i = 0; i < files.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
587
+ for (let i = 0; i < files.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
1275
588
  const file = files[i];
1276
- // Validate file type
1277
589
  if (!file.type.startsWith('image/')) {
1278
590
  errors.push(`${file.name}: Not an image file`);
1279
591
  continue;
1280
592
  }
1281
- // Validate file size
1282
593
  if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1283
594
  errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1284
595
  continue;
@@ -1286,59 +597,42 @@ function StoryUIPanel() {
1286
597
  try {
1287
598
  const base64 = await fileToBase64(file);
1288
599
  const preview = URL.createObjectURL(file);
1289
- newImages.push({
1290
- id: `${Date.now()}-${i}`,
1291
- file,
1292
- preview,
1293
- base64,
1294
- mediaType: file.type || 'image/png',
600
+ dispatch({
601
+ type: 'ADD_ATTACHED_IMAGE',
602
+ payload: { id: `${Date.now()}-${i}`, file, preview, base64, mediaType: file.type || 'image/png' },
1295
603
  });
1296
604
  }
1297
- catch (err) {
605
+ catch {
1298
606
  errors.push(`${file.name}: Failed to process`);
1299
607
  }
1300
608
  }
1301
- if (errors.length > 0) {
1302
- setError(errors.join('\n'));
1303
- }
1304
- setAttachedImages(prev => [...prev, ...newImages]);
1305
- // Reset file input
1306
- if (fileInputRef.current) {
609
+ if (errors.length > 0)
610
+ dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
611
+ if (fileInputRef.current)
1307
612
  fileInputRef.current.value = '';
1308
- }
1309
613
  };
1310
- // Remove attached image
1311
614
  const removeAttachedImage = (id) => {
1312
- setAttachedImages(prev => {
1313
- const removed = prev.find(img => img.id === id);
1314
- if (removed) {
1315
- URL.revokeObjectURL(removed.preview);
1316
- }
1317
- return prev.filter(img => img.id !== id);
1318
- });
615
+ const img = state.attachedImages.find(i => i.id === id);
616
+ if (img)
617
+ URL.revokeObjectURL(img.preview);
618
+ dispatch({ type: 'REMOVE_ATTACHED_IMAGE', payload: id });
1319
619
  };
1320
- // Clear all attached images
1321
620
  const clearAttachedImages = () => {
1322
- attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
1323
- setAttachedImages([]);
621
+ state.attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
622
+ dispatch({ type: 'CLEAR_ATTACHED_IMAGES' });
1324
623
  };
1325
- // Drag and drop state
1326
- const [isDragging, setIsDragging] = useState(false);
1327
- // Handle drag events
624
+ // Drag and drop handlers
1328
625
  const handleDragEnter = useCallback((e) => {
1329
626
  e.preventDefault();
1330
627
  e.stopPropagation();
1331
- if (e.dataTransfer.types.includes('Files')) {
1332
- setIsDragging(true);
1333
- }
628
+ if (e.dataTransfer.types.includes('Files'))
629
+ dispatch({ type: 'SET_DRAGGING', payload: true });
1334
630
  }, []);
1335
631
  const handleDragLeave = useCallback((e) => {
1336
632
  e.preventDefault();
1337
633
  e.stopPropagation();
1338
- // Only set to false if we're leaving the main container
1339
- if (!e.currentTarget.contains(e.relatedTarget)) {
1340
- setIsDragging(false);
1341
- }
634
+ if (!e.currentTarget.contains(e.relatedTarget))
635
+ dispatch({ type: 'SET_DRAGGING', payload: false });
1342
636
  }, []);
1343
637
  const handleDragOver = useCallback((e) => {
1344
638
  e.preventDefault();
@@ -1347,298 +641,132 @@ function StoryUIPanel() {
1347
641
  const handleDrop = useCallback(async (e) => {
1348
642
  e.preventDefault();
1349
643
  e.stopPropagation();
1350
- setIsDragging(false);
644
+ dispatch({ type: 'SET_DRAGGING', payload: false });
1351
645
  const files = Array.from(e.dataTransfer.files);
1352
646
  const imageFiles = files.filter(f => f.type.startsWith('image/'));
1353
647
  if (imageFiles.length === 0) {
1354
- setError('Please drop image files only');
648
+ dispatch({ type: 'SET_ERROR', payload: 'Please drop image files only' });
1355
649
  return;
1356
650
  }
1357
- const newImages = [];
1358
651
  const errors = [];
1359
- for (let i = 0; i < imageFiles.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
652
+ for (let i = 0; i < imageFiles.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
1360
653
  const file = imageFiles[i];
1361
654
  if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1362
- errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
655
+ errors.push(`${file.name}: File too large`);
1363
656
  continue;
1364
657
  }
1365
658
  try {
1366
659
  const base64 = await fileToBase64(file);
1367
660
  const preview = URL.createObjectURL(file);
1368
- newImages.push({
1369
- id: `${Date.now()}-${i}`,
1370
- file,
1371
- preview,
1372
- base64,
1373
- mediaType: file.type || 'image/png',
661
+ dispatch({
662
+ type: 'ADD_ATTACHED_IMAGE',
663
+ payload: { id: `${Date.now()}-${i}`, file, preview, base64, mediaType: file.type || 'image/png' },
1374
664
  });
1375
665
  }
1376
- catch (err) {
666
+ catch {
1377
667
  errors.push(`${file.name}: Failed to process`);
1378
668
  }
1379
669
  }
1380
- if (errors.length > 0) {
1381
- setError(errors.join('\n'));
1382
- }
1383
- setAttachedImages(prev => [...prev, ...newImages]);
1384
- }, [attachedImages.length, fileToBase64]);
1385
- // Handle clipboard paste for images
670
+ if (errors.length > 0)
671
+ dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
672
+ }, [state.attachedImages.length]);
673
+ // Paste handler
1386
674
  const handlePaste = useCallback(async (e) => {
1387
675
  const items = e.clipboardData?.items;
1388
676
  if (!items)
1389
677
  return;
1390
678
  const imageItems = [];
1391
679
  for (let i = 0; i < items.length; i++) {
1392
- if (items[i].type.startsWith('image/')) {
680
+ if (items[i].type.startsWith('image/'))
1393
681
  imageItems.push(items[i]);
1394
- }
1395
682
  }
1396
683
  if (imageItems.length === 0)
1397
684
  return;
1398
- // Prevent default text paste behavior when pasting images
1399
685
  e.preventDefault();
1400
- if (attachedImages.length >= MAX_IMAGES) {
1401
- setError(`Maximum ${MAX_IMAGES} images allowed`);
686
+ if (state.attachedImages.length >= MAX_IMAGES) {
687
+ dispatch({ type: 'SET_ERROR', payload: `Maximum ${MAX_IMAGES} images allowed` });
1402
688
  return;
1403
689
  }
1404
- const newImages = [];
1405
- const errors = [];
1406
- for (let i = 0; i < imageItems.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
1407
- const item = imageItems[i];
1408
- const file = item.getAsFile();
1409
- if (!file) {
1410
- errors.push('Failed to get image from clipboard');
690
+ for (let i = 0; i < imageItems.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
691
+ const file = imageItems[i].getAsFile();
692
+ if (!file)
1411
693
  continue;
1412
- }
1413
- if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1414
- errors.push(`Pasted image too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1415
- continue;
1416
- }
1417
694
  try {
1418
695
  const base64 = await fileToBase64(file);
1419
696
  const preview = URL.createObjectURL(file);
1420
- // Create a meaningful name for pasted images
1421
697
  const timestamp = new Date().toISOString().slice(11, 19).replace(/:/g, '-');
1422
- const renamedFile = new File([file], `pasted-image-${timestamp}.${file.type.split('/')[1] || 'png'}`, { type: file.type });
1423
- newImages.push({
1424
- id: `paste-${Date.now()}-${i}`,
1425
- file: renamedFile,
1426
- preview,
1427
- base64,
1428
- mediaType: file.type || 'image/png',
698
+ dispatch({
699
+ type: 'ADD_ATTACHED_IMAGE',
700
+ payload: {
701
+ id: `paste-${Date.now()}-${i}`,
702
+ file: new File([file], `pasted-image-${timestamp}.png`, { type: file.type }),
703
+ preview,
704
+ base64,
705
+ mediaType: file.type || 'image/png',
706
+ },
1429
707
  });
1430
708
  }
1431
- catch (err) {
1432
- errors.push('Failed to process pasted image');
709
+ catch {
710
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to process pasted image' });
1433
711
  }
1434
712
  }
1435
- if (errors.length > 0) {
1436
- setError(errors.join('\n'));
1437
- }
1438
- if (newImages.length > 0) {
1439
- setAttachedImages(prev => [...prev, ...newImages]);
1440
- // Clear any existing error on successful paste
1441
- if (errors.length === 0) {
1442
- setError(null);
1443
- }
1444
- }
1445
- }, [attachedImages.length, fileToBase64]);
1446
- // Load and sync chats on mount
1447
- useEffect(() => {
1448
- const initializeChats = async () => {
1449
- // Test connection first
1450
- const connectionTest = await testMCPConnection();
1451
- setConnectionStatus(connectionTest);
1452
- if (connectionTest.connected) {
1453
- // Fetch available providers
1454
- try {
1455
- const providersRes = await fetch(PROVIDERS_API);
1456
- if (providersRes.ok) {
1457
- const providersData = await providersRes.json();
1458
- setAvailableProviders(providersData.providers.filter(p => p.configured));
1459
- // Set initial selection from server defaults
1460
- if (providersData.current) {
1461
- setSelectedProvider(providersData.current.provider.toLowerCase());
1462
- setSelectedModel(providersData.current.model);
1463
- }
1464
- }
1465
- }
1466
- catch (e) {
1467
- console.error('Failed to fetch providers:', e);
1468
- }
1469
- // Fetch design system considerations for environment parity
1470
- // This ensures production gets the same considerations as local development
1471
- try {
1472
- const considerationsRes = await fetch(CONSIDERATIONS_API);
1473
- if (considerationsRes.ok) {
1474
- const considerationsData = await considerationsRes.json();
1475
- if (considerationsData.hasConsiderations && considerationsData.considerations) {
1476
- setConsiderations(considerationsData.considerations);
1477
- console.log(`Loaded considerations from ${considerationsData.source}`);
1478
- }
1479
- }
1480
- }
1481
- catch (e) {
1482
- console.error('Failed to fetch considerations:', e);
1483
- }
1484
- const syncedChats = await syncWithActualStories();
1485
- const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
1486
- setRecentChats(sortedChats);
1487
- if (sortedChats.length > 0) {
1488
- setConversation(sortedChats[0].conversation);
1489
- setActiveChatId(sortedChats[0].id);
1490
- setActiveTitle(sortedChats[0].title);
1491
- }
1492
- // Fetch orphan stories (stories on disk without chat history)
1493
- const orphans = await fetchOrphanStories();
1494
- setOrphanStories(orphans);
1495
- }
1496
- else {
1497
- // Load from local storage if server is not available
1498
- const localChats = loadChats();
1499
- setRecentChats(localChats);
1500
- }
1501
- };
1502
- initializeChats();
1503
- }, []);
1504
- // Scroll to bottom on new message
1505
- useEffect(() => {
1506
- if (chatEndRef.current) {
1507
- chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
1508
- }
1509
- }, [conversation, loading]);
1510
- // Helper function for non-streaming fallback
1511
- const handleSendNonStreaming = async (userInput, newConversation) => {
1512
- const res = await fetch(MCP_API, {
1513
- method: 'POST',
1514
- headers: { 'Content-Type': 'application/json' },
1515
- body: JSON.stringify({
1516
- prompt: userInput,
1517
- conversation: newConversation,
1518
- fileName: activeChatId || undefined,
1519
- provider: selectedProvider || undefined,
1520
- model: selectedModel || undefined,
1521
- considerations: considerations || undefined,
1522
- }),
1523
- });
1524
- const contentType = res.headers.get('content-type');
1525
- if (!contentType || !contentType.includes('application/json')) {
1526
- const text = await res.text();
1527
- throw new Error(`Server returned non-JSON response. Response: ${text.substring(0, 200)}...`);
1528
- }
1529
- const data = await res.json();
1530
- if (!res.ok || !data.success)
1531
- throw new Error(data.error || 'Story generation failed');
1532
- return data;
1533
- };
1534
- // Helper function to build a conversational response from completion data
1535
- // Uses special markers [SUCCESS], [ERROR], [TIP], [WRENCH] that renderMarkdown converts to icons
1536
- // Track whether we've shown the refresh hint in this session
1537
- const hasShownRefreshHint = useRef(false);
713
+ }, [state.attachedImages.length]);
714
+ // Build response message
1538
715
  const buildConversationalResponse = (completion, isUpdate) => {
1539
716
  const parts = [];
1540
717
  const statusMarker = completion.success ? '[SUCCESS]' : '[ERROR]';
1541
- // Lead with the result - more conversational
1542
- if (isUpdate) {
1543
- parts.push(`${statusMarker} **Updated: "${completion.title}"**`);
1544
- }
1545
- else {
1546
- parts.push(`${statusMarker} **Created: "${completion.title}"**`);
1547
- }
1548
- // Build component insights with reasons when available
718
+ parts.push(isUpdate ? `${statusMarker} **Updated: "${completion.title}"**` : `${statusMarker} **Created: "${completion.title}"**`);
1549
719
  const componentCount = completion.componentsUsed?.length || 0;
1550
720
  if (componentCount > 0) {
1551
- const componentList = completion.componentsUsed.slice(0, 5);
1552
- // Check if we have meaningful reasons (not just "Used in composition")
1553
- const componentsWithReasons = componentList.filter(c => c.reason && c.reason !== 'Used in composition');
1554
- if (componentsWithReasons.length > 0) {
1555
- // Show components with their reasons
1556
- const insights = componentsWithReasons
1557
- .slice(0, 3)
1558
- .map(c => `\`${c.name}\` - ${c.reason?.toLowerCase()}`)
1559
- .join(', ');
1560
- parts.push(`\nUsed ${insights}${componentCount > 3 ? ` and ${componentCount - 3} more` : ''}.`);
1561
- }
1562
- else {
1563
- // Fallback to simple list
1564
- const names = componentList.map(c => `\`${c.name}\``).join(', ');
1565
- parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
1566
- }
721
+ const names = completion.componentsUsed.slice(0, 5).map(c => `\`${c.name}\``).join(', ');
722
+ parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
1567
723
  }
1568
- // Add layout decisions with educational context
1569
- if (completion.layoutChoices && completion.layoutChoices.length > 0) {
1570
- const primaryLayout = completion.layoutChoices[0];
1571
- parts.push(`\n\n**Layout:** ${primaryLayout.pattern} - ${primaryLayout.reason.charAt(0).toLowerCase()}${primaryLayout.reason.slice(1)}.`);
724
+ if (completion.layoutChoices?.length > 0) {
725
+ const layout = completion.layoutChoices[0];
726
+ parts.push(`\n\n**Layout:** ${layout.pattern} - ${layout.reason}.`);
1572
727
  }
1573
- // Add style choices only if they add value
1574
- if (completion.styleChoices && completion.styleChoices.length > 0) {
1575
- const notableStyles = completion.styleChoices.filter(s => s.reason && s.reason !== 'Semantic color from design system');
1576
- if (notableStyles.length > 0) {
1577
- const styleInfo = notableStyles[0];
1578
- parts.push(` Applied \`${styleInfo.value}\` for ${styleInfo.reason?.toLowerCase() || 'visual consistency'}.`);
1579
- }
1580
- }
1581
- // Add validation fixes notice
1582
728
  if (completion.validation?.autoFixApplied) {
1583
729
  parts.push(`\n\n[WRENCH] **Auto-fixed:** Minor syntax issues were automatically corrected.`);
1584
730
  }
1585
- // Add suggestions only if meaningful
1586
- if (completion.suggestions && completion.suggestions.length > 0) {
1587
- const suggestion = completion.suggestions[0];
1588
- // Only show if it's not the generic "review the generated code" message
1589
- if (!suggestion.toLowerCase().includes('review the generated code')) {
1590
- parts.push(`\n\n[TIP] **Tip:** ${suggestion}`);
1591
- }
731
+ if (completion.suggestions && completion.suggestions.length > 0 && !completion.suggestions[0].toLowerCase().includes('review the generated code')) {
732
+ parts.push(`\n\n[TIP] **Tip:** ${completion.suggestions[0]}`);
1592
733
  }
1593
- // Show refresh hint only once per session for new stories (local mode only)
1594
- // In Edge mode, stories are stored in Durable Objects, not on filesystem
1595
734
  if (!isUpdate && !hasShownRefreshHint.current) {
1596
- if (isEdgeMode()) {
1597
- parts.push(`\n\n_Story saved to cloud. View code in chat history recent chats navigation._`);
1598
- }
1599
- else {
1600
- parts.push(`\n\n_Might need toefresh Storybook (Cmd/Ctrl + R) to see new stories in the sidebar._`);
1601
- }
735
+ parts.push(isEdgeMode() ? `\n\n_Story saved to cloud._` : `\n\n_Might need to refresh Storybook (Cmd/Ctrl + R) to see new stories._`);
1602
736
  hasShownRefreshHint.current = true;
1603
737
  }
1604
- // Add metrics in a subtle way (if available)
1605
738
  if (completion.metrics?.totalTimeMs) {
1606
- const seconds = (completion.metrics.totalTimeMs / 1000).toFixed(1);
1607
- parts.push(`\n\n_${seconds}s_`);
739
+ parts.push(`\n\n_${(completion.metrics.totalTimeMs / 1000).toFixed(1)}s_`);
1608
740
  }
1609
741
  return parts.join('');
1610
742
  };
1611
- // Helper function to finalize conversation after streaming completes
743
+ // Finalize streaming
1612
744
  const finalizeStreamingConversation = useCallback((newConversation, completion, userInput) => {
1613
- // Build conversational response using rich completion data
1614
745
  const isUpdate = completion.summary.action === 'updated';
1615
746
  const responseMessage = buildConversationalResponse(completion, isUpdate);
1616
747
  const aiMsg = { role: 'ai', content: responseMessage };
1617
748
  const updatedConversation = [...newConversation, aiMsg];
1618
- setConversation(updatedConversation);
1619
- // Update chat session
1620
- const isExistingSession = activeChatId && conversation.length > 0;
1621
- if (isExistingSession && activeChatId) {
749
+ dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
750
+ const isExistingSession = state.activeChatId && state.conversation.length > 0;
751
+ if (isExistingSession && state.activeChatId) {
1622
752
  const updatedSession = {
1623
- id: activeChatId,
1624
- title: activeTitle,
1625
- fileName: completion.fileName || activeChatId,
753
+ id: state.activeChatId,
754
+ title: state.activeTitle,
755
+ fileName: completion.fileName || state.activeChatId,
1626
756
  conversation: updatedConversation,
1627
757
  lastUpdated: Date.now(),
1628
758
  };
1629
759
  const chats = loadChats();
1630
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
1631
- if (chatIndex !== -1) {
760
+ const chatIndex = chats.findIndex(c => c.id === state.activeChatId);
761
+ if (chatIndex !== -1)
1632
762
  chats[chatIndex] = updatedSession;
1633
- }
1634
763
  saveChats(chats);
1635
- setRecentChats(chats);
764
+ dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
1636
765
  }
1637
766
  else {
1638
767
  const chatId = completion.storyId || completion.fileName || Date.now().toString();
1639
768
  const chatTitle = completion.title || userInput;
1640
- setActiveChatId(chatId);
1641
- setActiveTitle(chatTitle);
769
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chatId, title: chatTitle } });
1642
770
  const newSession = {
1643
771
  id: chatId,
1644
772
  title: chatTitle,
@@ -1648,66 +776,48 @@ function StoryUIPanel() {
1648
776
  };
1649
777
  const chats = loadChats().filter(c => c.id !== chatId);
1650
778
  chats.unshift(newSession);
1651
- if (chats.length > MAX_RECENT_CHATS) {
779
+ if (chats.length > MAX_RECENT_CHATS)
1652
780
  chats.splice(MAX_RECENT_CHATS);
1653
- }
1654
781
  saveChats(chats);
1655
- setRecentChats(chats);
1656
- // Auto-navigate to the newly created story after HMR processes the file
1657
- // This prevents the "Couldn't find story after HMR" error by refreshing
1658
- // after the file system has been updated and HMR has processed the change
1659
- navigateToNewStory(chatTitle, completion.code);
782
+ dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
1660
783
  }
1661
- }, [activeChatId, activeTitle, conversation.length]);
784
+ }, [state.activeChatId, state.activeTitle, state.conversation.length]);
785
+ // Handle send
1662
786
  const handleSend = async (e) => {
1663
787
  if (e)
1664
788
  e.preventDefault();
1665
- // Allow sending with either text or images
1666
- if (!input.trim() && attachedImages.length === 0)
789
+ if (!state.input.trim() && state.attachedImages.length === 0)
1667
790
  return;
1668
- // Use input text or default vision prompt if only images
1669
- const userInput = input.trim() || (attachedImages.length > 0 ? 'Create a component that matches this design' : '');
1670
- setError(null);
1671
- setLoading(true);
1672
- setStreamingState(null);
1673
- // Test connection before sending
791
+ const userInput = state.input.trim() || (state.attachedImages.length > 0 ? 'Create a component that matches this design' : '');
792
+ dispatch({ type: 'SET_ERROR', payload: null });
793
+ dispatch({ type: 'SET_LOADING', payload: true });
794
+ dispatch({ type: 'SET_STREAMING_STATE', payload: null });
1674
795
  const connectionTest = await testMCPConnection();
1675
- setConnectionStatus(connectionTest);
796
+ dispatch({ type: 'SET_CONNECTION_STATUS', payload: connectionTest });
1676
797
  if (!connectionTest.connected) {
1677
- setError(`Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}`);
1678
- setLoading(false);
798
+ dispatch({ type: 'SET_ERROR', payload: `Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}` });
799
+ dispatch({ type: 'SET_LOADING', payload: false });
1679
800
  return;
1680
801
  }
1681
- // Capture images before clearing
1682
- const imagesToSend = [...attachedImages];
802
+ const imagesToSend = [...state.attachedImages];
1683
803
  const hasImages = imagesToSend.length > 0;
1684
- // Create user message with images
1685
804
  const userMessage = {
1686
805
  role: 'user',
1687
806
  content: userInput,
1688
- attachedImages: hasImages ? imagesToSend : undefined
807
+ attachedImages: hasImages ? imagesToSend : undefined,
1689
808
  };
1690
- const newConversation = [...conversation, userMessage];
1691
- setConversation(newConversation);
1692
- setInput('');
809
+ const newConversation = [...state.conversation, userMessage];
810
+ dispatch({ type: 'SET_CONVERSATION', payload: newConversation });
811
+ dispatch({ type: 'SET_INPUT', payload: '' });
1693
812
  clearAttachedImages();
1694
- // Use streaming if enabled
1695
813
  if (USE_STREAMING) {
1696
814
  try {
1697
- // Cancel any existing request
1698
- if (abortControllerRef.current) {
815
+ if (abortControllerRef.current)
1699
816
  abortControllerRef.current.abort();
1700
- }
1701
817
  abortControllerRef.current = new AbortController();
1702
- // Initialize streaming state
1703
- setStreamingState({});
1704
- // Prepare images for API request
818
+ dispatch({ type: 'SET_STREAMING_STATE', payload: {} });
1705
819
  const imagePayload = hasImages
1706
- ? imagesToSend.map(img => ({
1707
- type: 'base64',
1708
- data: img.base64,
1709
- mediaType: img.file.type,
1710
- }))
820
+ ? imagesToSend.map(img => ({ type: 'base64', data: img.base64, mediaType: img.file.type }))
1711
821
  : undefined;
1712
822
  const response = await fetch(MCP_STREAM_API, {
1713
823
  method: 'POST',
@@ -1715,25 +825,23 @@ function StoryUIPanel() {
1715
825
  body: JSON.stringify({
1716
826
  prompt: userInput,
1717
827
  conversation: newConversation,
1718
- fileName: activeChatId || undefined,
1719
- isUpdate: activeChatId && conversation.length > 0,
1720
- originalTitle: activeTitle || undefined,
1721
- storyId: activeChatId || undefined,
828
+ fileName: state.activeChatId || undefined,
829
+ isUpdate: state.activeChatId && state.conversation.length > 0,
830
+ originalTitle: state.activeTitle || undefined,
831
+ storyId: state.activeChatId || undefined,
1722
832
  images: imagePayload,
1723
833
  visionMode: hasImages ? 'screenshot_to_story' : undefined,
1724
- provider: selectedProvider || undefined,
1725
- model: selectedModel || undefined,
1726
- considerations: considerations || undefined,
834
+ provider: state.selectedProvider || undefined,
835
+ model: state.selectedModel || undefined,
836
+ considerations: state.considerations || undefined,
1727
837
  }),
1728
838
  signal: abortControllerRef.current.signal,
1729
839
  });
1730
- if (!response.ok) {
840
+ if (!response.ok)
1731
841
  throw new Error(`Streaming request failed: ${response.status}`);
1732
- }
1733
842
  const reader = response.body?.getReader();
1734
- if (!reader) {
843
+ if (!reader)
1735
844
  throw new Error('No response body');
1736
- }
1737
845
  const decoder = new TextDecoder();
1738
846
  let buffer = '';
1739
847
  let completionData = null;
@@ -1743,215 +851,109 @@ function StoryUIPanel() {
1743
851
  if (done)
1744
852
  break;
1745
853
  buffer += decoder.decode(value, { stream: true });
1746
- // Parse SSE events from buffer
1747
854
  const lines = buffer.split('\n');
1748
- buffer = lines.pop() || ''; // Keep incomplete line in buffer
855
+ buffer = lines.pop() || '';
1749
856
  for (const line of lines) {
1750
857
  if (line.startsWith('data: ')) {
1751
858
  try {
1752
859
  const event = JSON.parse(line.slice(6));
1753
- // Update streaming state based on event type
1754
860
  switch (event.type) {
1755
861
  case 'intent':
1756
- setStreamingState(prev => ({ ...prev, intent: event.data }));
862
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { intent: event.data } });
1757
863
  break;
1758
864
  case 'progress':
1759
- setStreamingState(prev => ({ ...prev, progress: event.data }));
865
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { progress: event.data } });
1760
866
  break;
1761
867
  case 'validation':
1762
- setStreamingState(prev => ({ ...prev, validation: event.data }));
868
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { validation: event.data } });
1763
869
  break;
1764
870
  case 'retry':
1765
- setStreamingState(prev => ({ ...prev, retry: event.data }));
871
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { retry: event.data } });
1766
872
  break;
1767
873
  case 'completion':
1768
874
  completionData = event.data;
1769
- setStreamingState(prev => ({ ...prev, completion: event.data }));
875
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { completion: completionData } });
1770
876
  break;
1771
877
  case 'error':
1772
878
  errorData = event.data;
1773
- setStreamingState(prev => ({ ...prev, error: event.data }));
879
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { error: errorData } });
1774
880
  break;
1775
881
  }
1776
882
  }
1777
- catch (parseError) {
1778
- console.warn('Failed to parse SSE event:', line, parseError);
883
+ catch {
884
+ console.warn('Failed to parse SSE event:', line);
1779
885
  }
1780
886
  }
1781
887
  }
1782
888
  }
1783
- // Handle completion or error
1784
889
  if (completionData) {
1785
890
  finalizeStreamingConversation(newConversation, completionData, userInput);
1786
891
  }
1787
892
  else if (errorData) {
1788
- setError(errorData.message);
893
+ dispatch({ type: 'SET_ERROR', payload: errorData.message });
1789
894
  const errorConversation = [...newConversation, { role: 'ai', content: `Error: ${errorData.message}\n\n${errorData.suggestion || ''}` }];
1790
- setConversation(errorConversation);
895
+ dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
1791
896
  }
1792
897
  }
1793
898
  catch (err) {
1794
- if (err.name === 'AbortError') {
1795
- console.log('Request aborted');
899
+ if (err.name === 'AbortError')
1796
900
  return;
1797
- }
1798
- // Fall back to non-streaming on error
1799
901
  console.warn('Streaming failed, falling back to non-streaming:', err);
1800
- setStreamingState(null);
902
+ dispatch({ type: 'SET_STREAMING_STATE', payload: null });
1801
903
  try {
1802
- const data = await handleSendNonStreaming(userInput, newConversation);
1803
- // Process non-streaming response (same as before)
1804
- let responseMessage;
1805
- const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
1806
- // Build conversational response for fallback
1807
- if (data.isUpdate) {
1808
- responseMessage = `${statusMarker} **Updated: "${data.title}"**\n\nI've made the requested changes to your component. You can view the updated version in Storybook.\n\n_Check the Docs tab to see both the rendered component and its code._`;
1809
- }
1810
- else {
1811
- responseMessage = `${statusMarker} **Created: "${data.title}"**\n\nI've generated the component with the requested features. You can view it in Storybook where you'll see both the rendered component and its markup.\n\n[TIP] **Note**: If you don't see the story immediately, you may need to refresh your Storybook page (Cmd/Ctrl + R).`;
1812
- }
904
+ const res = await fetch(MCP_API, {
905
+ method: 'POST',
906
+ headers: { 'Content-Type': 'application/json' },
907
+ body: JSON.stringify({
908
+ prompt: userInput,
909
+ conversation: newConversation,
910
+ fileName: state.activeChatId || undefined,
911
+ provider: state.selectedProvider || undefined,
912
+ model: state.selectedModel || undefined,
913
+ considerations: state.considerations || undefined,
914
+ }),
915
+ });
916
+ const data = await res.json();
917
+ if (!res.ok || !data.success)
918
+ throw new Error(data.error || 'Story generation failed');
919
+ const responseMessage = `[SUCCESS] **Created: "${data.title}"**\n\nStory generated successfully.`;
1813
920
  const aiMsg = { role: 'ai', content: responseMessage };
1814
921
  const updatedConversation = [...newConversation, aiMsg];
1815
- setConversation(updatedConversation);
1816
- // Update chat session
1817
- const isUpdate = activeChatId && conversation.length > 0;
1818
- if (isUpdate && activeChatId) {
1819
- const updatedSession = {
1820
- id: activeChatId,
1821
- title: activeTitle,
1822
- fileName: data.fileName || activeChatId,
1823
- conversation: updatedConversation,
1824
- lastUpdated: Date.now(),
1825
- };
1826
- const chats = loadChats();
1827
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
1828
- if (chatIndex !== -1)
1829
- chats[chatIndex] = updatedSession;
1830
- saveChats(chats);
1831
- setRecentChats(chats);
1832
- }
1833
- else {
1834
- const chatId = data.storyId || data.fileName || Date.now().toString();
1835
- const chatTitle = data.title || userInput;
1836
- setActiveChatId(chatId);
1837
- setActiveTitle(chatTitle);
1838
- const newSession = {
1839
- id: chatId,
1840
- title: chatTitle,
1841
- fileName: data.fileName || '',
1842
- conversation: updatedConversation,
1843
- lastUpdated: Date.now(),
1844
- };
1845
- const chats = loadChats().filter(c => c.id !== chatId);
1846
- chats.unshift(newSession);
1847
- if (chats.length > MAX_RECENT_CHATS)
1848
- chats.splice(MAX_RECENT_CHATS);
1849
- saveChats(chats);
1850
- setRecentChats(chats);
1851
- // Auto-navigate to the newly created story
1852
- navigateToNewStory(chatTitle, data.code);
1853
- }
922
+ dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
1854
923
  }
1855
924
  catch (fallbackErr) {
1856
925
  const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : 'Unknown error';
1857
- setError(errorMessage);
926
+ dispatch({ type: 'SET_ERROR', payload: errorMessage });
1858
927
  const errorConversation = [...newConversation, { role: 'ai', content: `Error: ${errorMessage}` }];
1859
- setConversation(errorConversation);
928
+ dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
1860
929
  }
1861
930
  }
1862
931
  finally {
1863
- setLoading(false);
1864
- setStreamingState(null);
932
+ dispatch({ type: 'SET_LOADING', payload: false });
933
+ dispatch({ type: 'SET_STREAMING_STATE', payload: null });
1865
934
  abortControllerRef.current = null;
1866
935
  }
1867
936
  }
1868
- else {
1869
- // Non-streaming mode (original implementation)
1870
- try {
1871
- const data = await handleSendNonStreaming(userInput, newConversation);
1872
- let responseMessage;
1873
- const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
1874
- // Build conversational response for non-streaming mode
1875
- if (data.isUpdate) {
1876
- responseMessage = `${statusMarker} **Updated: "${data.title}"**\n\nI've made the requested changes to your component. You can view the updated version in Storybook.\n\n_Check the Docs tab to see both the rendered component and its code._`;
1877
- }
1878
- else {
1879
- responseMessage = `${statusMarker} **Created: "${data.title}"**\n\nI've generated the component with the requested features. You can view it in Storybook where you'll see both the rendered component and its markup.\n\n[TIP] **Note**: If you don't see the story immediately, you may need to refresh your Storybook page (Cmd/Ctrl + R).`;
1880
- }
1881
- const aiMsg = { role: 'ai', content: responseMessage };
1882
- const updatedConversation = [...newConversation, aiMsg];
1883
- setConversation(updatedConversation);
1884
- const isUpdate = activeChatId && conversation.length > 0;
1885
- if (isUpdate && activeChatId) {
1886
- const updatedSession = {
1887
- id: activeChatId,
1888
- title: activeTitle,
1889
- fileName: data.fileName || activeChatId,
1890
- conversation: updatedConversation,
1891
- lastUpdated: Date.now(),
1892
- };
1893
- const chats = loadChats();
1894
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
1895
- if (chatIndex !== -1)
1896
- chats[chatIndex] = updatedSession;
1897
- saveChats(chats);
1898
- setRecentChats(chats);
1899
- }
1900
- else {
1901
- const chatId = data.storyId || data.fileName || Date.now().toString();
1902
- const chatTitle = data.title || userInput;
1903
- setActiveChatId(chatId);
1904
- setActiveTitle(chatTitle);
1905
- const newSession = {
1906
- id: chatId,
1907
- title: chatTitle,
1908
- fileName: data.fileName || '',
1909
- conversation: updatedConversation,
1910
- lastUpdated: Date.now(),
1911
- };
1912
- const chats = loadChats().filter(c => c.id !== chatId);
1913
- chats.unshift(newSession);
1914
- if (chats.length > MAX_RECENT_CHATS)
1915
- chats.splice(MAX_RECENT_CHATS);
1916
- saveChats(chats);
1917
- setRecentChats(chats);
1918
- }
1919
- }
1920
- catch (err) {
1921
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1922
- setError(errorMessage);
1923
- const errorConversation = [...newConversation, { role: 'ai', content: `Error: ${errorMessage}` }];
1924
- setConversation(errorConversation);
1925
- }
1926
- finally {
1927
- setLoading(false);
1928
- }
1929
- }
1930
937
  };
938
+ // Chat management
1931
939
  const handleSelectChat = (chat) => {
1932
- setConversation(chat.conversation);
1933
- setActiveChatId(chat.id);
1934
- setActiveTitle(chat.title);
1935
- };
1936
- const handleNewChat = () => {
1937
- setConversation([]);
1938
- setActiveChatId(null);
1939
- setActiveTitle('');
940
+ dispatch({ type: 'SET_CONVERSATION', payload: chat.conversation });
941
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
1940
942
  };
943
+ const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
1941
944
  const handleDeleteChat = async (chatId, e) => {
1942
- e.stopPropagation(); // Prevent selecting the chat
945
+ if (e)
946
+ e.stopPropagation();
947
+ setContextMenuId(null);
1943
948
  if (confirm('Delete this story and chat? This action cannot be undone.')) {
1944
949
  const success = await deleteStoryAndChat(chatId);
1945
950
  if (success) {
1946
- // Update local state
1947
- const updatedChats = recentChats.filter(chat => chat.id !== chatId);
1948
- setRecentChats(updatedChats);
1949
- // If we deleted the active chat, switch to another or clear
1950
- if (activeChatId === chatId) {
951
+ const updatedChats = state.recentChats.filter(chat => chat.id !== chatId);
952
+ dispatch({ type: 'SET_RECENT_CHATS', payload: updatedChats });
953
+ if (state.activeChatId === chatId) {
1951
954
  if (updatedChats.length > 0) {
1952
- setConversation(updatedChats[0].conversation);
1953
- setActiveChatId(updatedChats[0].id);
1954
- setActiveTitle(updatedChats[0].title);
955
+ dispatch({ type: 'SET_CONVERSATION', payload: updatedChats[0].conversation });
956
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: updatedChats[0].id, title: updatedChats[0].title } });
1955
957
  }
1956
958
  else {
1957
959
  handleNewChat();
@@ -1963,372 +965,124 @@ function StoryUIPanel() {
1963
965
  }
1964
966
  }
1965
967
  };
1966
- // Toggle story selection for bulk operations
1967
- const toggleStorySelection = (storyId) => {
1968
- setSelectedStoryIds(prev => {
1969
- const newSet = new Set(prev);
1970
- if (newSet.has(storyId)) {
1971
- newSet.delete(storyId);
1972
- }
1973
- else {
1974
- newSet.add(storyId);
968
+ const handleStartRename = (chatId, currentTitle, e) => {
969
+ if (e)
970
+ e.stopPropagation();
971
+ setContextMenuId(null);
972
+ setRenamingChatId(chatId);
973
+ setRenameValue(currentTitle);
974
+ };
975
+ const handleConfirmRename = (chatId) => {
976
+ if (!renameValue.trim()) {
977
+ setRenamingChatId(null);
978
+ return;
979
+ }
980
+ const chats = loadChats();
981
+ const chatIndex = chats.findIndex(c => c.id === chatId);
982
+ if (chatIndex !== -1) {
983
+ chats[chatIndex].title = renameValue.trim();
984
+ saveChats(chats);
985
+ dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
986
+ if (state.activeChatId === chatId) {
987
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chatId, title: renameValue.trim() } });
1975
988
  }
1976
- return newSet;
1977
- });
989
+ }
990
+ setRenamingChatId(null);
991
+ setRenameValue('');
992
+ };
993
+ const handleCancelRename = () => {
994
+ setRenamingChatId(null);
995
+ setRenameValue('');
1978
996
  };
1979
- // Select/deselect all stories
997
+ // Orphan story handlers
1980
998
  const toggleSelectAll = () => {
1981
- if (selectedStoryIds.size === orphanStories.length) {
1982
- setSelectedStoryIds(new Set());
999
+ if (state.selectedStoryIds.size === state.orphanStories.length) {
1000
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
1983
1001
  }
1984
1002
  else {
1985
- setSelectedStoryIds(new Set(orphanStories.map(s => s.id)));
1003
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set(state.orphanStories.map(s => s.id)) });
1986
1004
  }
1987
1005
  };
1988
- // Bulk delete selected stories
1989
1006
  const handleBulkDelete = async () => {
1990
- if (selectedStoryIds.size === 0)
1007
+ if (state.selectedStoryIds.size === 0)
1991
1008
  return;
1992
- const count = selectedStoryIds.size;
1993
- if (!confirm(`Delete ${count} selected ${count === 1 ? 'story' : 'stories'}? This action cannot be undone.`)) {
1009
+ const count = state.selectedStoryIds.size;
1010
+ if (!confirm(`Delete ${count} selected ${count === 1 ? 'story' : 'stories'}?`))
1994
1011
  return;
1995
- }
1996
- setIsBulkDeleting(true);
1012
+ dispatch({ type: 'SET_BULK_DELETING', payload: true });
1997
1013
  try {
1998
1014
  const response = await fetch(`${STORIES_API}/delete-bulk`, {
1999
1015
  method: 'POST',
2000
1016
  headers: { 'Content-Type': 'application/json' },
2001
- body: JSON.stringify({ ids: Array.from(selectedStoryIds) }),
1017
+ body: JSON.stringify({ ids: Array.from(state.selectedStoryIds) }),
2002
1018
  });
2003
1019
  if (response.ok) {
2004
- const result = await response.json();
2005
- // Remove deleted stories from state
2006
- setOrphanStories(prev => prev.filter(s => !selectedStoryIds.has(s.id)));
2007
- setSelectedStoryIds(new Set());
2008
- console.log(`Deleted ${result.deleted?.length || count} stories`);
1020
+ dispatch({ type: 'SET_ORPHAN_STORIES', payload: state.orphanStories.filter(s => !state.selectedStoryIds.has(s.id)) });
1021
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
2009
1022
  }
2010
1023
  else {
2011
- alert('Failed to delete some stories. Please try again.');
1024
+ alert('Failed to delete some stories.');
2012
1025
  }
2013
1026
  }
2014
- catch (err) {
2015
- console.error('Error bulk deleting stories:', err);
2016
- alert('Failed to delete stories. Please try again.');
1027
+ catch {
1028
+ alert('Failed to delete stories.');
2017
1029
  }
2018
1030
  finally {
2019
- setIsBulkDeleting(false);
1031
+ dispatch({ type: 'SET_BULK_DELETING', payload: false });
2020
1032
  }
2021
1033
  };
2022
- // Clear all generated stories
2023
1034
  const handleClearAll = async () => {
2024
- if (orphanStories.length === 0)
1035
+ if (state.orphanStories.length === 0)
2025
1036
  return;
2026
- if (!confirm(`Delete ALL ${orphanStories.length} generated stories? This action cannot be undone.`)) {
1037
+ if (!confirm(`Delete ALL ${state.orphanStories.length} generated stories?`))
2027
1038
  return;
2028
- }
2029
- setIsBulkDeleting(true);
1039
+ dispatch({ type: 'SET_BULK_DELETING', payload: true });
2030
1040
  try {
2031
- const response = await fetch(STORIES_API, {
2032
- method: 'DELETE',
2033
- });
1041
+ const response = await fetch(STORIES_API, { method: 'DELETE' });
2034
1042
  if (response.ok) {
2035
- const result = await response.json();
2036
- setOrphanStories([]);
2037
- setSelectedStoryIds(new Set());
2038
- console.log(`Cleared ${result.deleted || 'all'} stories`);
1043
+ dispatch({ type: 'SET_ORPHAN_STORIES', payload: [] });
1044
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
2039
1045
  }
2040
1046
  else {
2041
- alert('Failed to clear stories. Please try again.');
1047
+ alert('Failed to clear stories.');
2042
1048
  }
2043
1049
  }
2044
- catch (err) {
2045
- console.error('Error clearing stories:', err);
2046
- alert('Failed to clear stories. Please try again.');
1050
+ catch {
1051
+ alert('Failed to clear stories.');
2047
1052
  }
2048
1053
  finally {
2049
- setIsBulkDeleting(false);
1054
+ dispatch({ type: 'SET_BULK_DELETING', payload: false });
1055
+ }
1056
+ };
1057
+ const handleDeleteOrphan = async (storyId) => {
1058
+ try {
1059
+ const response = await fetch(`${STORIES_API}/${storyId}`, { method: 'DELETE' });
1060
+ if (response.ok) {
1061
+ dispatch({ type: 'SET_ORPHAN_STORIES', payload: state.orphanStories.filter(s => s.id !== storyId) });
1062
+ const newSet = new Set(state.selectedStoryIds);
1063
+ newSet.delete(storyId);
1064
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: newSet });
1065
+ }
1066
+ }
1067
+ catch (err) {
1068
+ console.error('Error deleting orphan story:', err);
2050
1069
  }
2051
1070
  };
2052
- return (_jsxs("div", { className: "story-ui-panel", style: STYLES.container, children: [_jsxs("div", { style: {
2053
- ...STYLES.sidebar,
2054
- ...(sidebarOpen ? {} : STYLES.sidebarCollapsed),
2055
- }, children: [sidebarOpen && (_jsxs("div", { style: { flex: 1, overflowY: 'auto', padding: '16px' }, children: [_jsxs("button", { onClick: () => setSidebarOpen(false), style: STYLES.sidebarToggle, title: "Collapse sidebar", onMouseEnter: (e) => {
2056
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.25)';
2057
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
2058
- }, onMouseLeave: (e) => {
2059
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
2060
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
2061
- }, children: [_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }) }), _jsx("span", { children: "Chats" })] }), _jsxs("button", { onClick: handleNewChat, style: STYLES.newChatButton, onMouseEnter: (e) => {
2062
- e.currentTarget.style.background = '#2563eb';
2063
- e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)';
2064
- }, onMouseLeave: (e) => {
2065
- e.currentTarget.style.background = '#3b82f6';
2066
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.25)';
2067
- }, children: [_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "12", y1: "5", x2: "12", y2: "19" }), _jsx("line", { x1: "5", y1: "12", x2: "19", y2: "12" })] }), _jsx("span", { children: "New Chat" })] }), recentChats.length > 0 && (_jsx("div", { style: {
2068
- color: '#64748b',
2069
- fontSize: '12px',
2070
- marginBottom: '8px',
2071
- fontWeight: '500',
2072
- textTransform: 'uppercase',
2073
- letterSpacing: '0.05em',
2074
- }, children: "Recent Chats" })), recentChats.map(chat => (_jsxs("div", { onClick: () => handleSelectChat(chat), style: {
2075
- ...STYLES.chatItem,
2076
- ...(activeChatId === chat.id ? STYLES.chatItemActive : {}),
2077
- }, onMouseEnter: (e) => {
2078
- if (activeChatId !== chat.id) {
2079
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.12)';
2080
- }
2081
- const deleteBtn = e.currentTarget.querySelector('.delete-btn');
2082
- if (deleteBtn)
2083
- deleteBtn.style.opacity = '1';
2084
- }, onMouseLeave: (e) => {
2085
- if (activeChatId !== chat.id) {
2086
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)';
2087
- }
2088
- const deleteBtn = e.currentTarget.querySelector('.delete-btn');
2089
- if (deleteBtn)
2090
- deleteBtn.style.opacity = '0';
2091
- }, children: [_jsx("div", { style: STYLES.chatItemTitle, children: chat.title }), _jsx("div", { style: STYLES.chatItemTime, children: formatTime(chat.lastUpdated) }), _jsx("button", { className: "delete-btn", onClick: (e) => handleDeleteChat(chat.id, e), style: STYLES.deleteButton, title: "Delete chat", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }) })] }, chat.id))), orphanStories.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
2092
- display: 'flex',
2093
- alignItems: 'center',
2094
- justifyContent: 'space-between',
2095
- marginTop: '16px',
2096
- marginBottom: '8px',
2097
- }, children: _jsxs("div", { style: {
2098
- display: 'flex',
2099
- alignItems: 'center',
2100
- gap: '8px',
2101
- }, children: [_jsx("input", { type: "checkbox", checked: selectedStoryIds.size === orphanStories.length && orphanStories.length > 0, onChange: toggleSelectAll, style: {
2102
- width: '14px',
2103
- height: '14px',
2104
- cursor: 'pointer',
2105
- accentColor: '#3b82f6',
2106
- }, title: selectedStoryIds.size === orphanStories.length ? 'Deselect all' : 'Select all' }), _jsxs("span", { style: {
2107
- color: '#64748b',
2108
- fontSize: '12px',
2109
- fontWeight: '500',
2110
- textTransform: 'uppercase',
2111
- letterSpacing: '0.05em',
2112
- }, children: ["Generated Files (", orphanStories.length, ")"] })] }) }), selectedStoryIds.size > 0 && (_jsx("div", { style: {
2113
- display: 'flex',
2114
- gap: '8px',
2115
- marginBottom: '12px',
2116
- }, children: _jsx("button", { onClick: handleBulkDelete, disabled: isBulkDeleting, style: {
2117
- flex: 1,
2118
- padding: '6px 10px',
2119
- fontSize: '11px',
2120
- fontWeight: '500',
2121
- background: 'rgba(239, 68, 68, 0.15)',
2122
- color: '#f87171',
2123
- border: '1px solid rgba(239, 68, 68, 0.3)',
2124
- borderRadius: '6px',
2125
- cursor: isBulkDeleting ? 'not-allowed' : 'pointer',
2126
- opacity: isBulkDeleting ? 0.6 : 1,
2127
- transition: 'all 0.15s ease',
2128
- }, onMouseEnter: (e) => {
2129
- if (!isBulkDeleting) {
2130
- e.currentTarget.style.background = 'rgba(239, 68, 68, 0.25)';
2131
- }
2132
- }, onMouseLeave: (e) => {
2133
- e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
2134
- }, children: isBulkDeleting ? 'Deleting...' : `Delete Selected (${selectedStoryIds.size})` }) })), _jsx("div", { style: {
2135
- display: 'flex',
2136
- gap: '8px',
2137
- marginBottom: '12px',
2138
- }, children: _jsx("button", { onClick: handleClearAll, disabled: isBulkDeleting || orphanStories.length === 0, style: {
2139
- flex: 1,
2140
- padding: '6px 10px',
2141
- fontSize: '11px',
2142
- fontWeight: '500',
2143
- background: 'rgba(100, 116, 139, 0.15)',
2144
- color: '#94a3b8',
2145
- border: '1px solid rgba(100, 116, 139, 0.3)',
2146
- borderRadius: '6px',
2147
- cursor: (isBulkDeleting || orphanStories.length === 0) ? 'not-allowed' : 'pointer',
2148
- opacity: (isBulkDeleting || orphanStories.length === 0) ? 0.6 : 1,
2149
- transition: 'all 0.15s ease',
2150
- }, onMouseEnter: (e) => {
2151
- if (!isBulkDeleting && orphanStories.length > 0) {
2152
- e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
2153
- e.currentTarget.style.color = '#f87171';
2154
- e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
2155
- }
2156
- }, onMouseLeave: (e) => {
2157
- e.currentTarget.style.background = 'rgba(100, 116, 139, 0.15)';
2158
- e.currentTarget.style.color = '#94a3b8';
2159
- e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.3)';
2160
- }, children: "Clear All Stories" }) }), orphanStories.map(story => (_jsxs("div", { style: {
2161
- ...STYLES.chatItem,
2162
- background: selectedStoryIds.has(story.id)
2163
- ? 'rgba(59, 130, 246, 0.15)'
2164
- : 'rgba(251, 191, 36, 0.1)',
2165
- borderLeft: selectedStoryIds.has(story.id)
2166
- ? '3px solid rgba(59, 130, 246, 0.5)'
2167
- : '3px solid rgba(251, 191, 36, 0.5)',
2168
- display: 'flex',
2169
- alignItems: 'flex-start',
2170
- gap: '8px',
2171
- }, onMouseEnter: (e) => {
2172
- if (!selectedStoryIds.has(story.id)) {
2173
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.15)';
2174
- }
2175
- const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn');
2176
- if (deleteBtn)
2177
- deleteBtn.style.opacity = '1';
2178
- }, onMouseLeave: (e) => {
2179
- if (!selectedStoryIds.has(story.id)) {
2180
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.1)';
2181
- }
2182
- const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn');
2183
- if (deleteBtn)
2184
- deleteBtn.style.opacity = '0';
2185
- }, children: [_jsx("input", { type: "checkbox", checked: selectedStoryIds.has(story.id), onChange: () => toggleStorySelection(story.id), onClick: (e) => e.stopPropagation(), style: {
2186
- width: '14px',
2187
- height: '14px',
2188
- cursor: 'pointer',
2189
- accentColor: '#3b82f6',
2190
- marginTop: '2px',
2191
- flexShrink: 0,
2192
- } }), _jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [_jsx("div", { style: STYLES.chatItemTitle, children: story.title }), _jsx("div", { style: { ...STYLES.chatItemTime, fontSize: '11px' }, children: story.fileName })] }), _jsx("button", { className: "delete-orphan-btn", onClick: async (e) => {
2193
- e.stopPropagation();
2194
- try {
2195
- const response = await fetch(`${STORIES_API}/${story.id}`, {
2196
- method: 'DELETE',
2197
- });
2198
- if (response.ok) {
2199
- setOrphanStories(prev => prev.filter(s => s.id !== story.id));
2200
- setSelectedStoryIds(prev => {
2201
- const newSet = new Set(prev);
2202
- newSet.delete(story.id);
2203
- return newSet;
2204
- });
2205
- }
2206
- else {
2207
- console.error('Failed to delete orphan story');
2208
- }
2209
- }
2210
- catch (err) {
2211
- console.error('Error deleting orphan story:', err);
2212
- }
2213
- }, style: STYLES.deleteButton, title: "Delete generated file", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }) })] }, story.id)))] }))] })), !sidebarOpen && (_jsx("div", { style: { padding: '8px', display: 'flex', justifyContent: 'center' }, children: _jsx("button", { onClick: () => setSidebarOpen(true), style: {
2214
- ...STYLES.sidebarToggle,
2215
- width: '38px',
2216
- height: '38px',
2217
- padding: '0',
2218
- fontSize: '16px',
2219
- borderRadius: '8px',
2220
- }, title: "Expand sidebar", onMouseEnter: (e) => {
2221
- e.currentTarget.style.transform = 'scale(1.05)';
2222
- e.currentTarget.style.background = '#2563eb';
2223
- }, onMouseLeave: (e) => {
2224
- e.currentTarget.style.transform = 'scale(1)';
2225
- e.currentTarget.style.background = '#3b82f6';
2226
- }, children: _jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "3", y1: "6", x2: "21", y2: "6" }), _jsx("line", { x1: "3", y1: "12", x2: "21", y2: "12" }), _jsx("line", { x1: "3", y1: "18", x2: "21", y2: "18" })] }) }) }))] }), _jsxs("div", { style: { ...STYLES.mainContent, position: 'relative' }, onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, children: [isDragging && (_jsx("div", { style: STYLES.dropOverlay, children: _jsxs("div", { style: STYLES.dropOverlayText, children: [_jsx("svg", { width: 24, height: 24, viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" }) }), "Drop images here"] }) })), _jsxs("div", { style: STYLES.chatHeader, children: [_jsx("h1", { style: {
2227
- fontSize: '22px',
2228
- margin: 0,
2229
- fontWeight: '700',
2230
- background: 'linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%)',
2231
- WebkitBackgroundClip: 'text',
2232
- WebkitTextFillColor: 'transparent',
2233
- display: 'inline-block',
2234
- letterSpacing: '-0.02em'
2235
- }, children: "Story UI" }), _jsx("p", { style: { fontSize: '14px', margin: '6px 0 0 0', color: '#94a3b8', fontWeight: '500' }, children: "Generate Storybook stories with AI" }), _jsxs("div", { style: {
2236
- display: 'flex',
2237
- alignItems: 'center',
2238
- gap: '6px',
2239
- marginTop: '10px',
2240
- fontSize: '11px'
2241
- }, children: [_jsx("div", { style: {
2242
- width: '6px',
2243
- height: '6px',
2244
- borderRadius: '50%',
2245
- backgroundColor: connectionStatus.connected ? '#10b981' : '#f87171'
2246
- } }), _jsx("span", { className: `story-ui-status ${connectionStatus.connected ? 'story-ui-status-connected' : 'story-ui-status-disconnected'}`, style: { color: connectionStatus.connected ? '#10b981' : '#ef4444', fontWeight: '400' }, children: connectionStatus.connected
2247
- ? `Connected to ${getConnectionDisplayText()}`
2248
- : `Disconnected: ${connectionStatus.error || 'Server not running'}` })] }), connectionStatus.connected && availableProviders.length > 0 && (_jsxs("div", { style: {
2249
- display: 'flex',
2250
- gap: '12px',
2251
- marginTop: '12px',
2252
- flexWrap: 'wrap'
2253
- }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx("label", { style: { fontSize: '12px', color: '#94a3b8', fontWeight: '500' }, children: "Provider:" }), _jsx("select", { value: selectedProvider, onChange: (e) => {
2254
- const newProvider = e.target.value;
2255
- setSelectedProvider(newProvider);
2256
- // Reset model to first available for new provider
2257
- const provider = availableProviders.find(p => p.type === newProvider);
2258
- if (provider && provider.models.length > 0) {
2259
- setSelectedModel(provider.models[0]);
2260
- }
2261
- }, style: {
2262
- background: '#1e293b',
2263
- border: '1px solid #334155',
2264
- borderRadius: '6px',
2265
- color: '#e2e8f0',
2266
- padding: '4px 8px',
2267
- fontSize: '12px',
2268
- cursor: 'pointer'
2269
- }, children: availableProviders.map(p => (_jsx("option", { value: p.type, children: p.name }, p.type))) })] }), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx("label", { style: { fontSize: '12px', color: '#94a3b8', fontWeight: '500' }, children: "Model:" }), _jsx("select", { value: selectedModel, onChange: (e) => setSelectedModel(e.target.value), style: {
2270
- background: '#1e293b',
2271
- border: '1px solid #334155',
2272
- borderRadius: '6px',
2273
- color: '#e2e8f0',
2274
- padding: '4px 8px',
2275
- fontSize: '12px',
2276
- cursor: 'pointer',
2277
- maxWidth: '200px'
2278
- }, children: availableProviders
2279
- .find(p => p.type === selectedProvider)
2280
- ?.models.map(model => (_jsx("option", { value: model, children: getModelDisplayName(model) }, model))) })] })] }))] }), _jsxs("div", { style: STYLES.chatContainer, children: [error && (_jsx("div", { style: STYLES.errorMessage, children: error })), conversation.length === 0 && !loading && (_jsxs("div", { style: STYLES.emptyState, children: [_jsx("div", { style: STYLES.emptyStateTitle, children: "Start a new conversation" }), _jsx("div", { style: STYLES.emptyStateSubtitle, children: "Describe the UI component you'd like to create" })] })), conversation.map((msg, i) => (_jsx("div", { style: STYLES.messageContainer, children: _jsxs("div", { className: `story-ui-message ${msg.role === 'user' ? 'story-ui-user-message' : 'story-ui-ai-message'}`, style: msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage, children: [msg.role === 'ai' ? renderMarkdown(msg.content) : msg.content, msg.role === 'user' && msg.attachedImages && msg.attachedImages.length > 0 && (_jsx("div", { style: STYLES.userMessageImages, children: msg.attachedImages.map((img) => (_jsx("img", { src: img.base64
2281
- ? `data:${img.mediaType || 'image/png'};base64,${img.base64}`
2282
- : img.preview, alt: "attached", style: STYLES.userMessageImage }, img.id))) }))] }) }, i))), loading && (_jsx("div", { style: STYLES.messageContainer, children: streamingState ? (_jsx(StreamingProgressMessage, { streamingData: streamingState })) : (_jsxs("div", { style: STYLES.loadingMessage, children: [_jsx("span", { children: "Generating story" }), _jsx("span", { className: "loading-dots" })] })) })), _jsx("div", { ref: chatEndRef })] }), _jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", multiple: true, style: { display: 'none' }, onChange: handleFileSelect }), attachedImages.length > 0 && (_jsxs("div", { style: STYLES.imagePreviewContainer, children: [_jsxs("span", { style: STYLES.imagePreviewLabel, children: [_jsx("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" }) }), attachedImages.length, " image", attachedImages.length > 1 ? 's' : '', " attached"] }), attachedImages.map((img) => (_jsxs("div", { style: STYLES.imagePreviewItem, children: [_jsx("img", { src: img.preview, alt: "preview", style: STYLES.imagePreviewImg }), _jsx("button", { type: "button", style: STYLES.imageRemoveButton, onClick: () => removeAttachedImage(img.id), title: "Remove image", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }) })] }, img.id)))] })), _jsxs("form", { onSubmit: handleSend, style: {
2283
- ...STYLES.inputForm,
2284
- ...(attachedImages.length > 0 ? {
2285
- marginTop: 0,
2286
- borderTopLeftRadius: 0,
2287
- borderTopRightRadius: 0,
2288
- } : {})
2289
- }, children: [_jsx("button", { type: "button", onClick: () => fileInputRef.current?.click(), disabled: loading || attachedImages.length >= MAX_IMAGES, style: {
2290
- ...STYLES.uploadButton,
2291
- ...(attachedImages.length >= MAX_IMAGES ? {
2292
- opacity: 0.5,
2293
- cursor: 'not-allowed',
2294
- } : {})
2295
- }, title: attachedImages.length >= MAX_IMAGES
2296
- ? `Maximum ${MAX_IMAGES} images`
2297
- : 'Attach images (screenshots, designs)', onMouseEnter: (e) => {
2298
- if (attachedImages.length < MAX_IMAGES && !loading) {
2299
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)';
2300
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
2301
- }
2302
- }, onMouseLeave: (e) => {
2303
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
2304
- e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
2305
- }, children: _jsx("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" }) }) }), _jsx("input", { ref: inputRef, type: "text", value: input, onChange: e => setInput(e.target.value), onPaste: handlePaste, placeholder: attachedImages.length > 0
2306
- ? "Describe what to create from these images..."
2307
- : "Describe a UI component...", style: STYLES.textInput, onFocus: (e) => {
2308
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
2309
- e.currentTarget.style.boxShadow = '0 0 0 3px rgba(59, 130, 246, 0.1)';
2310
- }, onBlur: (e) => {
2311
- e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
2312
- e.currentTarget.style.boxShadow = 'none';
2313
- } }), _jsxs("button", { type: "submit", disabled: loading || (!input.trim() && attachedImages.length === 0), style: {
2314
- ...STYLES.sendButton,
2315
- ...(loading || (!input.trim() && attachedImages.length === 0) ? {
2316
- opacity: 0.4,
2317
- cursor: 'not-allowed',
2318
- background: '#64748b',
2319
- boxShadow: 'none'
2320
- } : {})
2321
- }, onMouseEnter: (e) => {
2322
- if (!loading && (input.trim() || attachedImages.length > 0)) {
2323
- e.currentTarget.style.background = '#2563eb';
2324
- e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.5)';
2325
- }
2326
- }, onMouseLeave: (e) => {
2327
- if (!loading && (input.trim() || attachedImages.length > 0)) {
2328
- e.currentTarget.style.background = '#3b82f6';
2329
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.35)';
2330
- }
2331
- }, children: [_jsx("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" }) }), _jsx("span", { children: "Send" })] })] })] })] }));
1071
+ // ============================================
1072
+ // Render
1073
+ // ============================================
1074
+ 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.map(chat => (_jsx("div", { className: `sui-chat-item ${state.activeChatId === chat.id ? 'active' : ''} ${contextMenuId === chat.id ? 'menu-open' : ''}`, onClick: () => renamingChatId !== chat.id && handleSelectChat(chat), role: "button", tabIndex: 0, onKeyDown: e => e.key === 'Enter' && renamingChatId !== chat.id && handleSelectChat(chat), children: renamingChatId === chat.id ? (_jsxs("div", { className: "sui-chat-item-rename", children: [_jsx("input", { type: "text", className: "sui-rename-input", value: renameValue, onChange: e => setRenameValue(e.target.value), onKeyDown: e => {
1075
+ if (e.key === 'Enter')
1076
+ handleConfirmRename(chat.id);
1077
+ if (e.key === 'Escape')
1078
+ handleCancelRename();
1079
+ }, onClick: e => e.stopPropagation(), autoFocus: true }), _jsx("button", { className: "sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); handleConfirmRename(chat.id); }, "aria-label": "Save", children: Icons.check }), _jsx("button", { className: "sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); handleCancelRename(); }, "aria-label": "Cancel", children: Icons.x })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "sui-chat-item-title", children: chat.title }), _jsxs("div", { className: "sui-chat-item-actions", children: [_jsx("button", { className: "sui-chat-item-menu sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); setContextMenuId(contextMenuId === chat.id ? null : chat.id); }, "aria-label": "More options", children: Icons.moreVertical }), contextMenuId === chat.id && (_jsxs("div", { className: "sui-context-menu", children: [_jsxs("button", { className: "sui-context-menu-item", onClick: e => handleStartRename(chat.id, chat.title, e), children: [Icons.pencil, _jsx("span", { children: "Rename" })] }), _jsxs("button", { className: "sui-context-menu-item sui-context-menu-item-danger", onClick: e => handleDeleteChat(chat.id, e), children: [Icons.trash, _jsx("span", { children: "Delete" })] })] }))] })] })) }, chat.id))) })] })), !state.sidebarOpen && (_jsx("div", { style: { padding: '12px', display: 'flex', justifyContent: 'center' }, children: _jsx("button", { className: "sui-button sui-button-ghost sui-button-icon", onClick: () => dispatch({ type: 'SET_SIDEBAR', payload: true }), "aria-label": "Show sidebar", children: Icons.panelLeft }) }))] }), _jsxs("main", { className: "sui-main", onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, children: [state.isDragging && (_jsx("div", { className: "sui-drop-overlay", children: _jsxs("div", { className: "sui-drop-overlay-text", children: [Icons.image, _jsx("span", { children: "Drop images here" })] }) })), _jsxs("header", { className: "sui-header", children: [_jsxs("div", { className: "sui-header-left", children: [_jsx("span", { className: "sui-header-title", children: "Story UI" }), _jsxs(Badge, { variant: state.connectionStatus.connected ? 'success' : 'destructive', children: [_jsx("span", { className: "sui-badge-dot" }), state.connectionStatus.connected ? getConnectionDisplayText() : 'Disconnected'] })] }), _jsx("div", { className: "sui-header-right", children: state.connectionStatus.connected && state.availableProviders.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sui-select", children: [_jsxs("div", { className: "sui-select-trigger", children: [_jsx("span", { children: state.availableProviders.find(p => p.type === state.selectedProvider)?.name || 'Provider' }), Icons.chevronDown] }), _jsx("select", { className: "sui-select-native", value: state.selectedProvider, onChange: e => {
1080
+ const newProvider = e.target.value;
1081
+ dispatch({ type: 'SET_SELECTED_PROVIDER', payload: newProvider });
1082
+ const provider = state.availableProviders.find(p => p.type === newProvider);
1083
+ if (provider?.models.length)
1084
+ dispatch({ type: 'SET_SELECTED_MODEL', payload: provider.models[0] });
1085
+ }, "aria-label": "Select provider", children: state.availableProviders.map(p => _jsx("option", { value: p.type, children: p.name }, p.type)) })] }), _jsxs("div", { className: "sui-select", children: [_jsxs("div", { className: "sui-select-trigger", children: [_jsx("span", { children: getModelDisplayName(state.selectedModel) }), Icons.chevronDown] }), _jsx("select", { className: "sui-select-native", value: state.selectedModel, onChange: e => dispatch({ type: 'SET_SELECTED_MODEL', payload: e.target.value }), "aria-label": "Select model", children: state.availableProviders.find(p => p.type === state.selectedProvider)?.models.map(model => (_jsx("option", { value: model, children: getModelDisplayName(model) }, model))) })] })] })) })] }), _jsxs("section", { className: "sui-chat-area", role: "log", "aria-live": "polite", children: [state.error && _jsx("div", { className: "sui-error", role: "alert", style: { margin: '24px' }, children: state.error }), state.conversation.length === 0 && !state.loading ? (_jsxs("div", { className: "sui-welcome", children: [_jsx("h2", { className: "sui-welcome-greeting", children: "What would you like to create?" }), _jsx("p", { className: "sui-welcome-subtitle", children: "Describe any UI component and I'll generate a Storybook story" }), _jsxs("div", { className: "sui-welcome-chips", children: [_jsx("button", { className: "sui-chip", onClick: () => dispatch({ type: 'SET_INPUT', payload: 'Create a responsive card with image, title, and description' }), children: "Card" }), _jsx("button", { className: "sui-chip", onClick: () => dispatch({ type: 'SET_INPUT', payload: 'Create a navigation bar with logo and menu links' }), children: "Navbar" }), _jsx("button", { className: "sui-chip", onClick: () => dispatch({ type: 'SET_INPUT', payload: 'Create a form with input fields and validation' }), children: "Form" }), _jsx("button", { className: "sui-chip", onClick: () => dispatch({ type: 'SET_INPUT', payload: 'Create a hero section with headline and call-to-action' }), children: "Hero" }), _jsx("button", { className: "sui-chip", onClick: () => dispatch({ type: 'SET_INPUT', payload: 'Create a button group with primary and secondary actions' }), children: "Buttons" }), _jsx("button", { className: "sui-chip", onClick: () => dispatch({ type: 'SET_INPUT', payload: 'Create a modal dialog with header, content, and footer' }), children: "Modal" })] })] })) : (_jsxs("div", { className: "sui-chat-messages", children: [state.conversation.map((msg, i) => (_jsx("article", { className: `sui-message ${msg.role === 'user' ? 'sui-message-user' : 'sui-message-ai'}`, children: _jsxs("div", { className: "sui-message-bubble", children: [msg.role === 'ai' ? renderMarkdown(msg.content) : msg.content, msg.role === 'user' && msg.attachedImages && msg.attachedImages.length > 0 && (_jsx("div", { className: "sui-message-images", children: msg.attachedImages.map(img => (_jsx("img", { src: img.base64 ? `data:${img.mediaType};base64,${img.base64}` : img.preview, alt: "attached", className: "sui-message-image" }, img.id))) }))] }) }, i))), state.loading && (_jsx("div", { className: "sui-message sui-message-ai", children: state.streamingState ? _jsx(ProgressIndicator, { streamingState: state.streamingState }) : (_jsx("div", { className: "sui-progress", children: _jsxs("span", { className: "sui-progress-label", children: ["Generating story", _jsx("span", { className: "sui-loading" })] }) })) })), _jsx("div", { ref: chatEndRef })] }))] }), _jsx("div", { className: "sui-input-area", children: _jsxs("div", { className: "sui-input-container", children: [_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", multiple: true, style: { display: 'none' }, onChange: handleFileSelect }), state.attachedImages.length > 0 && (_jsxs("div", { className: "sui-image-previews", children: [_jsxs("span", { className: "sui-image-preview-label", children: [Icons.image, " ", state.attachedImages.length, " image", state.attachedImages.length > 1 ? 's' : ''] }), state.attachedImages.map(img => (_jsxs("div", { className: "sui-image-preview-item", children: [_jsx("img", { src: img.preview, alt: "preview", className: "sui-image-preview-thumb" }), _jsx("button", { className: "sui-image-preview-remove", onClick: () => removeAttachedImage(img.id), "aria-label": "Remove", children: Icons.x })] }, img.id)))] })), _jsxs("form", { onSubmit: handleSend, className: "sui-input-form", style: state.attachedImages.length > 0 ? { borderTopLeftRadius: 0, borderTopRightRadius: 0 } : undefined, children: [_jsx("button", { type: "button", className: "sui-input-form-upload", onClick: () => fileInputRef.current?.click(), disabled: state.loading || state.attachedImages.length >= MAX_IMAGES, "aria-label": "Attach images", children: Icons.image }), _jsx("input", { ref: inputRef, type: "text", className: "sui-input-form-field", value: state.input, onChange: e => dispatch({ type: 'SET_INPUT', payload: e.target.value }), onPaste: handlePaste, placeholder: state.attachedImages.length > 0 ? 'Describe what to create from these images...' : 'Describe a UI component...' }), _jsx("button", { type: "submit", className: "sui-input-form-send", disabled: state.loading || (!state.input.trim() && state.attachedImages.length === 0), "aria-label": "Send", children: Icons.send })] })] }) })] })] }));
2332
1086
  }
2333
1087
  export default StoryUIPanel;
2334
1088
  export { StoryUIPanel };