@tpitre/story-ui 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,1538 +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 [selectedStoryIds, setSelectedStoryIds] = useState<Set<string>>(new Set());
1660
- const [isBulkDeleting, setIsBulkDeleting] = useState(false);
1661
- const chatEndRef = useRef<HTMLDivElement | null>(null);
1662
- const inputRef = useRef<HTMLInputElement | null>(null);
1663
- 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);
1664
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
+ }, []);
1665
914
 
1666
- // Maximum images allowed
1667
- const MAX_IMAGES = 4;
1668
- 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]);
1669
919
 
1670
- // Helper to convert file to base64
920
+ // File handling
1671
921
  const fileToBase64 = (file: File): Promise<string> => {
1672
922
  return new Promise((resolve, reject) => {
1673
923
  const reader = new FileReader();
1674
924
  reader.readAsDataURL(file);
1675
925
  reader.onload = () => {
1676
926
  const result = reader.result as string;
1677
- // Extract base64 data (remove data:image/...;base64, prefix)
1678
- const base64 = result.split(',')[1];
1679
- resolve(base64);
927
+ resolve(result.split(',')[1]);
1680
928
  };
1681
929
  reader.onerror = error => reject(error);
1682
930
  });
1683
931
  };
1684
932
 
1685
- // Handle file selection
1686
933
  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
1687
934
  const files = e.target.files;
1688
935
  if (!files) return;
1689
-
1690
- const newImages: AttachedImage[] = [];
1691
936
  const errors: string[] = [];
1692
-
1693
- 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++) {
1694
938
  const file = files[i];
1695
-
1696
- // Validate file type
1697
939
  if (!file.type.startsWith('image/')) {
1698
940
  errors.push(`${file.name}: Not an image file`);
1699
941
  continue;
1700
942
  }
1701
-
1702
- // Validate file size
1703
943
  if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1704
944
  errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1705
945
  continue;
1706
946
  }
1707
-
1708
947
  try {
1709
948
  const base64 = await fileToBase64(file);
1710
949
  const preview = URL.createObjectURL(file);
1711
-
1712
- newImages.push({
1713
- id: `${Date.now()}-${i}`,
1714
- file,
1715
- preview,
1716
- base64,
1717
- 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' },
1718
953
  });
1719
- } catch (err) {
954
+ } catch {
1720
955
  errors.push(`${file.name}: Failed to process`);
1721
956
  }
1722
957
  }
1723
-
1724
- if (errors.length > 0) {
1725
- setError(errors.join('\n'));
1726
- }
1727
-
1728
- setAttachedImages(prev => [...prev, ...newImages]);
1729
-
1730
- // Reset file input
1731
- if (fileInputRef.current) {
1732
- fileInputRef.current.value = '';
1733
- }
958
+ if (errors.length > 0) dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
959
+ if (fileInputRef.current) fileInputRef.current.value = '';
1734
960
  };
1735
961
 
1736
- // Remove attached image
1737
962
  const removeAttachedImage = (id: string) => {
1738
- setAttachedImages(prev => {
1739
- const removed = prev.find(img => img.id === id);
1740
- if (removed) {
1741
- URL.revokeObjectURL(removed.preview);
1742
- }
1743
- return prev.filter(img => img.id !== id);
1744
- });
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 });
1745
966
  };
1746
967
 
1747
- // Clear all attached images
1748
968
  const clearAttachedImages = () => {
1749
- attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
1750
- setAttachedImages([]);
969
+ state.attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
970
+ dispatch({ type: 'CLEAR_ATTACHED_IMAGES' });
1751
971
  };
1752
972
 
1753
- // Drag and drop state
1754
- const [isDragging, setIsDragging] = useState(false);
1755
-
1756
- // Handle drag events
973
+ // Drag and drop handlers
1757
974
  const handleDragEnter = useCallback((e: React.DragEvent) => {
1758
975
  e.preventDefault();
1759
976
  e.stopPropagation();
1760
- if (e.dataTransfer.types.includes('Files')) {
1761
- setIsDragging(true);
1762
- }
977
+ if (e.dataTransfer.types.includes('Files')) dispatch({ type: 'SET_DRAGGING', payload: true });
1763
978
  }, []);
1764
979
 
1765
980
  const handleDragLeave = useCallback((e: React.DragEvent) => {
1766
981
  e.preventDefault();
1767
982
  e.stopPropagation();
1768
- // Only set to false if we're leaving the main container
1769
- if (!e.currentTarget.contains(e.relatedTarget as Node)) {
1770
- setIsDragging(false);
1771
- }
983
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) dispatch({ type: 'SET_DRAGGING', payload: false });
1772
984
  }, []);
1773
985
 
