@tpitre/story-ui 3.8.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,1282 +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 })] }));
1219
396
  }
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" }) }))] }));
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"] })] }))] }));
399
+ }
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([]);
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('');
1244
407
  const chatEndRef = useRef(null);
1245
408
  const inputRef = useRef(null);
1246
409
  const fileInputRef = useRef(null);
1247
410
  const abortControllerRef = useRef(null);
1248
- // Maximum images allowed
1249
- const MAX_IMAGES = 4;
1250
- const MAX_IMAGE_SIZE_MB = 20;
1251
- // 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
1252
571
  const fileToBase64 = (file) => {
1253
572
  return new Promise((resolve, reject) => {
1254
573
  const reader = new FileReader();
1255
574
  reader.readAsDataURL(file);
1256
575
  reader.onload = () => {
1257
576
  const result = reader.result;
1258
- // Extract base64 data (remove data:image/...;base64, prefix)
1259
- const base64 = result.split(',')[1];
1260
- resolve(base64);
577
+ resolve(result.split(',')[1]);
1261
578
  };
1262
579
  reader.onerror = error => reject(error);
1263
580
  });
1264
581
  };
1265
- // Handle file selection
1266
582
  const handleFileSelect = async (e) => {
1267
583
  const files = e.target.files;
1268
584
  if (!files)
1269
585
  return;
1270
- const newImages = [];
1271
586
  const errors = [];
1272
- 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++) {
1273
588
  const file = files[i];
1274
- // Validate file type
1275
589
  if (!file.type.startsWith('image/')) {
1276
590
  errors.push(`${file.name}: Not an image file`);
1277
591
  continue;
1278
592
  }
1279
- // Validate file size
1280
593
  if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1281
594
  errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1282
595
  continue;
@@ -1284,59 +597,42 @@ function StoryUIPanel() {
1284
597
  try {
1285
598
  const base64 = await fileToBase64(file);
1286
599
  const preview = URL.createObjectURL(file);
1287
- newImages.push({
1288
- id: `${Date.now()}-${i}`,
1289
- file,
1290
- preview,
1291
- base64,
1292
- 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' },
1293
603
  });
1294
604
  }
1295
- catch (err) {
605
+ catch {
1296
606
  errors.push(`${file.name}: Failed to process`);
1297
607
  }
1298
608
  }
1299
- if (errors.length > 0) {
1300
- setError(errors.join('\n'));
1301
- }
1302
- setAttachedImages(prev => [...prev, ...newImages]);
1303
- // Reset file input
1304
- if (fileInputRef.current) {
609
+ if (errors.length > 0)
610
+ dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
611
+ if (fileInputRef.current)
1305
612
  fileInputRef.current.value = '';
1306
- }
1307
613
  };
1308
- // Remove attached image
1309
614
  const removeAttachedImage = (id) => {
1310
- setAttachedImages(prev => {
1311
- const removed = prev.find(img => img.id === id);
1312
- if (removed) {
1313
- URL.revokeObjectURL(removed.preview);
1314
- }
1315
- return prev.filter(img => img.id !== id);
1316
- });
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 });
1317
619
  };
1318
- // Clear all attached images
1319
620
  const clearAttachedImages = () => {
1320
- attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
1321
- setAttachedImages([]);
621
+ state.attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
622
+ dispatch({ type: 'CLEAR_ATTACHED_IMAGES' });
1322
623
  };
1323
- // Drag and drop state
1324
- const [isDragging, setIsDragging] = useState(false);
1325
- // Handle drag events
624
+ // Drag and drop handlers
1326
625
  const handleDragEnter = useCallback((e) => {
1327
626
  e.preventDefault();
1328
627
  e.stopPropagation();
1329
- if (e.dataTransfer.types.includes('Files')) {
1330
- setIsDragging(true);
1331
- }
628
+ if (e.dataTransfer.types.includes('Files'))
629
+ dispatch({ type: 'SET_DRAGGING', payload: true });
1332
630
  }, []);
1333
631
  const handleDragLeave = useCallback((e) => {
1334
632
  e.preventDefault();
1335
633
  e.stopPropagation();
1336
- // Only set to false if we're leaving the main container
1337
- if (!e.currentTarget.contains(e.relatedTarget)) {
1338
- setIsDragging(false);
1339
- }
634
+ if (!e.currentTarget.contains(e.relatedTarget))
635
+ dispatch({ type: 'SET_DRAGGING', payload: false });
1340
636
  }, []);
