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