1774
986
  const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -1779,348 +991,126 @@ function StoryUIPanel() {
1779
991
  const handleDrop = useCallback(async (e: React.DragEvent) => {
1780
992
  e.preventDefault();
1781
993
  e.stopPropagation();
1782
- setIsDragging(false);
1783
-
994
+ dispatch({ type: 'SET_DRAGGING', payload: false });
1784
995
  const files = Array.from(e.dataTransfer.files);
1785
996
  const imageFiles = files.filter(f => f.type.startsWith('image/'));
1786
-
1787
997
  if (imageFiles.length === 0) {
1788
- setError('Please drop image files only');
998
+ dispatch({ type: 'SET_ERROR', payload: 'Please drop image files only' });
1789
999
  return;
1790
1000
  }
1791
-
1792
- const newImages: AttachedImage[] = [];
1793
1001
  const errors: string[] = [];
1794
-
1795
- for (let i = 0; i < imageFiles.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
1002
+ for (let i = 0; i < imageFiles.length && (state.attachedImages.length + i) < MAX_IMAGES; i++) {
1796
1003
  const file = imageFiles[i];
1797
-
1798
1004
  if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1799
- errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1005
+ errors.push(`${file.name}: File too large`);
1800
1006
  continue;
1801
1007
  }
1802
-
1803
1008
  try {
1804
1009
  const base64 = await fileToBase64(file);
1805
- const preview = URL.createObjectURL(file);
1806
-
1807
- newImages.push({
1808
- id: `${Date.now()}-${i}`,
1809
- file,
1810
- preview,
1811
- base64,
1812
- mediaType: file.type || 'image/png',
1813
- });
1814
- } catch (err) {
1815
- errors.push(`${file.name}: Failed to process`);
1816
- }
1817
- }
1818
-
1819
- if (errors.length > 0) {
1820
- setError(errors.join('\n'));
1821
- }
1822
-
1823
- setAttachedImages(prev => [...prev, ...newImages]);
1824
- }, [attachedImages.length, fileToBase64]);
1825
-
1826
- // Handle clipboard paste for images
1827
- const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
1828
- const items = e.clipboardData?.items;
1829
- if (!items) return;
1830
-
1831
- const imageItems: DataTransferItem[] = [];
1832
- for (let i = 0; i < items.length; i++) {
1833
- if (items[i].type.startsWith('image/')) {
1834
- imageItems.push(items[i]);
1835
- }
1836
- }
1837
-
1838
- if (imageItems.length === 0) return;
1839
-
1840
- // Prevent default text paste behavior when pasting images
1841
- e.preventDefault();
1842
-
1843
- if (attachedImages.length >= MAX_IMAGES) {
1844
- setError(`Maximum ${MAX_IMAGES} images allowed`);
1845
- return;
1846
- }
1847
-
1848
- const newImages: AttachedImage[] = [];
1849
- const errors: string[] = [];
1850
-
1851
- for (let i = 0; i < imageItems.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
1852
- const item = imageItems[i];
1853
- const file = item.getAsFile();
1854
-
1855
- if (!file) {
1856
- errors.push('Failed to get image from clipboard');
1857
- continue;
1858
- }
1859
-
1860
- if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1861
- errors.push(`Pasted image too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1862
- continue;
1863
- }
1864
-
1865
- try {
1866
- const base64 = await fileToBase64(file);
1867
- const preview = URL.createObjectURL(file);
1868
-
1869
- // Create a meaningful name for pasted images
1870
- const timestamp = new Date().toISOString().slice(11, 19).replace(/:/g, '-');
1871
- const renamedFile = new File([file], `pasted-image-${timestamp}.${file.type.split('/')[1] || 'png'}`, { type: file.type });
1872
-
1873
- newImages.push({
1874
- id: `paste-${Date.now()}-${i}`,
1875
- file: renamedFile,
1876
- preview,
1877
- base64,
1878
- mediaType: file.type || 'image/png',
1010
+ const preview = URL.createObjectURL(file);
1011
+ dispatch({
1012
+ type: 'ADD_ATTACHED_IMAGE',
1013
+ payload: { id: `${Date.now()}-${i}`, file, preview, base64, mediaType: file.type || 'image/png' },
1879
1014
  });
1880
- } catch (err) {
1881
- errors.push('Failed to process pasted image');
1015
+ } catch {
1016
+ errors.push(`${file.name}: Failed to process`);
1882
1017
  }
1883
1018
  }
1019
+ if (errors.length > 0) dispatch({ type: 'SET_ERROR', payload: errors.join('\n') });
1020
+ }, [state.attachedImages.length]);
1884
1021
 
1885
- if (errors.length > 0) {
1886
- 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]);
1887
1029
  }
1888
-
1889
- if (newImages.length > 0) {
1890
- setAttachedImages(prev => [...prev, ...newImages]);
1891
- // Clear any existing error on successful paste
1892
- if (errors.length === 0) {
1893
- setError(null);
1894
- }
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;
1895
1035
  }
1896
- }, [attachedImages.length, fileToBase64]);
1897
-
1898
- // Load and sync chats on mount
1899
- useEffect(() => {
1900
- const initializeChats = async () => {
1901
- // Test connection first
1902
- const connectionTest = await testMCPConnection();
1903
- setConnectionStatus(connectionTest);
1904
-
1905
- if (connectionTest.connected) {
1906
- // Fetch available providers
1907
- try {
1908
- const providersRes = await fetch(PROVIDERS_API);
1909
- if (providersRes.ok) {
1910
- const providersData: ProvidersResponse = await providersRes.json();
1911
- setAvailableProviders(providersData.providers.filter(p => p.configured));
1912
- // Set initial selection from server defaults
1913
- if (providersData.current) {
1914
- setSelectedProvider(providersData.current.provider.toLowerCase());
1915
- setSelectedModel(providersData.current.model);
1916
- }
1917
- }
1918
- } catch (e) {
1919
- console.error('Failed to fetch providers:', e);
1920
- }
1921
-
1922
- // Fetch design system considerations for environment parity
1923
- // This ensures production gets the same considerations as local development
1924
- try {
1925
- const considerationsRes = await fetch(CONSIDERATIONS_API);
1926
- if (considerationsRes.ok) {
1927
- const considerationsData = await considerationsRes.json();
1928
- if (considerationsData.hasConsiderations && considerationsData.considerations) {
1929
- setConsiderations(considerationsData.considerations);
1930
- console.log(`Loaded considerations from ${considerationsData.source}`);
1931
- }
1932
- }
1933
- } catch (e) {
1934
- console.error('Failed to fetch considerations:', e);
1935
- }
1936
-
1937
- const syncedChats = await syncWithActualStories();
1938
- const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
1939
- setRecentChats(sortedChats);
1940
-
1941
- if (sortedChats.length > 0) {
1942
- setConversation(sortedChats[0].conversation);
1943
- setActiveChatId(sortedChats[0].id);
1944
- setActiveTitle(sortedChats[0].title);
1945
- }
1946
-
1947
- // Fetch orphan stories (stories on disk without chat history)
1948
- const orphans = await fetchOrphanStories();
1949
- setOrphanStories(orphans);
1950
- } else {
1951
- // Load from local storage if server is not available
1952
- const localChats = loadChats();
1953
- 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' });
1954
1055
  }
1955
- };
1956
-
1957
- initializeChats();
1958
- }, []);
1959
-
1960
- // Scroll to bottom on new message
1961
- useEffect(() => {
1962
- if (chatEndRef.current) {
1963
- chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
1964
- }
1965
- }, [conversation, loading]);
1966
-
1967
- // Helper function for non-streaming fallback
1968
- const handleSendNonStreaming = async (userInput: string, newConversation: Message[]) => {
1969
- const res = await fetch(MCP_API, {
1970
- method: 'POST',
1971
- headers: { 'Content-Type': 'application/json' },
1972
- body: JSON.stringify({
1973
- prompt: userInput,
1974
- conversation: newConversation,
1975
- fileName: activeChatId || undefined,
1976
- provider: selectedProvider || undefined,
1977
- model: selectedModel || undefined,
1978
- considerations: considerations || undefined,
1979
- }),
1980
- });
1981
-
1982
- const contentType = res.headers.get('content-type');
1983
- if (!contentType || !contentType.includes('application/json')) {
1984
- const text = await res.text();
1985
- throw new Error(`Server returned non-JSON response. Response: ${text.substring(0, 200)}...`);
1986
1056
  }
1057
+ }, [state.attachedImages.length]);
1987
1058
 
1988
- const data = await res.json();
1989
- if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
1990
-
1991
- return data;
1992
- };
1993
-
1994
- // Helper function to build a conversational response from completion data
1995
- // Uses special markers [SUCCESS], [ERROR], [TIP], [WRENCH] that renderMarkdown converts to icons
1996
- // Track whether we've shown the refresh hint in this session
1997
- const hasShownRefreshHint = useRef(false);
1998
-
1059
+ // Build response message
1999
1060
  const buildConversationalResponse = (completion: CompletionFeedback, isUpdate: boolean): string => {
2000
1061
  const parts: string[] = [];
2001
1062
  const statusMarker = completion.success ? '[SUCCESS]' : '[ERROR]';
2002
-
2003
- // Lead with the result - more conversational
2004
- if (isUpdate) {
2005
- parts.push(`${statusMarker} **Updated: "${completion.title}"**`);
2006
- } else {
2007
- parts.push(`${statusMarker} **Created: "${completion.title}"**`);
2008
- }
2009
-
2010
- // Build component insights with reasons when available
1063
+ parts.push(isUpdate ? `${statusMarker} **Updated: "${completion.title}"**` : `${statusMarker} **Created: "${completion.title}"**`);
2011
1064
  const componentCount = completion.componentsUsed?.length || 0;
2012
1065
  if (componentCount > 0) {
2013
- const componentList = completion.componentsUsed!.slice(0, 5);
2014
-
2015
- // Check if we have meaningful reasons (not just "Used in composition")
2016
- const componentsWithReasons = componentList.filter(c =>
2017
- c.reason && c.reason !== 'Used in composition'
2018
- );
2019
-
2020
- if (componentsWithReasons.length > 0) {
2021
- // Show components with their reasons
2022
- const insights = componentsWithReasons
2023
- .slice(0, 3)
2024
- .map(c => `\`${c.name}\` - ${c.reason?.toLowerCase()}`)
2025
- .join(', ');
2026
- parts.push(`\nUsed ${insights}${componentCount > 3 ? ` and ${componentCount - 3} more` : ''}.`);
2027
- } else {
2028
- // Fallback to simple list
2029
- const names = componentList.map(c => `\`${c.name}\``).join(', ');
2030
- parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
2031
- }
2032
- }
2033
-
2034
- // Add layout decisions with educational context
2035
- if (completion.layoutChoices && completion.layoutChoices.length > 0) {
2036
- const primaryLayout = completion.layoutChoices[0];
2037
- 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 ? '...' : ''}.`);
2038
1068
  }
2039
-
2040
- // Add style choices only if they add value
2041
- if (completion.styleChoices && completion.styleChoices.length > 0) {
2042
- const notableStyles = completion.styleChoices.filter(s =>
2043
- s.reason && s.reason !== 'Semantic color from design system'
2044
- );
2045
- if (notableStyles.length > 0) {
2046
- const styleInfo = notableStyles[0];
2047
- parts.push(` Applied \`${styleInfo.value}\` for ${styleInfo.reason?.toLowerCase() || 'visual consistency'}.`);
2048
- }
1069
+ if (completion.layoutChoices?.length > 0) {
1070
+ const layout = completion.layoutChoices[0];
1071
+ parts.push(`\n\n**Layout:** ${layout.pattern} - ${layout.reason}.`);
2049
1072
  }