1341
637
  const handleDragOver = useCallback((e) => {
1342
638
  e.preventDefault();
@@ -1345,298 +641,132 @@ function StoryUIPanel() {
1345
641
  const handleDrop = useCallback(async (e) => {
1346
642
  e.preventDefault();
1347
643
  e.stopPropagation();
1348
- setIsDragging(false);
644
+ dispatch({ type: 'SET_DRAGGING', payload: false });
1349
645
  const files = Array.from(e.dataTransfer.files);
1350
646
  const imageFiles = files.filter(f => f.type.startsWith('image/'));
1351
647
  if (imageFiles.length === 0) {
1352
- setError('Please drop image files only');
648
+ dispatch({ type: 'SET_ERROR', payload: 'Please drop image files only' });
1353
649
  return;
1354
650
  }
1355
- const newImages = [];
1356
651
  const errors = [];
1357
- 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++) {
1358
653
  const file = imageFiles[i];
1359
654
  if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1360
- errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
655
+ errors.push(`${file.name}: File too large`);
1361
656
  continue;
1362
657
  }
1363
658
  try {
1364
659
  const base64 = await fileToBase64(file);
1365
660
  const preview = URL.createObjectURL(file);
1366
- newImages.push({
1367
- id: `${Date.now()}-${i}`,
1368
- file,
1369
- preview,
1370
- base64,
1371
- 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' },
1372
664
  });
1373
665
  }
1374
- catch (err) {
666
+ catch {
1375
667
  errors.push(`${file.name}: Failed to process`);
1376
668
  }
1377
669
  }
1378
- if (errors.length > 0) {
1379
- setError(errors.join('\n'));
1380
- }
1381
- setAttachedImages(prev => [...prev, ...newImages]);
1382
- }, [attachedImages.length, fileToBase64]);
1383
- // 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
1384
674
  const handlePaste = useCallback(async (e) => {
1385
675
  const items = e.clipboardData?.items;
1386
676
  if (!items)
1387
677
  return;
1388
678
  const imageItems = [];
1389
679
  for (let i = 0; i < items.length; i++) {
1390
- if (items[i].type.startsWith('image/')) {
680
+ if (items[i].type.startsWith('image/'))
1391
681
  imageItems.push(items[i]);
1392
- }
1393
682
  }
1394
683
  if (imageItems.length === 0)
1395
684
  return;
1396
- // Prevent default text paste behavior when pasting images
1397
685
  e.preventDefault();
1398
- if (attachedImages.length >= MAX_IMAGES) {
1399
- setError(`Maximum ${MAX_IMAGES} images allowed`);
686
+ if (state.attachedImages.length >= MAX_IMAGES) {
687
+ dispatch({ type: 'SET_ERROR', payload: `Maximum ${MAX_IMAGES} images allowed` });
1400
688
  return;
1401
689
  }
1402
- const newImages = [];
1403
- const errors = [];
1404
- for (let i = 0; i < imageItems.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
1405
- const item = imageItems[i];
1406
- const file = item.getAsFile();
1407
- if (!file) {
1408
- 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)
1409
693
  continue;
1410
- }
1411
- if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1412
- errors.push(`Pasted image too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1413
- continue;
1414
- }
1415
694
  try {
1416
695
  const base64 = await fileToBase64(file);
1417
696
  const preview = URL.createObjectURL(file);
1418
- // Create a meaningful name for pasted images
1419
697
  const timestamp = new Date().toISOString().slice(11, 19).replace(/:/g, '-');
1420
- const renamedFile = new File([file], `pasted-image-${timestamp}.${file.type.split('/')[1] || 'png'}`, { type: file.type });
1421
- newImages.push({
1422
- id: `paste-${Date.now()}-${i}`,
1423
- file: renamedFile,
1424
- preview,
1425
- base64,
1426
- 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
+ },
1427
707
  });
1428
708
  }
1429
- catch (err) {
1430
- errors.push('Failed to process pasted image');
1431
- }
1432
- }
1433
- if (errors.length > 0) {
1434
- setError(errors.join('\n'));
1435
- }
1436
- if (newImages.length > 0) {
1437
- setAttachedImages(prev => [...prev, ...newImages]);
1438
- // Clear any existing error on successful paste
1439
- if (errors.length === 0) {
1440
- setError(null);
709
+ catch {
710
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to process pasted image' });
1441
711
  }
1442
712
  }
1443
- }, [attachedImages.length, fileToBase64]);
1444
- // Load and sync chats on mount
1445
- useEffect(() => {
1446
- const initializeChats = async () => {
1447
- // Test connection first
1448
- const connectionTest = await testMCPConnection();
1449
- setConnectionStatus(connectionTest);
1450
- if (connectionTest.connected) {
1451
- // Fetch available providers
1452
- try {
1453
- const providersRes = await fetch(PROVIDERS_API);
1454
- if (providersRes.ok) {
1455
- const providersData = await providersRes.json();
1456
- setAvailableProviders(providersData.providers.filter(p => p.configured));
1457
- // Set initial selection from server defaults
1458
- if (providersData.current) {
1459
- setSelectedProvider(providersData.current.provider.toLowerCase());
1460
- setSelectedModel(providersData.current.model);
1461
- }
1462
- }
1463
- }
1464
- catch (e) {
1465
- console.error('Failed to fetch providers:', e);
1466
- }
1467
- // Fetch design system considerations for environment parity
1468
- // This ensures production gets the same considerations as local development
1469
- try {
1470
- const considerationsRes = await fetch(CONSIDERATIONS_API);
1471
- if (considerationsRes.ok) {
1472
- const considerationsData = await considerationsRes.json();
1473
- if (considerationsData.hasConsiderations && considerationsData.considerations) {
1474
- setConsiderations(considerationsData.considerations);
1475
- console.log(`Loaded considerations from ${considerationsData.source}`);
1476
- }
1477
- }
1478
- }
1479
- catch (e) {
1480
- console.error('Failed to fetch considerations:', e);
1481
- }
1482
- const syncedChats = await syncWithActualStories();
1483
- const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
1484
- setRecentChats(sortedChats);
1485
- if (sortedChats.length > 0) {
1486
- setConversation(sortedChats[0].conversation);
1487
- setActiveChatId(sortedChats[0].id);
1488
- setActiveTitle(sortedChats[0].title);
1489
- }
1490
- // Fetch orphan stories (stories on disk without chat history)
1491
- const orphans = await fetchOrphanStories();
1492
- setOrphanStories(orphans);
1493
- }
1494
- else {
1495
- // Load from local storage if server is not available
1496
- const localChats = loadChats();
1497
- setRecentChats(localChats);
1498
- }
1499
- };
1500
- initializeChats();
1501
- }, []);
1502
- // Scroll to bottom on new message
1503
- useEffect(() => {
1504
- if (chatEndRef.current) {
1505
- chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
1506
- }
1507
- }, [conversation, loading]);
1508
- // Helper function for non-streaming fallback
1509
- const handleSendNonStreaming = async (userInput, newConversation) => {
1510
- const res = await fetch(MCP_API, {
1511
- method: 'POST',
1512
- headers: { 'Content-Type': 'application/json' },
1513
- body: JSON.stringify({
1514
- prompt: userInput,
1515
- conversation: newConversation,
1516
- fileName: activeChatId || undefined,
1517
- provider: selectedProvider || undefined,
1518
- model: selectedModel || undefined,
1519
- considerations: considerations || undefined,
1520
- }),
1521
- });
1522
- const contentType = res.headers.get('content-type');
1523
- if (!contentType || !contentType.includes('application/json')) {
1524
- const text = await res.text();
1525
- throw new Error(`Server returned non-JSON response. Response: ${text.substring(0, 200)}...`);
1526
- }
1527
- const data = await res.json();
1528
- if (!res.ok || !data.success)
1529
- throw new Error(data.error || 'Story generation failed');
1530
- return data;
1531
- };
1532
- // Helper function to build a conversational response from completion data
1533
- // Uses special markers [SUCCESS], [ERROR], [TIP], [WRENCH] that renderMarkdown converts to icons
1534
- // Track whether we've shown the refresh hint in this session
1535
- const hasShownRefreshHint = useRef(false);
713
+ }, [state.attachedImages.length]);
714
+ // Build response message
1536
715
  const buildConversationalResponse = (completion, isUpdate) => {
1537
716
  const parts = [];
1538
717
  const statusMarker = completion.success ? '[SUCCESS]' : '[ERROR]';
1539
- // Lead with the result - more conversational
1540
- if (isUpdate) {
1541
- parts.push(`${statusMarker} **Updated: "${completion.title}"**`);
1542
- }
1543
- else {
1544
- parts.push(`${statusMarker} **Created: "${completion.title}"**`);
1545
- }
1546
- // Build component insights with reasons when available
718
+ parts.push(isUpdate ? `${statusMarker} **Updated: "${completion.title}"**` : `${statusMarker} **Created: "${completion.title}"**`);
1547
719
  const componentCount = completion.componentsUsed?.length || 0;
1548
720
  if (componentCount > 0) {
1549
- const componentList = completion.componentsUsed.slice(0, 5);
1550
- // Check if we have meaningful reasons (not just "Used in composition")
1551
- const componentsWithReasons = componentList.filter(c => c.reason && c.reason !== 'Used in composition');
1552
- if (componentsWithReasons.length > 0) {
1553
- // Show components with their reasons
1554
- const insights = componentsWithReasons
1555
- .slice(0, 3)
1556
- .map(c => `\`${c.name}\` - ${c.reason?.toLowerCase()}`)
1557
- .join(', ');
1558
- parts.push(`\nUsed ${insights}${componentCount > 3 ? ` and ${componentCount - 3} more` : ''}.`);
1559
- }
1560
- else {
1561
- // Fallback to simple list
1562
- const names = componentList.map(c => `\`${c.name}\``).join(', ');
1563
- parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
1564
- }
721
+ const names = completion.componentsUsed.slice(0, 5).map(c => `\`${c.name}\``).join(', ');
722
+ parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
1565
723
  }
1566
- // Add layout decisions with educational context
1567
- if (completion.layoutChoices && completion.layoutChoices.length > 0) {
1568
- const primaryLayout = completion.layoutChoices[0];
1569
- 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}.`);
1570
727
  }
1571
- // Add style choices only if they add value
1572
- if (completion.styleChoices && completion.styleChoices.length > 0) {
1573
- const notableStyles = completion.styleChoices.filter(s => s.reason && s.reason !== 'Semantic color from design system');
1574
- if (notableStyles.length > 0) {
1575
- const styleInfo = notableStyles[0];
1576
- parts.push(` Applied \`${styleInfo.value}\` for ${styleInfo.reason?.toLowerCase() || 'visual consistency'}.`);
1577
- }
1578
- }
1579
- // Add validation fixes notice
1580
728
  if (completion.validation?.autoFixApplied) {
1581
729
  parts.push(`\n\n[WRENCH] **Auto-fixed:** Minor syntax issues were automatically corrected.`);
1582
730
  }
1583
- // Add suggestions only if meaningful
1584
- if (completion.suggestions && completion.suggestions.length > 0) {
1585
- const suggestion = completion.suggestions[0];
1586
- // Only show if it's not the generic "review the generated code" message
1587
- if (!suggestion.toLowerCase().includes('review the generated code')) {
1588
- parts.push(`\n\n[TIP] **Tip:** ${suggestion}`);
1589
- }
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]}`);
1590
733
  }
1591
- // Show refresh hint only once per session for new stories (local mode only)
1592
- // In Edge mode, stories are stored in Durable Objects, not on filesystem
1593
734
  if (!isUpdate && !hasShownRefreshHint.current) {
1594
- if (isEdgeMode()) {
1595
- parts.push(`\n\n_Story saved to cloud. View code in chat history recent chats navigation._`);
1596
- }
1597
- else {
1598
- parts.push(`\n\n_Might need toefresh Storybook (Cmd/Ctrl + R) to see new stories in the sidebar._`);
1599
- }
735
+ parts.push(isEdgeMode() ? `\n\n_Story saved to cloud._` : `\n\n_Might need to refresh Storybook (Cmd/Ctrl + R) to see new stories._`);
1600
736
  hasShownRefreshHint.current = true;
1601
737
  }
1602
- // Add metrics in a subtle way (if available)
1603
738
  if (completion.metrics?.totalTimeMs) {
1604
- const seconds = (completion.metrics.totalTimeMs / 1000).toFixed(1);
1605
- parts.push(`\n\n_${seconds}s_`);
739
+ parts.push(`\n\n_${(completion.metrics.totalTimeMs / 1000).toFixed(1)}s_`);
1606
740
  }
1607
741
  return parts.join('');
1608
742
  };
1609
- // Helper function to finalize conversation after streaming completes
743
+ // Finalize streaming
1610
744
  const finalizeStreamingConversation = useCallback((newConversation, completion, userInput) => {
1611
- // Build conversational response using rich completion data
1612
745
  const isUpdate = completion.summary.action === 'updated';
1613
746
  const responseMessage = buildConversationalResponse(completion, isUpdate);
1614
747
  const aiMsg = { role: 'ai', content: responseMessage };
1615
748
  const updatedConversation = [...newConversation, aiMsg];
1616
- setConversation(updatedConversation);
1617
- // Update chat session
1618
- const isExistingSession = activeChatId && conversation.length > 0;
1619
- if (isExistingSession && activeChatId) {
749
+ dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
750
+ const isExistingSession = state.activeChatId && state.conversation.length > 0;
751
+ if (isExistingSession && state.activeChatId) {
1620
752
  const updatedSession = {
1621
- id: activeChatId,
1622
- title: activeTitle,
1623
- fileName: completion.fileName || activeChatId,
753
+ id: state.activeChatId,
754
+ title: state.activeTitle,
755
+ fileName: completion.fileName || state.activeChatId,
1624
756
  conversation: updatedConversation,
1625
757
  lastUpdated: Date.now(),
1626
758
  };
1627
759
  const chats = loadChats();
1628
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
1629
- if (chatIndex !== -1) {
760
+ const chatIndex = chats.findIndex(c => c.id === state.activeChatId);
761
+ if (chatIndex !== -1)
1630
762
  chats[chatIndex] = updatedSession;
1631
- }
1632
763
  saveChats(chats);
1633
- setRecentChats(chats);
764
+ dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
1634
765
  }
1635
766
  else {
1636
767
  const chatId = completion.storyId || completion.fileName || Date.now().toString();
1637
768
  const chatTitle = completion.title || userInput;
1638
- setActiveChatId(chatId);
1639
- setActiveTitle(chatTitle);
769
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chatId, title: chatTitle } });
1640
770
  const newSession = {
1641
771
  id: chatId,
1642
772
  title: chatTitle,
@@ -1646,66 +776,48 @@ function StoryUIPanel() {
1646
776
  };
1647
777
  const chats = loadChats().filter(c => c.id !== chatId);
1648
778
  chats.unshift(newSession);
1649
- if (chats.length > MAX_RECENT_CHATS) {
779
+ if (chats.length > MAX_RECENT_CHATS)
1650
780
  chats.splice(MAX_RECENT_CHATS);
1651
- }
1652
781
  saveChats(chats);
1653
- setRecentChats(chats);
1654
- // Auto-navigate to the newly created story after HMR processes the file
1655
- // This prevents the "Couldn't find story after HMR" error by refreshing
1656
- // after the file system has been updated and HMR has processed the change
1657
- navigateToNewStory(chatTitle, completion.code);
782
+ dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
1658
783
  }
1659
- }, [activeChatId, activeTitle, conversation.length]);
784
+ }, [state.activeChatId, state.activeTitle, state.conversation.length]);
785
+ // Handle send
1660
786
  const handleSend = async (e) => {
1661
787
  if (e)
1662
788
  e.preventDefault();
1663
- // Allow sending with either text or images
1664
- if (!input.trim() && attachedImages.length === 0)
789
+ if (!state.input.trim() && state.attachedImages.length === 0)
1665
790
  return;
1666
- // Use input text or default vision prompt if only images
1667
- const userInput = input.trim() || (attachedImages.length > 0 ? 'Create a component that matches this design' : '');
1668
- setError(null);
1669
- setLoading(true);
1670
- setStreamingState(null);
1671
- // 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 });
1672
795
  const connectionTest = await testMCPConnection();
1673
- setConnectionStatus(connectionTest);
796
+ dispatch({ type: 'SET_CONNECTION_STATUS', payload: connectionTest });
1674
797
  if (!connectionTest.connected) {
1675
- setError(`Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}`);
1676
- 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 });
1677
800
  return;
1678
801
  }
1679
- // Capture images before clearing
1680
- const imagesToSend = [...attachedImages];
802
+ const imagesToSend = [...state.attachedImages];
1681
803
  const hasImages = imagesToSend.length > 0;
1682
- // Create user message with images
1683
804
  const userMessage = {
1684
805
  role: 'user',
1685
806
  content: userInput,
1686
- attachedImages: hasImages ? imagesToSend : undefined
807
+ attachedImages: hasImages ? imagesToSend : undefined,
1687
808
  };
1688
- const newConversation = [...conversation, userMessage];
1689
- setConversation(newConversation);
1690
- setInput('');
809
+ const newConversation = [...state.conversation, userMessage];
810
+ dispatch({ type: 'SET_CONVERSATION', payload: newConversation });
811
+ dispatch({ type: 'SET_INPUT', payload: '' });
1691
812
  clearAttachedImages();
1692
- // Use streaming if enabled
1693
813
  if (USE_STREAMING) {
1694
814
  try {
1695
- // Cancel any existing request
1696
- if (abortControllerRef.current) {
815
+ if (abortControllerRef.current)
1697
816
  abortControllerRef.current.abort();
1698
- }
1699
817
  abortControllerRef.current = new AbortController();
1700
- // Initialize streaming state
1701
- setStreamingState({});
1702
- // Prepare images for API request
818
+ dispatch({ type: 'SET_STREAMING_STATE', payload: {} });
1703
819
  const imagePayload = hasImages
1704
- ? imagesToSend.map(img => ({
1705
- type: 'base64',
1706
- data: img.base64,
1707
- mediaType: img.file.type,
1708
- }))
820
+ ? imagesToSend.map(img => ({ type: 'base64', data: img.base64, mediaType: img.file.type }))
1709
821
  : undefined;
1710
822
  const response = await fetch(MCP_STREAM_API, {
1711
823
  method: 'POST',
@@ -1713,25 +825,23 @@ function StoryUIPanel() {
1713
825
  body: JSON.stringify({
1714
826
  prompt: userInput,
1715
827
  conversation: newConversation,
1716
- fileName: activeChatId || undefined,
1717
- isUpdate: activeChatId && conversation.length > 0,
1718
- originalTitle: activeTitle || undefined,
1719
- 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,
1720
832
  images: imagePayload,
1721
833
  visionMode: hasImages ? 'screenshot_to_story' : undefined,
1722
- provider: selectedProvider || undefined,
1723
- model: selectedModel || undefined,
1724
- considerations: considerations || undefined,
834
+ provider: state.selectedProvider || undefined,
835
+ model: state.selectedModel || undefined,
836
+ considerations: state.considerations || undefined,
1725
837
  }),
1726
838
  signal: abortControllerRef.current.signal,
1727
839
  });
1728
- if (!response.ok) {
840
+ if (!response.ok)
1729
841
  throw new Error(`Streaming request failed: ${response.status}`);
1730
- }
1731
842
  const reader = response.body?.getReader();
1732
- if (!reader) {
843
+ if (!reader)
1733
844
  throw new Error('No response body');
1734
- }
1735
845
  const decoder = new TextDecoder();
1736
846
  let buffer = '';
1737
847
  let completionData = null;
@@ -1741,215 +851,109 @@ function StoryUIPanel() {
1741
851
  if (done)
1742
852
  break;
1743
853
  buffer += decoder.decode(value, { stream: true });
1744
- // Parse SSE events from buffer
1745
854
  const lines = buffer.split('\n');
1746
- buffer = lines.pop() || ''; // Keep incomplete line in buffer
855
+ buffer = lines.pop() || '';
1747
856
  for (const line of lines) {
1748
857
  if (line.startsWith('data: ')) {
1749
858
  try {
1750
859
  const event = JSON.parse(line.slice(6));
1751
- // Update streaming state based on event type
1752
860
  switch (event.type) {
1753
861
  case 'intent':
1754
- setStreamingState(prev => ({ ...prev, intent: event.data }));
862
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { intent: event.data } });
1755
863
  break;
1756
864
  case 'progress':
1757
- setStreamingState(prev => ({ ...prev, progress: event.data }));
865
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { progress: event.data } });
1758
866
  break;
1759
867
  case 'validation':
1760
- setStreamingState(prev => ({ ...prev, validation: event.data }));
868
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { validation: event.data } });
1761
869
  break;
1762
870
  case 'retry':
1763
- setStreamingState(prev => ({ ...prev, retry: event.data }));
871
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { retry: event.data } });
1764
872
  break;
1765
873
  case 'completion':
1766
874
  completionData = event.data;
1767
- setStreamingState(prev => ({ ...prev, completion: event.data }));
875
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { completion: completionData } });
1768
876
  break;
1769
877
  case 'error':
1770
878
  errorData = event.data;
1771
- setStreamingState(prev => ({ ...prev, error: event.data }));
879
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { error: errorData } });
1772
880
  break;
1773
881
  }
1774
882
  }
1775
- catch (parseError) {
1776
- console.warn('Failed to parse SSE event:', line, parseError);
883
+ catch {
884
+ console.warn('Failed to parse SSE event:', line);
1777
885
  }
1778
886
  }
1779
887
  }
1780
888
  }
1781
- // Handle completion or error
1782
889
  if (completionData) {
1783
890
  finalizeStreamingConversation(newConversation, completionData, userInput);
1784
891
  }
1785
892
  else if (errorData) {
1786
- setError(errorData.message);
893
+ dispatch({ type: 'SET_ERROR', payload: errorData.message });
1787
894
  const errorConversation = [...newConversation, { role: 'ai', content: `Error: ${errorData.message}\n\n${errorData.suggestion || ''}` }];
1788
- setConversation(errorConversation);
895
+ dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
1789
896
  }
1790
897
  }
1791
898
  catch (err) {
1792
- if (err.name === 'AbortError') {
1793
- console.log('Request aborted');
899
+ if (err.name === 'AbortError')
1794
900
  return;
1795
- }
1796
- // Fall back to non-streaming on error
1797
901
  console.warn('Streaming failed, falling back to non-streaming:', err);
1798
- setStreamingState(null);
902
+ dispatch({ type: 'SET_STREAMING_STATE', payload: null });
1799
903
  try {
1800
- const data = await handleSendNonStreaming(userInput, newConversation);
1801
- // Process non-streaming response (same as before)
1802
- let responseMessage;
1803
- const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
1804
- // Build conversational response for fallback
1805
- if (data.isUpdate) {
1806
- 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._`;
1807
- }
1808
- else {
1809
- 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).`;
1810
- }
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.`;
1811
920
  const aiMsg = { role: 'ai', content: responseMessage };