2050
-
2051
- // Add validation fixes notice
2052
1073
  if (completion.validation?.autoFixApplied) {
2053
1074
  parts.push(`\n\n[WRENCH] **Auto-fixed:** Minor syntax issues were automatically corrected.`);
2054
1075
  }
2055
-
2056
- // Add suggestions only if meaningful
2057
- if (completion.suggestions && completion.suggestions.length > 0) {
2058
- const suggestion = completion.suggestions[0];
2059
- // Only show if it's not the generic "review the generated code" message
2060
- if (!suggestion.toLowerCase().includes('review the generated code')) {
2061
- parts.push(`\n\n[TIP] **Tip:** ${suggestion}`);
2062
- }
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]}`);
2063
1078
  }
2064
-
2065
- // Show refresh hint only once per session for new stories (local mode only)
2066
- // In Edge mode, stories are stored in Durable Objects, not on filesystem
2067
1079
  if (!isUpdate && !hasShownRefreshHint.current) {
2068
- if (isEdgeMode()) {
2069
- parts.push(`\n\n_Story saved to cloud. View code in chat history recent chats navigation._`);
2070
- } else {
2071
- parts.push(`\n\n_Might need toefresh Storybook (Cmd/Ctrl + R) to see new stories in the sidebar._`);
2072
- }
1080
+ parts.push(isEdgeMode() ? `\n\n_Story saved to cloud._` : `\n\n_Might need to refresh Storybook (Cmd/Ctrl + R) to see new stories._`);
2073
1081
  hasShownRefreshHint.current = true;
2074
1082
  }
2075
-
2076
- // Add metrics in a subtle way (if available)
2077
1083
  if (completion.metrics?.totalTimeMs) {
2078
- const seconds = (completion.metrics.totalTimeMs / 1000).toFixed(1);
2079
- parts.push(`\n\n_${seconds}s_`);
1084
+ parts.push(`\n\n_${(completion.metrics.totalTimeMs / 1000).toFixed(1)}s_`);
2080
1085
  }
2081
-
2082
1086
  return parts.join('');
2083
1087
  };
2084
1088
 
2085
- // Helper function to finalize conversation after streaming completes
2086
- const finalizeStreamingConversation = useCallback((
2087
- newConversation: Message[],
2088
- completion: CompletionFeedback,
2089
- userInput: string
2090
- ) => {
2091
- // Build conversational response using rich completion data
1089
+ // Finalize streaming
1090
+ const finalizeStreamingConversation = useCallback((newConversation: Message[], completion: CompletionFeedback, userInput: string) => {
2092
1091
  const isUpdate = completion.summary.action === 'updated';
2093
1092
  const responseMessage = buildConversationalResponse(completion, isUpdate);
2094
-
2095
1093
  const aiMsg: Message = { role: 'ai', content: responseMessage };
2096
1094
  const updatedConversation = [...newConversation, aiMsg];
2097
- setConversation(updatedConversation);
2098
-
2099
- // Update chat session
2100
- const isExistingSession = activeChatId && conversation.length > 0;
2101
-
2102
- if (isExistingSession && activeChatId) {
1095
+ dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
1096
+ const isExistingSession = state.activeChatId && state.conversation.length > 0;
1097
+ if (isExistingSession && state.activeChatId) {
2103
1098
  const updatedSession: ChatSession = {
2104
- id: activeChatId,
2105
- title: activeTitle,
2106
- fileName: completion.fileName || activeChatId,
1099
+ id: state.activeChatId,
1100
+ title: state.activeTitle,
1101
+ fileName: completion.fileName || state.activeChatId,
2107
1102
  conversation: updatedConversation,
2108
1103
  lastUpdated: Date.now(),
2109
1104
  };
2110
-
2111
1105
  const chats = loadChats();
2112
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
2113
- if (chatIndex !== -1) {
2114
- chats[chatIndex] = updatedSession;
2115
- }
1106
+ const chatIndex = chats.findIndex(c => c.id === state.activeChatId);
1107
+ if (chatIndex !== -1) chats[chatIndex] = updatedSession;
2116
1108
  saveChats(chats);
2117
- setRecentChats(chats);
1109
+ dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
2118
1110
  } else {
2119
1111
  const chatId = completion.storyId || completion.fileName || Date.now().toString();
2120
1112
  const chatTitle = completion.title || userInput;
2121
- setActiveChatId(chatId);
2122
- setActiveTitle(chatTitle);
2123
-
1113
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chatId, title: chatTitle } });
2124
1114
  const newSession: ChatSession = {
2125
1115
  id: chatId,
2126
1116
  title: chatTitle,
@@ -2128,332 +1118,176 @@ function StoryUIPanel() {
2128
1118
  conversation: updatedConversation,
2129
1119
  lastUpdated: Date.now(),
2130
1120
  };
2131
-
2132
1121
  const chats = loadChats().filter(c => c.id !== chatId);
2133
1122
  chats.unshift(newSession);
2134
- if (chats.length > MAX_RECENT_CHATS) {
2135
- chats.splice(MAX_RECENT_CHATS);
2136
- }
1123
+ if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
2137
1124
  saveChats(chats);
2138
- setRecentChats(chats);
2139
-
2140
- // Auto-navigate to the newly created story after HMR processes the file
2141
- // This prevents the "Couldn't find story after HMR" error by refreshing
2142
- // after the file system has been updated and HMR has processed the change
2143
- navigateToNewStory(chatTitle, completion.code);
1125
+ dispatch({ type: 'SET_RECENT_CHATS', payload: chats });
2144
1126
  }
2145
- }, [activeChatId, activeTitle, conversation.length]);
1127
+ }, [state.activeChatId, state.activeTitle, state.conversation.length]);
2146
1128
 
1129
+ // Handle send
2147
1130
  const handleSend = async (e?: React.FormEvent) => {
2148
1131
  if (e) e.preventDefault();
2149
- // Allow sending with either text or images
2150
- if (!input.trim() && attachedImages.length === 0) return;
2151
-
2152
- // Use input text or default vision prompt if only images
2153
- const userInput = input.trim() || (attachedImages.length > 0 ? 'Create a component that matches this design' : '');
2154
- setError(null);
2155
- setLoading(true);
2156
- setStreamingState(null);
2157
-
2158
- // 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 });
2159
1137
  const connectionTest = await testMCPConnection();
2160
- setConnectionStatus(connectionTest);
2161
-
1138
+ dispatch({ type: 'SET_CONNECTION_STATUS', payload: connectionTest });
2162
1139
  if (!connectionTest.connected) {
2163
- setError(`Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}`);
2164
- 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 });
2165
1142
  return;
2166
1143
  }
2167
-
2168
- // Capture images before clearing
2169
- const imagesToSend = [...attachedImages];
1144
+ const imagesToSend = [...state.attachedImages];
2170
1145
  const hasImages = imagesToSend.length > 0;
2171
-
2172
- // Create user message with images
2173
1146
  const userMessage: Message = {
2174
1147
  role: 'user',
2175
1148
  content: userInput,
2176
- attachedImages: hasImages ? imagesToSend : undefined
1149
+ attachedImages: hasImages ? imagesToSend : undefined,
2177
1150
  };
2178
- const newConversation: Message[] = [...conversation, userMessage];
2179
- setConversation(newConversation);
2180
- setInput('');
1151
+ const newConversation: Message[] = [...state.conversation, userMessage];
1152
+ dispatch({ type: 'SET_CONVERSATION', payload: newConversation });
1153
+ dispatch({ type: 'SET_INPUT', payload: '' });
2181
1154
  clearAttachedImages();
2182
1155
 
2183
- // Use streaming if enabled
2184
1156
  if (USE_STREAMING) {
2185
1157
  try {
2186
- // Cancel any existing request
2187
- if (abortControllerRef.current) {
2188
- abortControllerRef.current.abort();
2189
- }
1158
+ if (abortControllerRef.current) abortControllerRef.current.abort();
2190
1159
  abortControllerRef.current = new AbortController();
2191
-
2192
- // Initialize streaming state
2193
- setStreamingState({});
2194
-
2195
- // Prepare images for API request
1160
+ dispatch({ type: 'SET_STREAMING_STATE', payload: {} });
2196
1161
  const imagePayload = hasImages
2197
- ? imagesToSend.map(img => ({
2198
- type: 'base64' as const,
2199
- data: img.base64,
2200
- mediaType: img.file.type,
2201
- }))
1162
+ ? imagesToSend.map(img => ({ type: 'base64' as const, data: img.base64, mediaType: img.file.type }))
2202
1163
  : undefined;
2203
-
2204
1164
  const response = await fetch(MCP_STREAM_API, {
2205
1165
  method: 'POST',
2206
1166
  headers: { 'Content-Type': 'application/json' },
2207
1167
  body: JSON.stringify({
2208
1168
  prompt: userInput,
2209
1169
  conversation: newConversation,
2210
- fileName: activeChatId || undefined,
2211
- isUpdate: activeChatId && conversation.length > 0,
2212
- originalTitle: activeTitle || undefined,
2213
- 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,
2214
1174
  images: imagePayload,
2215
1175
  visionMode: hasImages ? 'screenshot_to_story' : undefined,
2216
- provider: selectedProvider || undefined,
2217
- model: selectedModel || undefined,
2218
- considerations: considerations || undefined,
1176
+ provider: state.selectedProvider || undefined,
1177
+ model: state.selectedModel || undefined,
1178
+ considerations: state.considerations || undefined,
2219
1179
  }),
2220
1180
  signal: abortControllerRef.current.signal,
2221
1181
  });
2222
-
2223
- if (!response.ok) {
2224
- throw new Error(`Streaming request failed: ${response.status}`);
2225
- }
2226
-
1182
+ if (!response.ok) throw new Error(`Streaming request failed: ${response.status}`);
2227
1183
  const reader = response.body?.getReader();
2228
- if (!reader) {
2229
- throw new Error('No response body');
2230
- }
2231
-
1184
+ if (!reader) throw new Error('No response body');
2232
1185
  const decoder = new TextDecoder();
2233
1186
  let buffer = '';
2234
1187
  let completionData: CompletionFeedback | null = null;
2235
1188
  let errorData: ErrorFeedback | null = null;
2236
-
2237
1189
  while (true) {
2238
1190
  const { done, value } = await reader.read();
2239
1191
  if (done) break;
2240
-
2241
1192
  buffer += decoder.decode(value, { stream: true });
2242
-
2243
- // Parse SSE events from buffer
2244
1193
  const lines = buffer.split('\n');
2245
- buffer = lines.pop() || ''; // Keep incomplete line in buffer
2246
-
1194
+ buffer = lines.pop() || '';
2247
1195
  for (const line of lines) {
2248
1196
  if (line.startsWith('data: ')) {
2249
1197
  try {
2250
1198
  const event: StreamEvent = JSON.parse(line.slice(6));
2251
-
2252
- // Update streaming state based on event type
2253
1199
  switch (event.type) {
2254
1200
  case 'intent':
2255
- setStreamingState(prev => ({ ...prev, intent: event.data as IntentPreview }));
1201
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { intent: event.data as IntentPreview } });
2256
1202
  break;
2257
1203
  case 'progress':
2258
- setStreamingState(prev => ({ ...prev, progress: event.data as ProgressUpdate }));
1204
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { progress: event.data as ProgressUpdate } });
2259
1205
  break;
2260
1206
  case 'validation':
2261
- setStreamingState(prev => ({ ...prev, validation: event.data as ValidationFeedback }));
1207
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { validation: event.data as ValidationFeedback } });
2262
1208
  break;
2263
1209
  case 'retry':
2264
- setStreamingState(prev => ({ ...prev, retry: event.data as RetryInfo }));
1210
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { retry: event.data as RetryInfo } });
2265
1211
  break;
2266
1212
  case 'completion':
2267
1213
  completionData = event.data as CompletionFeedback;
2268
- setStreamingState(prev => ({ ...prev, completion: event.data as CompletionFeedback }));
1214
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { completion: completionData } });
2269
1215
  break;
2270
1216
  case 'error':
2271
1217
  errorData = event.data as ErrorFeedback;
2272
- setStreamingState(prev => ({ ...prev, error: event.data as ErrorFeedback }));
1218
+ dispatch({ type: 'UPDATE_STREAMING_STATE', payload: { error: errorData } });
2273
1219
  break;
2274
1220
  }
2275
- } catch (parseError) {
2276
- console.warn('Failed to parse SSE event:', line, parseError);
1221
+ } catch {
1222
+ console.warn('Failed to parse SSE event:', line);
2277
1223
  }
2278
1224
  }
2279
1225
  }
2280
1226
  }
2281
-
2282
- // Handle completion or error
2283
1227
  if (completionData) {
2284
1228
  finalizeStreamingConversation(newConversation, completionData, userInput);
2285
1229
  } else if (errorData) {
2286
- setError(errorData.message);
1230
+ dispatch({ type: 'SET_ERROR', payload: errorData.message });
2287
1231
  const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorData.message}\n\n${errorData.suggestion || ''}` }];