1812
921
  const updatedConversation = [...newConversation, aiMsg];
1813
- setConversation(updatedConversation);
1814
- // Update chat session
1815
- const isUpdate = activeChatId && conversation.length > 0;
1816
- if (isUpdate && activeChatId) {
1817
- const updatedSession = {
1818
- id: activeChatId,
1819
- title: activeTitle,
1820
- fileName: data.fileName || activeChatId,
1821
- conversation: updatedConversation,
1822
- lastUpdated: Date.now(),
1823
- };
1824
- const chats = loadChats();
1825
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
1826
- if (chatIndex !== -1)
1827
- chats[chatIndex] = updatedSession;
1828
- saveChats(chats);
1829
- setRecentChats(chats);
1830
- }
1831
- else {
1832
- const chatId = data.storyId || data.fileName || Date.now().toString();
1833
- const chatTitle = data.title || userInput;
1834
- setActiveChatId(chatId);
1835
- setActiveTitle(chatTitle);
1836
- const newSession = {
1837
- id: chatId,
1838
- title: chatTitle,
1839
- fileName: data.fileName || '',
1840
- conversation: updatedConversation,
1841
- lastUpdated: Date.now(),
1842
- };
1843
- const chats = loadChats().filter(c => c.id !== chatId);
1844
- chats.unshift(newSession);
1845
- if (chats.length > MAX_RECENT_CHATS)
1846
- chats.splice(MAX_RECENT_CHATS);
1847
- saveChats(chats);
1848
- setRecentChats(chats);
1849
- // Auto-navigate to the newly created story
1850
- navigateToNewStory(chatTitle, data.code);
1851
- }
922
+ dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
1852
923
  }
1853
924
  catch (fallbackErr) {
1854
925
  const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : 'Unknown error';
1855
- setError(errorMessage);
926
+ dispatch({ type: 'SET_ERROR', payload: errorMessage });
1856
927
  const errorConversation = [...newConversation, { role: 'ai', content: `Error: ${errorMessage}` }];
1857
- setConversation(errorConversation);
928
+ dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
1858
929
  }
1859
930
  }
1860
931
  finally {
1861
- setLoading(false);
1862
- setStreamingState(null);
932
+ dispatch({ type: 'SET_LOADING', payload: false });
933
+ dispatch({ type: 'SET_STREAMING_STATE', payload: null });
1863
934
  abortControllerRef.current = null;
1864
935
  }
1865
936
  }
1866
- else {
1867
- // Non-streaming mode (original implementation)
1868
- try {
1869
- const data = await handleSendNonStreaming(userInput, newConversation);
1870
- let responseMessage;
1871
- const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
1872
- // Build conversational response for non-streaming mode
1873
- if (data.isUpdate) {
1874
- 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._`;
1875
- }
1876
- else {
1877
- 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).`;
1878
- }
1879
- const aiMsg = { role: 'ai', content: responseMessage };
1880
- const updatedConversation = [...newConversation, aiMsg];
1881
- setConversation(updatedConversation);
1882
- const isUpdate = activeChatId && conversation.length > 0;
1883
- if (isUpdate && activeChatId) {
1884
- const updatedSession = {
1885
- id: activeChatId,
1886
- title: activeTitle,
1887
- fileName: data.fileName || activeChatId,
1888
- conversation: updatedConversation,
1889
- lastUpdated: Date.now(),
1890
- };
1891
- const chats = loadChats();
1892
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
1893
- if (chatIndex !== -1)
1894
- chats[chatIndex] = updatedSession;
1895
- saveChats(chats);
1896
- setRecentChats(chats);
1897
- }
1898
- else {
1899
- const chatId = data.storyId || data.fileName || Date.now().toString();
1900
- const chatTitle = data.title || userInput;
1901
- setActiveChatId(chatId);
1902
- setActiveTitle(chatTitle);
1903
- const newSession = {
1904
- id: chatId,
1905
- title: chatTitle,
1906
- fileName: data.fileName || '',
1907
- conversation: updatedConversation,
1908
- lastUpdated: Date.now(),
1909
- };
1910
- const chats = loadChats().filter(c => c.id !== chatId);
1911
- chats.unshift(newSession);
1912
- if (chats.length > MAX_RECENT_CHATS)
1913
- chats.splice(MAX_RECENT_CHATS);
1914
- saveChats(chats);
1915
- setRecentChats(chats);
1916
- }
1917
- }
1918
- catch (err) {
1919
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1920
- setError(errorMessage);
1921
- const errorConversation = [...newConversation, { role: 'ai', content: `Error: ${errorMessage}` }];
1922
- setConversation(errorConversation);
1923
- }
1924
- finally {
1925
- setLoading(false);
1926
- }
1927
- }
1928
937
  };