2288
- setConversation(errorConversation);
1232
+ dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
2289
1233
  }
2290
-
2291
1234
  } catch (err: unknown) {
2292
- if ((err as Error).name === 'AbortError') {
2293
- console.log('Request aborted');
2294
- return;
2295
- }
2296
-
2297
- // Fall back to non-streaming on error
1235
+ if ((err as Error).name === 'AbortError') return;
2298
1236
  console.warn('Streaming failed, falling back to non-streaming:', err);
2299
- setStreamingState(null);
2300
-
1237
+ dispatch({ type: 'SET_STREAMING_STATE', payload: null });
2301
1238
  try {
2302
- const data = await handleSendNonStreaming(userInput, newConversation);
2303
-
2304
- // Process non-streaming response (same as before)
2305
- let responseMessage: string;
2306
- const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
2307
-
2308
- // Build conversational response for fallback
2309
- if (data.isUpdate) {
2310
- 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._`;
2311
- } else {
2312
- 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).`;
2313
- }
2314
-
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.`;
2315
1254
  const aiMsg: Message = { role: 'ai', content: responseMessage };
2316
1255
  const updatedConversation = [...newConversation, aiMsg];
2317
- setConversation(updatedConversation);
2318
-
2319
- // Update chat session
2320
- const isUpdate = activeChatId && conversation.length > 0;
2321
- if (isUpdate && activeChatId) {
2322
- const updatedSession: ChatSession = {
2323
- id: activeChatId,
2324
- title: activeTitle,
2325
- fileName: data.fileName || activeChatId,
2326
- conversation: updatedConversation,
2327
- lastUpdated: Date.now(),
2328
- };
2329
- const chats = loadChats();
2330
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
2331
- if (chatIndex !== -1) chats[chatIndex] = updatedSession;
2332
- saveChats(chats);
2333
- setRecentChats(chats);
2334
- } else {
2335
- const chatId = data.storyId || data.fileName || Date.now().toString();
2336
- const chatTitle = data.title || userInput;
2337
- setActiveChatId(chatId);
2338
- setActiveTitle(chatTitle);
2339
- const newSession: ChatSession = {
2340
- id: chatId,
2341
- title: chatTitle,
2342
- fileName: data.fileName || '',
2343
- conversation: updatedConversation,
2344
- lastUpdated: Date.now(),
2345
- };
2346
- const chats = loadChats().filter(c => c.id !== chatId);
2347
- chats.unshift(newSession);
2348
- if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
2349
- saveChats(chats);
2350
- setRecentChats(chats);
2351
-
2352
- // Auto-navigate to the newly created story
2353
- navigateToNewStory(chatTitle, data.code);
2354
- }
1256
+ dispatch({ type: 'SET_CONVERSATION', payload: updatedConversation });
2355
1257
  } catch (fallbackErr: unknown) {
2356
1258
  const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : 'Unknown error';
2357
- setError(errorMessage);
1259
+ dispatch({ type: 'SET_ERROR', payload: errorMessage });
2358
1260
  const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
2359
- setConversation(errorConversation);
1261
+ dispatch({ type: 'SET_CONVERSATION', payload: errorConversation });
2360
1262
  }
2361
1263
  } finally {
2362
- setLoading(false);
2363
- setStreamingState(null);
1264
+ dispatch({ type: 'SET_LOADING', payload: false });
1265
+ dispatch({ type: 'SET_STREAMING_STATE', payload: null });
2364
1266
  abortControllerRef.current = null;
2365
1267
  }
2366
- } else {
2367
- // Non-streaming mode (original implementation)
2368
- try {
2369
- const data = await handleSendNonStreaming(userInput, newConversation);
2370
-
2371
- let responseMessage: string;
2372
- const statusMarker = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '[WRENCH]' : '[TIP]') : '[SUCCESS]';
2373
-
2374
- // Build conversational response for non-streaming mode
2375
- if (data.isUpdate) {
2376
- 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._`;
2377
- } else {
2378
- 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).`;
2379
- }
2380
-
2381
- const aiMsg: Message = { role: 'ai', content: responseMessage };
2382
- const updatedConversation = [...newConversation, aiMsg];
2383
- setConversation(updatedConversation);
2384
-
2385
- const isUpdate = activeChatId && conversation.length > 0;
2386
- if (isUpdate && activeChatId) {
2387
- const updatedSession: ChatSession = {
2388
- id: activeChatId,
2389
- title: activeTitle,
2390
- fileName: data.fileName || activeChatId,
2391
- conversation: updatedConversation,
2392
- lastUpdated: Date.now(),
2393
- };
2394
- const chats = loadChats();
2395
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
2396
- if (chatIndex !== -1) chats[chatIndex] = updatedSession;
2397
- saveChats(chats);
2398
- setRecentChats(chats);
2399
- } else {
2400
- const chatId = data.storyId || data.fileName || Date.now().toString();
2401
- const chatTitle = data.title || userInput;
2402
- setActiveChatId(chatId);
2403
- setActiveTitle(chatTitle);
2404
- const newSession: ChatSession = {
2405
- id: chatId,
2406
- title: chatTitle,
2407
- fileName: data.fileName || '',
2408
- conversation: updatedConversation,
2409
- lastUpdated: Date.now(),
2410
- };
2411
- const chats = loadChats().filter(c => c.id !== chatId);
2412
- chats.unshift(newSession);
2413
- if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
2414
- saveChats(chats);
2415
- setRecentChats(chats);
2416
- }
2417
- } catch (err: unknown) {
2418
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2419
- setError(errorMessage);
2420
- const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
2421
- setConversation(errorConversation);
2422
- } finally {
2423
- setLoading(false);
2424
- }
2425
1268
  }
2426
1269
  };
2427
1270
 
1271
+ // Chat management
2428
1272
  const handleSelectChat = (chat: ChatSession) => {
2429
- setConversation(chat.conversation);
2430
- setActiveChatId(chat.id);
2431
- setActiveTitle(chat.title);
2432
- };
2433
-
2434
- const handleNewChat = () => {
2435
- setConversation([]);
2436
- setActiveChatId(null);
2437
- setActiveTitle('');
1273
+ dispatch({ type: 'SET_CONVERSATION', payload: chat.conversation });
1274
+ dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
2438
1275
  };
2439
1276
 
2440
- const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => {
2441
- e.stopPropagation(); // Prevent selecting the chat
1277
+ const handleNewChat = () => dispatch({ type: 'NEW_CHAT' });
2442
1278
 
1279
+ const handleDeleteChat = async (chatId: string, e?: React.MouseEvent) => {
1280
+ if (e) e.stopPropagation();
1281
+ setContextMenuId(null);
2443
1282
  if (confirm('Delete this story and chat? This action cannot be undone.')) {
2444
1283
  const success = await deleteStoryAndChat(chatId);
2445
-
2446
1284
  if (success) {
2447
- // Update local state
2448
- const updatedChats = recentChats.filter(chat => chat.id !== chatId);
2449
- setRecentChats(updatedChats);
2450
-
2451
- // If we deleted the active chat, switch to another or clear
2452
- 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) {
2453
1288
  if (updatedChats.length > 0) {
2454
- setConversation(updatedChats[0].conversation);
2455
- setActiveChatId(updatedChats[0].id);
2456
- 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 } });
2457
1291
  } else {
2458
1292
  handleNewChat();
2459
1293
  }
@@ -2464,724 +1298,360 @@ function StoryUIPanel() {
2464
1298
  }
2465
1299
  };
2466
1300
 
2467
- // Toggle story selection for bulk operations
2468
- const toggleStorySelection = (storyId: string) => {
2469
- setSelectedStoryIds(prev => {
2470
- const newSet = new Set(prev);
2471
- if (newSet.has(storyId)) {
2472
- newSet.delete(storyId);
2473
- } else {
2474
- newSet.add(storyId);
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() } });
2475
1321
  }
2476
- return newSet;
2477
- });
1322
+ }
1323
+ setRenamingChatId(null);
1324
+ setRenameValue('');
1325
+ };
1326
+
1327
+ const handleCancelRename = () => {
1328
+ setRenamingChatId(null);
1329
+ setRenameValue('');
2478
1330
  };
2479
1331
 
2480
- // Select/deselect all stories
1332
+ // Orphan story handlers
2481
1333
  const toggleSelectAll = () => {
2482
- if (selectedStoryIds.size === orphanStories.length) {
2483
- setSelectedStoryIds(new Set());
1334
+ if (state.selectedStoryIds.size === state.orphanStories.length) {
1335
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set() });
2484
1336
  } else {
2485
- setSelectedStoryIds(new Set(orphanStories.map(s => s.id)));
1337
+ dispatch({ type: 'SET_SELECTED_STORY_IDS', payload: new Set(state.orphanStories.map(s => s.id)) });
2486
1338
  }
2487
1339
  };
2488
1340
 
2489
- // Bulk delete selected stories
2490
1341
  const handleBulkDelete = async () => {
2491
- if (selectedStoryIds.size === 0) return;
2492
-
2493
- const count = selectedStoryIds.size;
2494
- if (!confirm(`Delete ${count} selected ${count === 1 ? 'story' : 'stories'}? This action cannot be undone.`)) {
2495
- return;
2496
- }
2497
-
2498
- setIsBulkDeleting(true);
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 });
2499
1346
  try {
2500
1347
  const response = await fetch(`${STORIES_API}/delete-bulk`, {
2501
1348
  method: 'POST',
2502
1349
  headers: { 'Content-Type': 'application/json' },
2503
- body: JSON.stringify({ ids: Array.from(selectedStoryIds) }),
1350
+ body: JSON.stringify({ ids: Array.from(state.selectedStoryIds) }),
2504
1351
  });
2505
-
2506
1352
  if (response.ok) {
2507
- const result = await response.json();
2508
- // Remove deleted stories from state
2509
- setOrphanStories(prev => prev.filter(s => !selectedStoryIds.has(s.id)));
2510
- setSelectedStoryIds(new Set());
2511
- console.log(`Deleted ${result.deleted?.length || count} stories`);
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() });
2512
1355
  } else {
2513
- alert('Failed to delete some stories. Please try again.');
1356
+ alert('Failed to delete some stories.');
2514
1357
  }
2515
- } catch (err) {
2516
- console.error('Error bulk deleting stories:', err);
2517
- alert('Failed to delete stories. Please try again.');
1358
+ } catch {
1359
+ alert('Failed to delete stories.');
2518
1360
  } finally {
2519
- setIsBulkDeleting(false);
1361
+ dispatch({ type: 'SET_BULK_DELETING', payload: false });
2520
1362
  }
2521
1363
  };
2522
1364
 
2523
- // Clear all generated stories
2524
1365
  const handleClearAll = async () => {
2525
- if (orphanStories.length === 0) return;
2526
-
2527
- if (!confirm(`Delete ALL ${orphanStories.length} generated stories? This action cannot be undone.`)) {
2528
- return;
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 });
2529
1381
  }
1382
+ };
2530
1383
 
2531
- setIsBulkDeleting(true);
1384
+ const handleDeleteOrphan = async (storyId: string) => {
2532
1385
  try {
2533
- const response = await fetch(STORIES_API, {
2534
- method: 'DELETE',
2535
- });
2536
-
1386
+ const response = await fetch(`${STORIES_API}/${storyId}`, { method: 'DELETE' });
2537
1387
  if (response.ok) {
2538
- const result = await response.json();
2539
- setOrphanStories([]);
2540
- setSelectedStoryIds(new Set());
2541
- console.log(`Cleared ${result.deleted || 'all'} stories`);
2542
- } else {
2543
- alert('Failed to clear stories. Please try again.');
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 });
2544
1392
  }
2545
1393
  } catch (err) {
2546
- console.error('Error clearing stories:', err);
2547
- alert('Failed to clear stories. Please try again.');
2548
- } finally {
2549
- setIsBulkDeleting(false);
1394
+ console.error('Error deleting orphan story:', err);
2550
1395
  }
2551
1396
  };
2552
1397
 
1398
+ // ============================================
1399
+ // Render
1400
+ // ============================================
1401
+
2553
1402
  return (
2554
- <div className="story-ui-panel" style={STYLES.container}>
1403
+ <div className={`sui-root ${state.isDarkMode ? 'dark' : ''}`}>
2555
1404
  {/* Sidebar */}
2556
- <div style={{
2557
- ...STYLES.sidebar,
2558
- ...(sidebarOpen ? {} : STYLES.sidebarCollapsed),
2559
- }}>
2560
- {sidebarOpen && (
2561
- <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 */}
2562
1409
  <button
2563
- onClick={() => setSidebarOpen(false)}
2564
- style={STYLES.sidebarToggle}
2565
- title="Collapse sidebar"
2566
- onMouseEnter={(e) => {
2567
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.25)';
2568
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
2569
- }}
2570
- onMouseLeave={(e) => {
2571
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
2572
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
2573
- }}
1410
+ className="sui-button sui-button-ghost"
1411
+ onClick={() => dispatch({ type: 'TOGGLE_SIDEBAR' })}
1412
+ style={{ width: '100%', marginBottom: '12px', justifyContent: 'flex-start' }}
2574
1413
  >
2575
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
2576
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
2577
- </svg>
2578
- <span>Chats</span>
1414
+ {Icons.panelLeft}
1415
+ <span style={{ marginLeft: '8px' }}>Hide sidebar</span>
2579
1416
  </button>
2580
- <button
2581
- onClick={handleNewChat}
2582
- style={STYLES.newChatButton}
2583
- onMouseEnter={(e) => {
2584
- e.currentTarget.style.background = '#2563eb';
2585
- e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)';
2586
- }}
2587
- onMouseLeave={(e) => {
2588
- e.currentTarget.style.background = '#3b82f6';
2589
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.25)';
2590
- }}
2591
- >
2592
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
2593
- <line x1="12" y1="5" x2="12" y2="19"/>
2594
- <line x1="5" y1="12" x2="19" y2="12"/>
2595
- </svg>
1417
+
1418
+ {/* New Chat */}
1419
+ <button className="sui-button sui-button-default" onClick={handleNewChat} style={{ width: '100%', marginBottom: '16px' }}>
1420
+ {Icons.plus}
2596
1421
  <span>New Chat</span>
2597
1422
  </button>
2598
- {recentChats.length > 0 && (
2599
- <div style={{
2600
- color: '#64748b',
2601
- fontSize: '12px',
2602
- marginBottom: '8px',
2603
- fontWeight: '500',
2604
- textTransform: 'uppercase',
2605
- letterSpacing: '0.05em',
2606
- }}>
2607
- Recent Chats
2608
- </div>
2609
- )}
2610
- {recentChats.map(chat => (
2611
- <div
2612
- key={chat.id}
2613
- onClick={() => handleSelectChat(chat)}
2614
- style={{
2615
- ...STYLES.chatItem,
2616
- ...(activeChatId === chat.id ? STYLES.chatItemActive : {}),
2617
- }}
2618
- onMouseEnter={(e) => {
2619
- if (activeChatId !== chat.id) {
2620
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.12)';
2621
- }
2622
- const deleteBtn = e.currentTarget.querySelector('.delete-btn') as HTMLElement;
2623
- if (deleteBtn) deleteBtn.style.opacity = '1';
2624
- }}
2625
- onMouseLeave={(e) => {
2626
- if (activeChatId !== chat.id) {
2627
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)';
2628
- }
2629
- const deleteBtn = e.currentTarget.querySelector('.delete-btn') as HTMLElement;
2630
- if (deleteBtn) deleteBtn.style.opacity = '0';
2631
- }}
2632
- >
2633
- <div style={STYLES.chatItemTitle}>{chat.title}</div>
2634
- <div style={STYLES.chatItemTime}>{formatTime(chat.lastUpdated)}</div>
2635
- <button
2636
- className="delete-btn"
2637
- onClick={(e) => handleDeleteChat(chat.id, e)}
2638
- style={STYLES.deleteButton}
2639
- title="Delete chat"
2640
- >
2641
- <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>
2642
- </button>
2643
- </div>
2644
- ))}
2645
-
2646
- {/* Generated Files Section - orphan stories without chat history */}
2647
- {orphanStories.length > 0 && (
2648
- <>
2649
- {/* Header with Select All and Count */}
2650
- <div style={{
2651
- display: 'flex',
2652
- alignItems: 'center',
2653
- justifyContent: 'space-between',
2654
- marginTop: '16px',
2655
- marginBottom: '8px',
2656
- }}>
2657
- <div style={{
2658
- display: 'flex',
2659
- alignItems: 'center',
2660
- gap: '8px',
2661
- }}>
2662
- <input
2663
- type="checkbox"
2664
- checked={selectedStoryIds.size === orphanStories.length && orphanStories.length > 0}
2665
- onChange={toggleSelectAll}
2666
- style={{
2667
- width: '14px',
2668
- height: '14px',
2669
- cursor: 'pointer',
2670
- accentColor: '#3b82f6',
2671
- }}
2672
- title={selectedStoryIds.size === orphanStories.length ? 'Deselect all' : 'Select all'}
2673
- />
2674
- <span style={{
2675
- color: '#64748b',
2676
- fontSize: '12px',
2677
- fontWeight: '500',
2678
- textTransform: 'uppercase',
2679
- letterSpacing: '0.05em',
2680
- }}>
2681
- Generated Files ({orphanStories.length})
2682
- </span>
2683
- </div>
2684
- </div>
2685
1423
 
2686
- {/* Bulk Action Buttons */}
2687
- {selectedStoryIds.size > 0 && (
2688
- <div style={{
2689
- display: 'flex',
2690
- gap: '8px',
2691
- marginBottom: '12px',
2692
- }}>
2693
- <button
2694
- onClick={handleBulkDelete}
2695
- disabled={isBulkDeleting}
2696
- style={{
2697
- flex: 1,
2698
- padding: '6px 10px',
2699
- fontSize: '11px',
2700
- fontWeight: '500',
2701
- background: 'rgba(239, 68, 68, 0.15)',
2702
- color: '#f87171',
2703
- border: '1px solid rgba(239, 68, 68, 0.3)',
2704
- borderRadius: '6px',
2705
- cursor: isBulkDeleting ? 'not-allowed' : 'pointer',
2706
- opacity: isBulkDeleting ? 0.6 : 1,
2707
- transition: 'all 0.15s ease',
2708
- }}
2709
- onMouseEnter={(e) => {
2710
- if (!isBulkDeleting) {
2711
- e.currentTarget.style.background = 'rgba(239, 68, 68, 0.25)';
2712
- }
2713
- }}
2714
- onMouseLeave={(e) => {
2715
- e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
2716
- }}
2717
- >
2718
- {isBulkDeleting ? 'Deleting...' : `Delete Selected (${selectedStoryIds.size})`}
2719
- </button>
2720
- </div>
2721
- )}
2722
-
2723
- {/* Clear All Button (always visible) */}
2724
- <div style={{
2725
- display: 'flex',
2726
- gap: '8px',
2727
- marginBottom: '12px',
2728
- }}>
2729
- <button
2730
- onClick={handleClearAll}
2731
- disabled={isBulkDeleting || orphanStories.length === 0}
2732
- style={{
2733
- flex: 1,
2734
- padding: '6px 10px',
2735
- fontSize: '11px',
2736
- fontWeight: '500',
2737
- background: 'rgba(100, 116, 139, 0.15)',
2738
- color: '#94a3b8',
2739
- border: '1px solid rgba(100, 116, 139, 0.3)',
2740
- borderRadius: '6px',
2741
- cursor: (isBulkDeleting || orphanStories.length === 0) ? 'not-allowed' : 'pointer',
2742
- opacity: (isBulkDeleting || orphanStories.length === 0) ? 0.6 : 1,
2743
- transition: 'all 0.15s ease',
2744
- }}
2745
- onMouseEnter={(e) => {
2746
- if (!isBulkDeleting && orphanStories.length > 0) {
2747
- e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
2748
- e.currentTarget.style.color = '#f87171';
2749
- e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
2750
- }
2751
- }}
2752
- onMouseLeave={(e) => {
2753
- e.currentTarget.style.background = 'rgba(100, 116, 139, 0.15)';
2754
- e.currentTarget.style.color = '#94a3b8';
2755
- e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.3)';
2756
- }}
2757
- >
2758
- Clear All Stories
2759
- </button>
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>
1455
+ </div>
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
+ )}
2760
1482
  </div>
1483
+ ))}
1484
+ </div>
2761
1485
 
2762
- {/* Story List */}
2763
- {orphanStories.map(story => (
2764
- <div
2765
- key={story.id}
2766
- style={{
2767
- ...STYLES.chatItem,
2768
- background: selectedStoryIds.has(story.id)
2769
- ? 'rgba(59, 130, 246, 0.15)'
2770
- : 'rgba(251, 191, 36, 0.1)',
2771
- borderLeft: selectedStoryIds.has(story.id)
2772
- ? '3px solid rgba(59, 130, 246, 0.5)'
2773
- : '3px solid rgba(251, 191, 36, 0.5)',
2774
- display: 'flex',
2775
- alignItems: 'flex-start',
2776
- gap: '8px',
2777
- }}
2778
- onMouseEnter={(e) => {
2779
- if (!selectedStoryIds.has(story.id)) {
2780
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.15)';
2781
- }
2782
- const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn') as HTMLElement;
2783
- if (deleteBtn) deleteBtn.style.opacity = '1';
2784
- }}
2785
- onMouseLeave={(e) => {
2786
- if (!selectedStoryIds.has(story.id)) {
2787
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.1)';
2788
- }
2789
- const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn') as HTMLElement;
2790
- if (deleteBtn) deleteBtn.style.opacity = '0';
2791
- }}
2792
- >
2793
- {/* Checkbox */}
2794
- <input
2795
- type="checkbox"
2796
- checked={selectedStoryIds.has(story.id)}
2797
- onChange={() => toggleStorySelection(story.id)}
2798
- onClick={(e) => e.stopPropagation()}
2799
- style={{
2800
- width: '14px',
2801
- height: '14px',
2802
- cursor: 'pointer',
2803
- accentColor: '#3b82f6',
2804
- marginTop: '2px',
2805
- flexShrink: 0,
2806
- }}
2807
- />
2808
- {/* Story Info */}
2809
- <div style={{ flex: 1, minWidth: 0 }}>
2810
- <div style={STYLES.chatItemTitle}>{story.title}</div>
2811
- <div style={{ ...STYLES.chatItemTime, fontSize: '11px' }}>
2812
- {story.fileName}
2813
- </div>
2814
- </div>
2815
- {/* Delete Button */}
2816
- <button
2817
- className="delete-orphan-btn"
2818
- onClick={async (e) => {
2819
- e.stopPropagation();
2820
- try {
2821
- const response = await fetch(`${STORIES_API}/${story.id}`, {
2822
- method: 'DELETE',
2823
- });
2824
- if (response.ok) {
2825
- setOrphanStories(prev => prev.filter(s => s.id !== story.id));
2826
- setSelectedStoryIds(prev => {
2827
- const newSet = new Set(prev);
2828
- newSet.delete(story.id);
2829
- return newSet;
2830
- });
2831
- } else {
2832
- console.error('Failed to delete orphan story');
2833
- }
2834
- } catch (err) {
2835
- console.error('Error deleting orphan story:', err);
2836
- }
2837
- }}
2838
- style={STYLES.deleteButton}
2839
- title="Delete generated file"
2840
- >
2841
- <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>
2842
- </button>
2843
- </div>
2844
- ))}
2845
- </>
2846
- )}
2847
1486
  </div>
2848
1487
  )}
2849
- {!sidebarOpen && (
2850
- <div style={{ padding: '8px', display: 'flex', justifyContent: 'center' }}>
2851
- <button
2852
- onClick={() => setSidebarOpen(true)}
2853
- style={{
2854
- ...STYLES.sidebarToggle,
2855
- width: '38px',
2856
- height: '38px',
2857
- padding: '0',
2858
- fontSize: '16px',
2859
- borderRadius: '8px',
2860
- }}
2861
- title="Expand sidebar"
2862
- onMouseEnter={(e) => {
2863
- e.currentTarget.style.transform = 'scale(1.05)';
2864
- e.currentTarget.style.background = '#2563eb';
2865
- }}
2866
- onMouseLeave={(e) => {
2867
- e.currentTarget.style.transform = 'scale(1)';
2868
- e.currentTarget.style.background = '#3b82f6';
2869
- }}
2870
- >
2871
- <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}
2872
1492
  </button>
2873
1493
  </div>
2874
1494
  )}
2875
- </div>
2876
-
2877
- {/* Main content */}
2878
- <div
2879
- style={{ ...STYLES.mainContent, position: 'relative' as const }}
2880
- onDragEnter={handleDragEnter}
2881
- onDragLeave={handleDragLeave}
2882
- onDragOver={handleDragOver}
2883
- onDrop={handleDrop}
2884
- >
2885
- {/* Drop zone overlay */}
2886
- {isDragging && (
2887
- <div style={STYLES.dropOverlay}>
2888
- <div style={STYLES.dropOverlayText}>
2889
- <svg width={24} height={24} viewBox="0 0 24 24" fill="currentColor">
2890
- <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"/>
2891
- </svg>
2892
- 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>
2893
1505
  </div>
2894
1506
  </div>
2895
1507
  )}
2896
1508
 
2897
- <div style={STYLES.chatHeader}>
2898
- <h1 style={{
2899
- fontSize: '22px',
2900
- margin: 0,
2901
- fontWeight: '700',
2902
- background: 'linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%)',
2903
- WebkitBackgroundClip: 'text',
2904
- WebkitTextFillColor: 'transparent',
2905
- display: 'inline-block',
2906
- letterSpacing: '-0.02em'
2907
- }}>
2908
- Story UI
2909
- </h1>
2910
- <p style={{ fontSize: '14px', margin: '6px 0 0 0', color: '#94a3b8', fontWeight: '500' }}>
2911
- Generate Storybook stories with AI
2912
- </p>
2913
- <div style={{
2914
- display: 'flex',
2915
- alignItems: 'center',
2916
- gap: '6px',
2917
- marginTop: '10px',
2918
- fontSize: '11px'
2919
- }}>
2920
- <div style={{
2921
- width: '6px',
2922
- height: '6px',
2923
- borderRadius: '50%',
2924
- backgroundColor: connectionStatus.connected ? '#10b981' : '#f87171'
2925
- }}></div>
2926
- <span
2927
- className={`story-ui-status ${connectionStatus.connected ? 'story-ui-status-connected' : 'story-ui-status-disconnected'}`}
2928
- style={{ color: connectionStatus.connected ? '#10b981' : '#ef4444', fontWeight: '400' }}
2929
- >
2930
- {connectionStatus.connected
2931
- ? `Connected to ${getConnectionDisplayText()}`
2932
- : `Disconnected: ${connectionStatus.error || 'Server not running'}`
2933
- }
2934
- </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>
2935
1517
  </div>
2936
-
2937
- {/* LLM Provider/Model Selection */}
2938
- {connectionStatus.connected && availableProviders.length > 0 && (
2939
- <div style={{
2940
- display: 'flex',
2941
- gap: '12px',
2942
- marginTop: '12px',
2943
- flexWrap: 'wrap'
2944
- }}>
2945
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
2946
- <label style={{ fontSize: '12px', color: '#94a3b8', fontWeight: '500' }}>Provider:</label>
2947
- <select
2948
- value={selectedProvider}
2949
- onChange={(e) => {
2950
- const newProvider = e.target.value;
2951
- setSelectedProvider(newProvider);
2952
- // Reset model to first available for new provider
2953
- const provider = availableProviders.find(p => p.type === newProvider);
2954
- if (provider && provider.models.length > 0) {
2955
- setSelectedModel(provider.models[0]);
2956
- }
2957
- }}
2958
- style={{
2959
- background: '#1e293b',
2960
- border: '1px solid #334155',
2961
- borderRadius: '6px',
2962
- color: '#e2e8f0',
2963
- padding: '4px 8px',
2964
- fontSize: '12px',
2965
- cursor: 'pointer'
2966
- }}
2967
- >
2968
- {availableProviders.map(p => (
2969
- <option key={p.type} value={p.type}>{p.name}</option>
2970
- ))}
2971
- </select>
2972
- </div>
2973
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
2974
- <label style={{ fontSize: '12px', color: '#94a3b8', fontWeight: '500' }}>Model:</label>
2975
- <select
2976
- value={selectedModel}
2977
- onChange={(e) => setSelectedModel(e.target.value)}
2978
- style={{
2979
- background: '#1e293b',
2980
- border: '1px solid #334155',
2981
- borderRadius: '6px',
2982
- color: '#e2e8f0',
2983
- padding: '4px 8px',
2984
- fontSize: '12px',
2985
- cursor: 'pointer',
2986
- maxWidth: '200px'
2987
- }}
2988
- >
2989
- {availableProviders
2990
- .find(p => p.type === selectedProvider)
2991
- ?.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 => (
2992
1552
  <option key={model} value={model}>{getModelDisplayName(model)}</option>
2993
1553
  ))}
2994
- </select>
2995
- </div>
2996
- </div>
2997
- )}
2998
- </div>
2999
-
3000
- <div style={STYLES.chatContainer}>
3001
- {error && (
3002
- <div style={STYLES.errorMessage}>
3003
- {error}
3004
- </div>
3005
- )}
3006
-
3007
- {conversation.length === 0 && !loading && (
3008
- <div style={STYLES.emptyState}>
3009
- <div style={STYLES.emptyStateTitle}>Start a new conversation</div>
3010
- <div style={STYLES.emptyStateSubtitle}>
3011
- 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>
3012
1588
  </div>
3013
1589
  </div>
3014
- )}
3015
-
3016
- {conversation.map((msg, i) => (
3017
- <div key={i} style={STYLES.messageContainer}>
3018
- <div
3019
- className={`story-ui-message ${msg.role === 'user' ? 'story-ui-user-message' : 'story-ui-ai-message'}`}
3020
- style={msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage}
3021
- >
3022
- {msg.role === 'ai' ? renderMarkdown(msg.content) : msg.content}
3023
- {/* Show attached images in user messages */}
3024
- {msg.role === 'user' && msg.attachedImages && msg.attachedImages.length > 0 && (
3025
- <div style={STYLES.userMessageImages}>
3026
- {msg.attachedImages.map((img) => (
3027
- <img
3028
- key={img.id}
3029
- src={img.base64
3030
- ? `data:${img.mediaType || 'image/png'};base64,${img.base64}`
3031
- : img.preview}
3032
- alt="attached"
3033
- style={STYLES.userMessageImage}
3034
- />
3035
- ))}
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
+ )}
3036
1603
  </div>
3037
- )}
3038
- </div>
3039
- </div>
3040
- ))}
3041
-
3042
- {loading && (
3043
- <div style={STYLES.messageContainer}>
3044
- {streamingState ? (
3045
- <StreamingProgressMessage streamingData={streamingState} />
3046
- ) : (
3047
- <div style={STYLES.loadingMessage}>
3048
- <span>Generating story</span>
3049
- <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
+ )}
3050
1613
  </div>
3051
1614
  )}
1615
+ <div ref={chatEndRef} />
3052
1616
  </div>
3053
1617
  )}
3054
-
3055
- <div ref={chatEndRef} />
3056
- </div>
3057
-
3058
- {/* Hidden file input */}
3059
- <input
3060
- ref={fileInputRef}
3061
- type="file"
3062
- accept="image/*"
3063
- multiple
3064
- style={{ display: 'none' }}
3065
- onChange={handleFileSelect}
3066
- />
3067
-
3068
- {/* Image preview area */}
3069
- {attachedImages.length > 0 && (
3070
- <div style={STYLES.imagePreviewContainer}>
3071
- <span style={STYLES.imagePreviewLabel}>
3072
- <svg width={14} height={14} viewBox="0 0 24 24" fill="currentColor">
3073
- <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"/>
3074
- </svg>
3075
- {attachedImages.length} image{attachedImages.length > 1 ? 's' : ''} attached
3076
- </span>
3077
- {attachedImages.map((img) => (
3078
- <div key={img.id} style={STYLES.imagePreviewItem}>
3079
- <img src={img.preview} alt="preview" style={STYLES.imagePreviewImg} />
3080
- <button
3081
- type="button"
3082
- style={STYLES.imageRemoveButton}
3083
- onClick={() => removeAttachedImage(img.id)}
3084
- title="Remove image"
3085
- >
3086
- <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>
3087
- </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
+ ))}
3088
1633
  </div>
3089
- ))}
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>
3090
1652
  </div>
3091
- )}
3092
-
3093
- <form onSubmit={handleSend} style={{
3094
- ...STYLES.inputForm,
3095
- ...(attachedImages.length > 0 ? {
3096
- marginTop: 0,
3097
- borderTopLeftRadius: 0,
3098
- borderTopRightRadius: 0,
3099
- } : {})
3100
- }}>
3101
- {/* Upload button */}
3102
- <button
3103
- type="button"
3104
- onClick={() => fileInputRef.current?.click()}
3105
- disabled={loading || attachedImages.length >= MAX_IMAGES}
3106
- style={{
3107
- ...STYLES.uploadButton,
3108
- ...(attachedImages.length >= MAX_IMAGES ? {
3109
- opacity: 0.5,
3110
- cursor: 'not-allowed',
3111
- } : {})
3112
- }}
3113
- title={attachedImages.length >= MAX_IMAGES
3114
- ? `Maximum ${MAX_IMAGES} images`
3115
- : 'Attach images (screenshots, designs)'
3116
- }
3117
- onMouseEnter={(e) => {
3118
- if (attachedImages.length < MAX_IMAGES && !loading) {
3119
- e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)';
3120
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
3121
- }
3122
- }}
3123
- onMouseLeave={(e) => {
3124
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
3125
- e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
3126
- }}
3127
- >
3128
- <svg width={20} height={20} viewBox="0 0 24 24" fill="currentColor">
3129
- <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"/>
3130
- </svg>
3131
- </button>
3132
-
3133
- <input
3134
- ref={inputRef}
3135
- type="text"
3136
- value={input}
3137
- onChange={e => setInput(e.target.value)}
3138
- onPaste={handlePaste}
3139
- placeholder={attachedImages.length > 0
3140
- ? "Describe what to create from these images..."
3141
- : "Describe a UI component..."
3142
- }
3143
- style={STYLES.textInput}
3144
- onFocus={(e) => {
3145
- e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
3146
- e.currentTarget.style.boxShadow = '0 0 0 3px rgba(59, 130, 246, 0.1)';
3147
- }}
3148
- onBlur={(e) => {
3149
- e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
3150
- e.currentTarget.style.boxShadow = 'none';
3151
- }}
3152
- />
3153
- <button
3154
- type="submit"
3155
- disabled={loading || (!input.trim() && attachedImages.length === 0)}
3156
- style={{
3157
- ...STYLES.sendButton,
3158
- ...(loading || (!input.trim() && attachedImages.length === 0) ? {
3159
- opacity: 0.4,
3160
- cursor: 'not-allowed',
3161
- background: '#64748b',
3162
- boxShadow: 'none'
3163
- } : {})
3164
- }}
3165
- onMouseEnter={(e) => {
3166
- if (!loading && (input.trim() || attachedImages.length > 0)) {
3167
- e.currentTarget.style.background = '#2563eb';
3168
- e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.5)';
3169
- }
3170
- }}
3171
- onMouseLeave={(e) => {
3172
- if (!loading && (input.trim() || attachedImages.length > 0)) {
3173
- e.currentTarget.style.background = '#3b82f6';
3174
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.35)';
3175
- }
3176
- }}
3177
- >
3178
- <svg width={18} height={18} viewBox="0 0 24 24" fill="currentColor">
3179
- <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
3180
- </svg>
3181
- <span>Send</span>
3182
- </button>
3183
- </form>
3184
- </div>
1653
+ </div>
1654
+ </main>
3185
1655
  </div>
3186
1656
  );
3187
1657
  }