938
+ // Chat management
1929
939
  const handleSelectChat = (chat) => {
1930
- setConversation(chat.conversation);
1931
- setActiveChatId(chat.id);
1932
- setActiveTitle(chat.title);
1933
- };
1934
- const handleNewChat = () => {
1935
- setConversation([]);
1936
- setActiveChatId(null);
1937
- setActiveTitle('');
940
+ dispatch({ type: 'SET_CONVERSATION', payload: chat.conversation });
941
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
1938
942
  };
943
+ const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
1939
944
  const handleDeleteChat = async (chatId, e) => {
1940
- e.stopPropagation(); // Prevent selecting the chat
945
+ if (e)
946
+ e.stopPropagation();
947
+ setContextMenuId(null);
1941
948
  if (confirm('Delete this story and chat? This action cannot be undone.')) {
1942
949
  const success = await deleteStoryAndChat(chatId);
1943
950
  if (success) {
1944
- // Update local state
1945
- const updatedChats = recentChats.filter(chat => chat.id !== chatId);
1946
- setRecentChats(updatedChats);
1947
- // If we deleted the active chat, switch to another or clear
1948
- 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) {
1949
954
  if (updatedChats.length > 0) {
1950
- setConversation(updatedChats[0].conversation);
1951
- setActiveChatId(updatedChats[0].id);
1952
- 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 } });
1953
957
  }
1954
958
  else {
1955
959
  handleNewChat();
@@ -1961,202 +965,124 @@ function StoryUIPanel() {
1961
965
  }
1962
966
  }
1963
967
  };
1964
- return (_jsxs("div", { className: "story-ui-panel", style: STYLES.container, children: [_jsxs("div", { style: {
1965
- ...STYLES.sidebar,
1966
- ...(sidebarOpen ? {} : STYLES.sidebarCollapsed),
1967
- }, 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) => {
1968
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.25)';
1969
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
1970
- }, onMouseLeave: (e) => {
1971
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
1972
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
1973
- }, 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) => {
1974
- e.currentTarget.style.background = '#2563eb';
1975
- e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)';
1976
- }, onMouseLeave: (e) => {
1977
- e.currentTarget.style.background = '#3b82f6';
1978
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.25)';
1979
- }, 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: {
1980
- color: '#64748b',
1981
- fontSize: '12px',
1982
- marginBottom: '8px',
1983
- fontWeight: '500',
1984
- textTransform: 'uppercase',
1985
- letterSpacing: '0.05em',
1986
- }, children: "Recent Chats" })), recentChats.map(chat => (_jsxs("div", { onClick: () => handleSelectChat(chat), style: {
1987
- ...STYLES.chatItem,
1988
- ...(activeChatId === chat.id ? STYLES.chatItemActive : {}),
1989
- }, onMouseEnter: (e) => {
1990
- if (activeChatId !== chat.id) {
1991
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.12)';
1992
- }
1993
- const deleteBtn = e.currentTarget.querySelector('.delete-btn');
1994
- if (deleteBtn)
1995
- deleteBtn.style.opacity = '1';
1996
- }, onMouseLeave: (e) => {
1997
- if (activeChatId !== chat.id) {
1998
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)';
1999
- }
2000
- const deleteBtn = e.currentTarget.querySelector('.delete-btn');
2001
- if (deleteBtn)
2002
- deleteBtn.style.opacity = '0';
2003
- }, 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: {
2004
- color: '#64748b',
2005
- fontSize: '12px',
2006
- marginTop: '16px',
2007
- marginBottom: '8px',
2008
- fontWeight: '500',
2009
- textTransform: 'uppercase',
2010
- letterSpacing: '0.05em',
2011
- }, children: "Generated Files" }), orphanStories.map(story => (_jsxs("div", { style: {
2012
- ...STYLES.chatItem,
2013
- background: 'rgba(251, 191, 36, 0.1)',
2014
- borderLeft: '3px solid rgba(251, 191, 36, 0.5)',
2015
- }, onMouseEnter: (e) => {
2016
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.15)';
2017
- const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn');
2018
- if (deleteBtn)
2019
- deleteBtn.style.opacity = '1';
2020
- }, onMouseLeave: (e) => {
2021
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.1)';
2022
- const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn');
2023
- if (deleteBtn)
2024
- deleteBtn.style.opacity = '0';
2025
- }, 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) => {
2026
- e.stopPropagation();
2027
- try {
2028
- const response = await fetch(`${STORIES_API}/${story.id}`, {
2029
- method: 'DELETE',
2030
- });
2031
- if (response.ok) {
2032
- setOrphanStories(prev => prev.filter(s => s.id !== story.id));
2033
- }
2034
- else {
2035
- console.error('Failed to delete orphan story');
2036
- }
2037
- }
2038
- catch (err) {
2039
- console.error('Error deleting orphan story:', err);
2040
- }
2041
- }, 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: {
2042
- ...STYLES.sidebarToggle,
2043
- width: '38px',
2044
- height: '38px',
2045
- padding: '0',
2046
- fontSize: '16px',
2047
- borderRadius: '8px',
2048
- }, title: "Expand sidebar", onMouseEnter: (e) => {
2049
- e.currentTarget.style.transform = 'scale(1.05)';
2050
- e.currentTarget.style.background = '#2563eb';
2051
- }, onMouseLeave: (e) => {
2052
- e.currentTarget.style.transform = 'scale(1)';
2053
- e.currentTarget.style.background = '#3b82f6';
2054
- }, 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: {
2055
- fontSize: '22px',
2056
- margin: 0,
2057
- fontWeight: '700',
2058
- background: 'linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%)',
2059
- WebkitBackgroundClip: 'text',
2060
- WebkitTextFillColor: 'transparent',
2061
- display: 'inline-block',
2062
- letterSpacing: '-0.02em'
2063
- }, 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: {
2064
- display: 'flex',
2065
- alignItems: 'center',
2066
- gap: '6px',
2067
- marginTop: '10px',
2068
- fontSize: '11px'
2069
- }, children: [_jsx("div", { style: {
2070
- width: '6px',
2071
- height: '6px',
2072
- borderRadius: '50%',
2073
- backgroundColor: connectionStatus.connected ? '#10b981' : '#f87171'
2074
- } }), _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
2075
- ? `Connected to ${getConnectionDisplayText()}`
2076
- : `Disconnected: ${connectionStatus.error || 'Server not running'}` })] }), connectionStatus.connected && availableProviders.length > 0 && (_jsxs("div", { style: {
2077
- display: 'flex',
2078
- gap: '12px',
2079
- marginTop: '12px',
2080
- flexWrap: 'wrap'
2081
- }, 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) => {
2082
- const newProvider = e.target.value;
2083
- setSelectedProvider(newProvider);
2084
- // Reset model to first available for new provider
2085
- const provider = availableProviders.find(p => p.type === newProvider);
2086
- if (provider && provider.models.length > 0) {
2087
- setSelectedModel(provider.models[0]);
2088
- }
2089
- }, style: {
2090
- background: '#1e293b',
2091
- border: '1px solid #334155',
2092
- borderRadius: '6px',
2093
- color: '#e2e8f0',
2094
- padding: '4px 8px',
2095
- fontSize: '12px',
2096
- cursor: 'pointer'
2097
- }, 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: {
2098
- background: '#1e293b',
2099
- border: '1px solid #334155',
2100
- borderRadius: '6px',
2101
- color: '#e2e8f0',
2102
- padding: '4px 8px',
2103
- fontSize: '12px',
2104
- cursor: 'pointer',
2105
- maxWidth: '200px'
2106
- }, children: availableProviders
2107
- .find(p => p.type === selectedProvider)
2108
- ?.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
2109
- ? `data:${img.mediaType || 'image/png'};base64,${img.base64}`
2110
- : 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: {
2111
- ...STYLES.inputForm,
2112
- ...(attachedImages.length > 0 ? {
2113
- marginTop: 0,
2114
- borderTopLeftRadius: 0,
2115
- borderTopRightRadius: 0,
2116
- } : {})
2117
- }, children: [_jsx("button", { type: "button", onClick: () => fileInputRef.current?.click(), disabled: loading || attachedImages.length >= MAX_IMAGES, style: {
2118
- ...STYLES.uploadButton,
2119
- ...(attachedImages.length >= MAX_IMAGES ? {
2120
- opacity: 0.5,
2121
- cursor: 'not-allowed',
2122
- } : {})
2123
- }, title: attachedImages.length >= MAX_IMAGES
2124
- ? `Maximum ${MAX_IMAGES} images`
2125
- : 'Attach images (screenshots, designs)', onMouseEnter: (e) => {
2126
- if (attachedImages.length < MAX_IMAGES && !loading) {
2127
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)';
2128
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
2129
- }
2130
- }, onMouseLeave: (e) => {
2131
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
2132
- e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
2133
- }, 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
2134
- ? "Describe what to create from these images..."
2135
- : "Describe a UI component...", style: STYLES.textInput, onFocus: (e) => {
2136
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
2137
- e.currentTarget.style.boxShadow = '0 0 0 3px rgba(59, 130, 246, 0.1)';
2138
- }, onBlur: (e) => {
2139
- e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
2140
- e.currentTarget.style.boxShadow = 'none';
2141
- } }), _jsxs("button", { type: "submit", disabled: loading || (!input.trim() && attachedImages.length === 0), style: {
2142
- ...STYLES.sendButton,
2143
- ...(loading || (!input.trim() && attachedImages.length === 0) ? {
2144
- opacity: 0.4,
2145
- cursor: 'not-allowed',
2146
- background: '#64748b',
2147
- boxShadow: 'none'
2148
- } : {})
2149
- }, onMouseEnter: (e) => {
2150
- if (!loading && (input.trim() || attachedImages.length > 0)) {
2151
- e.currentTarget.style.background = '#2563eb';
2152
- e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.5)';
2153
- }
2154
- }, onMouseLeave: (e) => {
2155
- if (!loading && (input.trim() || attachedImages.length > 0)) {
2156
- e.currentTarget.style.background = '#3b82f6';
2157
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.35)';
2158
- }
2159
- }, 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" })] })] })] })] }));
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() } });
988
+ }
989
+ }
990
+ setRenamingChatId(null);
991
+ setRenameValue('');
992
+ };
993
+ const handleCancelRename = () => {
994
+ setRenamingChatId(null);
995
+ setRenameValue('');
996
+ };
997
+ // Orphan story handlers
998
+ const toggleSelectAll = () => {
999
+ if (state.selectedStoryIds.size === state.orphanStories.length) {
1000
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
1001
+ }
1002
+ else {
1003
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set(state.orphanStories.map(s => s.id)) });
1004
+ }
1005
+ };
1006
+ const handleBulkDelete = async () => {
1007
+ if (state.selectedStoryIds.size === 0)
1008
+ return;
1009
+ const count = state.selectedStoryIds.size;
1010
+ if (!confirm(`Delete ${count} selected ${count === 1 ? 'story' : 'stories'}?`))
1011
+ return;
1012
+ dispatch({ type: 'SET_BULK_DELETING', payload: true });
1013
+ try {
1014
+ const response = await fetch(`${STORIES_API}/delete-bulk`, {
1015
+ method: 'POST',
1016
+ headers: { 'Content-Type': 'application/json' },
1017
+ body: JSON.stringify({ ids: Array.from(state.selectedStoryIds) }),
1018
+ });
1019
+ if (response.ok) {
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() });
1022
+ }
1023
+ else {
1024
+ alert('Failed to delete some stories.');
1025
+ }
1026
+ }
1027
+ catch {
1028
+ alert('Failed to delete stories.');
1029
+ }
1030
+ finally {
1031
+ dispatch({ type: 'SET_BULK_DELETING', payload: false });
1032
+ }
1033
+ };
1034
+ const handleClearAll = async () => {
1035
+ if (state.orphanStories.length === 0)
1036
+ return;
1037
+ if (!confirm(`Delete ALL ${state.orphanStories.length} generated stories?`))
1038
+ return;
1039
+ dispatch({ type: 'SET_BULK_DELETING', payload: true });
1040
+ try {
1041
+ const response = await fetch(STORIES_API, { method: 'DELETE' });
1042
+ if (response.ok) {
1043
+ dispatch({ type: 'SET_ORPHAN_STORIES', payload: [] });
1044
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
1045
+ }
1046
+ else {
1047
+ alert('Failed to clear stories.');
1048
+ }
1049
+ }
1050
+ catch {
1051
+ alert('Failed to clear stories.');
1052
+ }
1053
+ finally {
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);
1069
+ }
1070
+ };
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 })] })] }) })] })] }));
2160
1086
  }
2161
1087
  export default StoryUIPanel;
2162
1088
  export { StoryUIPanel };