@tpitre/story-ui 1.0.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.
@@ -0,0 +1,870 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+
3
+ const MCP_API = 'http://localhost:4001/mcp/generate-story';
4
+ const SYNC_API = 'http://localhost:4001/mcp/sync';
5
+ const LOCAL_STORAGE_KEY = 'story_ui_chat_history_v2'; // Updated version for sync
6
+ const MAX_RECENT_CHATS = 20;
7
+
8
+ interface Message {
9
+ role: 'user' | 'ai';
10
+ content: string;
11
+ }
12
+
13
+ interface ChatSession {
14
+ id: string; // fileName or hash
15
+ title: string;
16
+ fileName: string;
17
+ conversation: Message[];
18
+ lastUpdated: number;
19
+ isValid?: boolean; // Whether the story still exists
20
+ }
21
+
22
+ // Professional, self-contained styles - no external dependencies
23
+ const STYLES = {
24
+ // Typography - Professional font stack like ChatGPT
25
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
26
+
27
+ // Base container with CSS reset
28
+ container: {
29
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
30
+ fontSize: '14px',
31
+ lineHeight: '1.5',
32
+ color: '#ffffff',
33
+ margin: 0,
34
+ padding: 0,
35
+ boxSizing: 'border-box' as const,
36
+ display: 'flex',
37
+ height: '600px',
38
+ background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
39
+ borderRadius: '12px',
40
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
41
+ overflow: 'hidden',
42
+ border: '1px solid rgba(255, 255, 255, 0.1)',
43
+ },
44
+
45
+ // Sidebar styles
46
+ sidebar: {
47
+ width: '280px',
48
+ background: 'linear-gradient(180deg, #0f172a 0%, #1e293b 100%)',
49
+ borderRight: '1px solid rgba(255, 255, 255, 0.1)',
50
+ display: 'flex',
51
+ flexDirection: 'column' as const,
52
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
53
+ },
54
+
55
+ sidebarCollapsed: {
56
+ width: '60px',
57
+ },
58
+
59
+ sidebarToggle: {
60
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
61
+ background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
62
+ color: '#ffffff',
63
+ border: 'none',
64
+ borderRadius: '8px',
65
+ margin: '16px',
66
+ padding: '12px 16px',
67
+ fontWeight: '600',
68
+ fontSize: '14px',
69
+ cursor: 'pointer',
70
+ transition: 'all 0.2s ease',
71
+ display: 'flex',
72
+ alignItems: 'center',
73
+ justifyContent: 'center',
74
+ boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
75
+ },
76
+
77
+ newChatButton: {
78
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
79
+ width: '100%',
80
+ padding: '12px 16px',
81
+ marginBottom: '12px',
82
+ borderRadius: '8px',
83
+ border: '1px solid rgba(59, 130, 246, 0.3)',
84
+ background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
85
+ color: '#ffffff',
86
+ fontWeight: '600',
87
+ fontSize: '14px',
88
+ cursor: 'pointer',
89
+ transition: 'all 0.2s ease',
90
+ boxShadow: '0 2px 8px rgba(59, 130, 246, 0.2)',
91
+ },
92
+
93
+ chatItem: {
94
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
95
+ width: '100%',
96
+ textAlign: 'left' as const,
97
+ padding: '12px 40px 12px 16px',
98
+ borderRadius: '8px',
99
+ border: '1px solid transparent',
100
+ background: 'rgba(255, 255, 255, 0.05)',
101
+ color: '#e2e8f0',
102
+ fontWeight: '400',
103
+ fontSize: '13px',
104
+ cursor: 'pointer',
105
+ overflow: 'hidden',
106
+ textOverflow: 'ellipsis',
107
+ whiteSpace: 'nowrap' as const,
108
+ transition: 'all 0.2s ease',
109
+ marginBottom: '4px',
110
+ },
111
+
112
+ chatItemActive: {
113
+ border: '1px solid rgba(59, 130, 246, 0.5)',
114
+ background: 'rgba(59, 130, 246, 0.1)',
115
+ color: '#60a5fa',
116
+ fontWeight: '500',
117
+ },
118
+
119
+ // Main chat area
120
+ mainArea: {
121
+ flex: 1,
122
+ display: 'flex',
123
+ flexDirection: 'column' as const,
124
+ background: 'rgba(255, 255, 255, 0.02)',
125
+ },
126
+
127
+ header: {
128
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
129
+ color: '#f8fafc',
130
+ margin: '0',
131
+ padding: '24px 24px 16px 24px',
132
+ fontSize: '20px',
133
+ fontWeight: '600',
134
+ letterSpacing: '-0.01em',
135
+ background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
136
+ WebkitBackgroundClip: 'text',
137
+ WebkitTextFillColor: 'transparent',
138
+ backgroundClip: 'text',
139
+ },
140
+
141
+ chatContainer: {
142
+ flex: 1,
143
+ overflowY: 'auto' as const,
144
+ background: 'rgba(0, 0, 0, 0.2)',
145
+ borderRadius: '12px',
146
+ padding: '20px',
147
+ margin: '0 24px 16px 24px',
148
+ border: '1px solid rgba(255, 255, 255, 0.1)',
149
+ backdropFilter: 'blur(10px)',
150
+ },
151
+
152
+ emptyState: {
153
+ color: '#94a3b8',
154
+ textAlign: 'center' as const,
155
+ marginTop: '60px',
156
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
157
+ },
158
+
159
+ emptyStateTitle: {
160
+ fontSize: '16px',
161
+ fontWeight: '500',
162
+ marginBottom: '8px',
163
+ color: '#cbd5e1',
164
+ },
165
+
166
+ emptyStateSubtitle: {
167
+ fontSize: '13px',
168
+ color: '#64748b',
169
+ },
170
+
171
+ // Message bubbles
172
+ messageContainer: {
173
+ display: 'flex',
174
+ marginBottom: '16px',
175
+ },
176
+
177
+ userMessage: {
178
+ background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
179
+ color: '#ffffff',
180
+ borderRadius: '18px 18px 4px 18px',
181
+ padding: '12px 16px',
182
+ maxWidth: '80%',
183
+ marginLeft: 'auto',
184
+ fontSize: '14px',
185
+ lineHeight: '1.5',
186
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
187
+ boxShadow: '0 2px 12px rgba(59, 130, 246, 0.3)',
188
+ wordWrap: 'break-word' as const,
189
+ },
190
+
191
+ aiMessage: {
192
+ background: 'rgba(255, 255, 255, 0.95)',
193
+ color: '#1f2937',
194
+ borderRadius: '18px 18px 18px 4px',
195
+ padding: '12px 16px',
196
+ maxWidth: '80%',
197
+ fontSize: '14px',
198
+ lineHeight: '1.5',
199
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
200
+ boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
201
+ wordWrap: 'break-word' as const,
202
+ whiteSpace: 'pre-wrap' as const,
203
+ },
204
+
205
+ loadingMessage: {
206
+ background: 'rgba(255, 255, 255, 0.9)',
207
+ color: '#6b7280',
208
+ borderRadius: '18px 18px 18px 4px',
209
+ padding: '12px 16px',
210
+ fontSize: '14px',
211
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
212
+ display: 'flex',
213
+ alignItems: 'center',
214
+ gap: '8px',
215
+ },
216
+
217
+ // Input form
218
+ inputForm: {
219
+ display: 'flex',
220
+ alignItems: 'center',
221
+ gap: '12px',
222
+ margin: '0 24px 24px 24px',
223
+ padding: '16px',
224
+ background: 'rgba(255, 255, 255, 0.05)',
225
+ borderRadius: '12px',
226
+ border: '1px solid rgba(255, 255, 255, 0.1)',
227
+ backdropFilter: 'blur(10px)',
228
+ },
229
+
230
+ textInput: {
231
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
232
+ flex: 1,
233
+ padding: '12px 16px',
234
+ borderRadius: '8px',
235
+ border: '1px solid rgba(255, 255, 255, 0.2)',
236
+ fontSize: '14px',
237
+ color: '#1f2937',
238
+ background: '#ffffff',
239
+ outline: 'none',
240
+ transition: 'all 0.2s ease',
241
+ boxSizing: 'border-box' as const,
242
+ },
243
+
244
+ sendButton: {
245
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
246
+ padding: '12px 20px',
247
+ borderRadius: '8px',
248
+ border: 'none',
249
+ background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
250
+ color: '#ffffff',
251
+ fontWeight: '600',
252
+ fontSize: '14px',
253
+ cursor: 'pointer',
254
+ transition: 'all 0.2s ease',
255
+ boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
256
+ },
257
+
258
+ sendButtonDisabled: {
259
+ background: 'rgba(107, 114, 128, 0.5)',
260
+ cursor: 'not-allowed',
261
+ boxShadow: 'none',
262
+ },
263
+
264
+ errorMessage: {
265
+ color: '#ef4444',
266
+ margin: '0 24px 16px 24px',
267
+ padding: '12px 16px',
268
+ background: 'rgba(239, 68, 68, 0.1)',
269
+ borderRadius: '8px',
270
+ border: '1px solid rgba(239, 68, 68, 0.3)',
271
+ fontSize: '13px',
272
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
273
+ },
274
+
275
+ deleteButton: {
276
+ position: 'absolute' as const,
277
+ right: '8px',
278
+ top: '50%',
279
+ transform: 'translateY(-50%)',
280
+ width: '20px',
281
+ height: '20px',
282
+ borderRadius: '4px',
283
+ border: 'none',
284
+ background: 'rgba(239, 68, 68, 0.8)',
285
+ color: '#ffffff',
286
+ fontSize: '12px',
287
+ fontWeight: 'bold',
288
+ cursor: 'pointer',
289
+ display: 'flex',
290
+ alignItems: 'center',
291
+ justifyContent: 'center',
292
+ transition: 'all 0.2s ease',
293
+ zIndex: 10,
294
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
295
+ },
296
+ };
297
+
298
+ function loadChats(): ChatSession[] {
299
+ try {
300
+ const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
301
+ if (!raw) return [];
302
+ return JSON.parse(raw) as ChatSession[];
303
+ } catch {
304
+ return [];
305
+ }
306
+ }
307
+
308
+ function saveChats(chats: ChatSession[]) {
309
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(chats));
310
+ }
311
+
312
+ function removeDuplicateChats(chats: ChatSession[]): ChatSession[] {
313
+ const seen = new Set<string>();
314
+ const seenTitles = new Map<string, ChatSession>();
315
+
316
+ return chats.filter(chat => {
317
+ // Remove exact ID duplicates
318
+ if (seen.has(chat.id)) {
319
+ return false;
320
+ }
321
+ seen.add(chat.id);
322
+
323
+ // For title duplicates, keep the most recent one
324
+ const existingChat = seenTitles.get(chat.title);
325
+ if (existingChat) {
326
+ if (chat.lastUpdated > existingChat.lastUpdated) {
327
+ // Remove the older chat and keep this newer one
328
+ const oldIndex = chats.findIndex(c => c.id === existingChat.id);
329
+ if (oldIndex !== -1) {
330
+ seen.delete(existingChat.id);
331
+ }
332
+ seenTitles.set(chat.title, chat);
333
+ return true;
334
+ } else {
335
+ // Keep the existing newer chat, skip this one
336
+ return false;
337
+ }
338
+ } else {
339
+ seenTitles.set(chat.title, chat);
340
+ return true;
341
+ }
342
+ });
343
+ }
344
+
345
+ async function syncWithActualStories(): Promise<ChatSession[]> {
346
+ try {
347
+ // Get actual stories from the server
348
+ const response = await fetch(`${SYNC_API}/stories`);
349
+ const data = await response.json();
350
+
351
+ if (!data.success) {
352
+ console.warn('Failed to sync with actual stories:', data.error);
353
+ return loadChats();
354
+ }
355
+
356
+ const actualStories = data.stories;
357
+ const existingChats = loadChats();
358
+
359
+ // More robust matching: check multiple ID formats to prevent duplicates
360
+ const validChats = existingChats.filter(chat => {
361
+ return actualStories.some((story: { id: string; fileName: string }) => {
362
+ // Direct ID match
363
+ if (story.id === chat.id) return true;
364
+
365
+ // Filename match
366
+ if (story.fileName === chat.fileName) return true;
367
+
368
+ // Check if chat ID matches story filename (old format)
369
+ if (story.fileName === chat.id) return true;
370
+
371
+ // Check if story ID matches chat filename (common mismatch)
372
+ if (story.id === chat.fileName) return true;
373
+
374
+ return false;
375
+ });
376
+ });
377
+
378
+ // Only add stories that truly don't have chat sessions after robust checking
379
+ const newChats: ChatSession[] = actualStories
380
+ .filter((story: { id: string; fileName: string }) => {
381
+ return !existingChats.some(chat => {
382
+ // Check all possible matches to avoid creating duplicates
383
+ return chat.id === story.id ||
384
+ chat.fileName === story.fileName ||
385
+ chat.id === story.fileName ||
386
+ chat.fileName === story.id;
387
+ });
388
+ })
389
+ .map((story: { id: string; title: string; fileName: string; createdAt: string }) => ({
390
+ id: story.id,
391
+ title: story.title,
392
+ fileName: story.fileName,
393
+ conversation: [
394
+ { role: 'ai' as const, content: `Story "${story.title}" was found.\nGenerated: ${new Date(story.createdAt).toLocaleString()}` }
395
+ ],
396
+ lastUpdated: new Date(story.createdAt).getTime(),
397
+ isValid: true
398
+ }));
399
+
400
+ // Mark all chats as valid and combine
401
+ const combinedChats = [...validChats.map(chat => ({ ...chat, isValid: true })), ...newChats];
402
+
403
+ // Remove any duplicates that might have been created
404
+ const syncedChats = removeDuplicateChats(combinedChats);
405
+
406
+ // Save the synchronized chats
407
+ saveChats(syncedChats);
408
+
409
+ console.log('Sync completed:', {
410
+ totalStories: actualStories.length,
411
+ existingChats: existingChats.length,
412
+ validChats: validChats.length,
413
+ newChats: newChats.length,
414
+ finalChats: syncedChats.length
415
+ });
416
+
417
+ return syncedChats;
418
+ } catch (error) {
419
+ console.warn('Failed to sync with server:', error);
420
+ return loadChats();
421
+ }
422
+ }
423
+
424
+ async function deleteStoryAndChat(storyId: string): Promise<boolean> {
425
+ try {
426
+ const response = await fetch(`${SYNC_API}/stories/${storyId}`, {
427
+ method: 'DELETE'
428
+ });
429
+ const data = await response.json();
430
+
431
+ if (data.success) {
432
+ // Remove from localStorage
433
+ const chats = loadChats().filter(chat => chat.id !== storyId);
434
+ saveChats(chats);
435
+ return true;
436
+ }
437
+
438
+ return false;
439
+ } catch (error) {
440
+ console.warn('Failed to delete story:', error);
441
+ return false;
442
+ }
443
+ }
444
+
445
+ const StoryUIPanel: React.FC = () => {
446
+ const [conversation, setConversation] = useState<Message[]>([]);
447
+ const [input, setInput] = useState('');
448
+ const [loading, setLoading] = useState(false);
449
+ const [error, setError] = useState<string | null>(null);
450
+ const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
451
+ const [activeChatId, setActiveChatId] = useState<string | null>(null);
452
+ const [activeTitle, setActiveTitle] = useState<string>('');
453
+ const [sidebarOpen, setSidebarOpen] = useState(true);
454
+ const chatEndRef = useRef<HTMLDivElement | null>(null);
455
+
456
+ // Load and sync chats on mount
457
+ useEffect(() => {
458
+ const initializeChats = async () => {
459
+ const syncedChats = await syncWithActualStories();
460
+ const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
461
+ setRecentChats(sortedChats);
462
+
463
+ if (sortedChats.length > 0) {
464
+ setConversation(sortedChats[0].conversation);
465
+ setActiveChatId(sortedChats[0].id);
466
+ setActiveTitle(sortedChats[0].title);
467
+ }
468
+ };
469
+
470
+ initializeChats();
471
+ }, []);
472
+
473
+ // Scroll to bottom on new message
474
+ useEffect(() => {
475
+ if (chatEndRef.current) {
476
+ chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
477
+ }
478
+ }, [conversation, loading]);
479
+
480
+ const handleSend = async (e?: React.FormEvent) => {
481
+ if (e) e.preventDefault();
482
+ if (!input.trim()) return;
483
+ setError(null);
484
+ setLoading(true);
485
+ const newConversation = [...conversation, { role: 'user' as const, content: input }];
486
+ setConversation(newConversation);
487
+ setInput('');
488
+ try {
489
+ const res = await fetch(MCP_API, {
490
+ method: 'POST',
491
+ headers: { 'Content-Type': 'application/json' },
492
+ body: JSON.stringify({
493
+ prompt: input,
494
+ conversation: newConversation,
495
+ fileName: activeChatId || undefined,
496
+ }),
497
+ });
498
+ const data = await res.json();
499
+ if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
500
+
501
+ // Create user-friendly response message instead of showing raw markup
502
+ let responseMessage: string;
503
+ if (data.isUpdate) {
504
+ responseMessage = `✅ Updated your story: "${data.title}"\n\nI've made the requested changes while keeping the same layout structure. You can view the updated component in Storybook.`;
505
+ } else {
506
+ responseMessage = `✅ Created new story: "${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 in the Docs tab.`;
507
+ }
508
+
509
+ const aiMsg = { role: 'ai' as const, content: responseMessage };
510
+ const updatedConversation = [...newConversation, aiMsg];
511
+ setConversation(updatedConversation);
512
+
513
+ // Determine if this is an update or new chat
514
+ // Check if we have an active chat AND the backend indicates this is an update
515
+ const isUpdate = activeChatId && conversation.length > 0 && (
516
+ data.isUpdate ||
517
+ data.fileName === activeChatId ||
518
+ // Also check if fileName matches any existing chat's fileName
519
+ recentChats.some(chat => chat.fileName === data.fileName && chat.id === activeChatId)
520
+ );
521
+
522
+ console.log('Update detection:', {
523
+ activeChatId,
524
+ conversationLength: conversation.length,
525
+ dataIsUpdate: data.isUpdate,
526
+ dataFileName: data.fileName,
527
+ isUpdate
528
+ });
529
+
530
+ if (isUpdate) {
531
+ // Update existing chat session
532
+ const chatTitle = activeTitle; // Keep existing title for updates
533
+ const updatedSession: ChatSession = {
534
+ id: activeChatId,
535
+ title: chatTitle,
536
+ fileName: data.fileName || activeChatId,
537
+ conversation: updatedConversation,
538
+ lastUpdated: Date.now(),
539
+ };
540
+
541
+ const chats = loadChats();
542
+ const chatIndex = chats.findIndex(c => c.id === activeChatId);
543
+ if (chatIndex !== -1) {
544
+ chats[chatIndex] = updatedSession;
545
+ }
546
+ saveChats(chats);
547
+ setRecentChats(chats);
548
+ console.log('Updated existing chat:', activeChatId);
549
+ } else {
550
+ // Create new chat session - use storyId from backend for consistency
551
+ const chatId = data.storyId || data.fileName || data.outPath || Date.now().toString();
552
+ const chatTitle = data.title || input;
553
+ setActiveChatId(chatId);
554
+ setActiveTitle(chatTitle);
555
+
556
+ const newSession: ChatSession = {
557
+ id: chatId,
558
+ title: chatTitle,
559
+ fileName: data.fileName || '',
560
+ conversation: updatedConversation,
561
+ lastUpdated: Date.now(),
562
+ };
563
+
564
+ const chats = loadChats().filter(c => c.id !== chatId);
565
+ chats.unshift(newSession);
566
+ if (chats.length > MAX_RECENT_CHATS) {
567
+ chats.splice(MAX_RECENT_CHATS);
568
+ }
569
+ saveChats(chats);
570
+ setRecentChats(chats);
571
+ console.log('Created new chat:', chatId);
572
+ }
573
+
574
+ } catch (err: unknown) {
575
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
576
+ setError(errorMessage);
577
+ const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
578
+ setConversation(errorConversation);
579
+
580
+ // IMPORTANT: Create/update chat session even on error so retries continue the same conversation
581
+ const isUpdate = activeChatId && conversation.length > 0;
582
+
583
+ if (isUpdate) {
584
+ // Update existing chat with error
585
+ const updatedSession: ChatSession = {
586
+ id: activeChatId,
587
+ title: activeTitle,
588
+ fileName: activeChatId,
589
+ conversation: errorConversation,
590
+ lastUpdated: Date.now(),
591
+ };
592
+
593
+ const chats = loadChats();
594
+ const chatIndex = chats.findIndex(c => c.id === activeChatId);
595
+ if (chatIndex !== -1) {
596
+ chats[chatIndex] = updatedSession;
597
+ }
598
+ saveChats(chats);
599
+ setRecentChats(chats);
600
+ } else {
601
+ // Create new chat session for error (so retries can continue it)
602
+ const chatId = `error-${Date.now()}`;
603
+ const chatTitle = input.length > 30 ? input.substring(0, 30) + '...' : input;
604
+ setActiveChatId(chatId);
605
+ setActiveTitle(chatTitle);
606
+
607
+ const newSession: ChatSession = {
608
+ id: chatId,
609
+ title: chatTitle,
610
+ fileName: '',
611
+ conversation: errorConversation,
612
+ lastUpdated: Date.now(),
613
+ };
614
+
615
+ const chats = loadChats();
616
+ chats.unshift(newSession);
617
+ if (chats.length > MAX_RECENT_CHATS) {
618
+ chats.splice(MAX_RECENT_CHATS);
619
+ }
620
+ saveChats(chats);
621
+ setRecentChats(chats);
622
+ }
623
+ } finally {
624
+ setLoading(false);
625
+ }
626
+ };
627
+
628
+ const handleSelectChat = (chat: ChatSession) => {
629
+ setConversation(chat.conversation);
630
+ setActiveChatId(chat.id);
631
+ setActiveTitle(chat.title);
632
+ };
633
+
634
+ const handleNewChat = () => {
635
+ setConversation([]);
636
+ setActiveChatId(null);
637
+ setActiveTitle('');
638
+ };
639
+
640
+ const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => {
641
+ e.stopPropagation(); // Prevent selecting the chat
642
+
643
+ if (confirm('Delete this story and chat? This action cannot be undone.')) {
644
+ const success = await deleteStoryAndChat(chatId);
645
+
646
+ if (success) {
647
+ // Update local state
648
+ const updatedChats = recentChats.filter(chat => chat.id !== chatId);
649
+ setRecentChats(updatedChats);
650
+
651
+ // If we deleted the active chat, switch to another or clear
652
+ if (activeChatId === chatId) {
653
+ if (updatedChats.length > 0) {
654
+ setConversation(updatedChats[0].conversation);
655
+ setActiveChatId(updatedChats[0].id);
656
+ setActiveTitle(updatedChats[0].title);
657
+ } else {
658
+ handleNewChat();
659
+ }
660
+ }
661
+ } else {
662
+ alert('Failed to delete story. Please try again.');
663
+ }
664
+ }
665
+ };
666
+
667
+ return (
668
+ <div style={STYLES.container}>
669
+ {/* Sidebar */}
670
+ <div style={{
671
+ ...STYLES.sidebar,
672
+ ...(sidebarOpen ? {} : STYLES.sidebarCollapsed),
673
+ }}>
674
+ <button
675
+ onClick={() => setSidebarOpen(o => !o)}
676
+ style={{
677
+ ...STYLES.sidebarToggle,
678
+ ...(sidebarOpen ? {} : { width: '40px', height: '40px', padding: '0' }),
679
+ }}
680
+ title={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
681
+ onMouseEnter={(e) => {
682
+ e.currentTarget.style.transform = 'scale(1.05)';
683
+ e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.4)';
684
+ }}
685
+ onMouseLeave={(e) => {
686
+ e.currentTarget.style.transform = 'scale(1)';
687
+ e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.3)';
688
+ }}
689
+ >
690
+ {sidebarOpen ? '☰ Chats' : '☰'}
691
+ </button>
692
+ {sidebarOpen && (
693
+ <div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
694
+ <button
695
+ onClick={handleNewChat}
696
+ style={STYLES.newChatButton}
697
+ onMouseEnter={(e) => {
698
+ e.currentTarget.style.transform = 'translateY(-1px)';
699
+ e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.4)';
700
+ }}
701
+ onMouseLeave={(e) => {
702
+ e.currentTarget.style.transform = 'translateY(0)';
703
+ e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.2)';
704
+ }}
705
+ >
706
+ + New Chat
707
+ </button>
708
+ {recentChats.length > 0 && (
709
+ <div style={{
710
+ color: '#64748b',
711
+ fontSize: '12px',
712
+ marginBottom: '8px',
713
+ fontWeight: '500',
714
+ textTransform: 'uppercase',
715
+ letterSpacing: '0.05em',
716
+ fontFamily: STYLES.fontFamily,
717
+ }}>
718
+ Recent
719
+ </div>
720
+ )}
721
+ {recentChats.map(chat => (
722
+ <div
723
+ key={chat.id}
724
+ style={{
725
+ position: 'relative',
726
+ marginBottom: '4px',
727
+ }}
728
+ >
729
+ <button
730
+ onClick={() => handleSelectChat(chat)}
731
+ style={{
732
+ ...STYLES.chatItem,
733
+ ...(chat.id === activeChatId ? STYLES.chatItemActive : {}),
734
+ }}
735
+ title={chat.title}
736
+ onMouseEnter={(e) => {
737
+ if (chat.id !== activeChatId) {
738
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
739
+ }
740
+ }}
741
+ onMouseLeave={(e) => {
742
+ if (chat.id !== activeChatId) {
743
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
744
+ }
745
+ }}
746
+ >
747
+ {chat.title}
748
+ </button>
749
+ <button
750
+ onClick={(e) => handleDeleteChat(chat.id, e)}
751
+ style={STYLES.deleteButton}
752
+ title="Delete story and chat"
753
+ onMouseEnter={(e) => {
754
+ e.currentTarget.style.background = '#ef4444';
755
+ e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
756
+ }}
757
+ onMouseLeave={(e) => {
758
+ e.currentTarget.style.background = 'rgba(239, 68, 68, 0.8)';
759
+ e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
760
+ }}
761
+ >
762
+ ×
763
+ </button>
764
+ </div>
765
+ ))}
766
+ </div>
767
+ )}
768
+ </div>
769
+
770
+ {/* Main chat area */}
771
+ <div style={STYLES.mainArea}>
772
+ <h2 style={STYLES.header}>Story UI: AI Story Generator</h2>
773
+
774
+ <div style={STYLES.chatContainer}>
775
+ {conversation.length === 0 && (
776
+ <div style={STYLES.emptyState}>
777
+ <div style={STYLES.emptyStateTitle}>Start a conversation to generate a Storybook story</div>
778
+ <div style={STYLES.emptyStateSubtitle}>
779
+ (e.g. &quot;Build a login form with two fields and a button&quot;)
780
+ </div>
781
+ </div>
782
+ )}
783
+
784
+ {conversation.map((msg, i) => (
785
+ <div key={i} style={{
786
+ ...STYLES.messageContainer,
787
+ justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
788
+ }}>
789
+ <div style={msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage}>
790
+ {msg.content}
791
+ </div>
792
+ </div>
793
+ ))}
794
+
795
+ {loading && (
796
+ <div style={{ ...STYLES.messageContainer, justifyContent: 'flex-start' }}>
797
+ <div style={STYLES.loadingMessage}>
798
+ <div style={{
799
+ width: '6px',
800
+ height: '6px',
801
+ backgroundColor: '#6b7280',
802
+ borderRadius: '50%',
803
+ animation: 'pulse 1.5s ease-in-out infinite',
804
+ }}></div>
805
+ Generating...
806
+ </div>
807
+ </div>
808
+ )}
809
+
810
+ <div ref={chatEndRef} />
811
+ </div>
812
+
813
+ <form onSubmit={handleSend} style={STYLES.inputForm}>
814
+ <input
815
+ type="text"
816
+ value={input}
817
+ onChange={e => setInput(e.target.value)}
818
+ placeholder="Describe your UI or give feedback..."
819
+ style={STYLES.textInput}
820
+ disabled={loading}
821
+ onFocus={(e) => {
822
+ e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
823
+ e.currentTarget.style.boxShadow = '0 0 0 3px rgba(59, 130, 246, 0.1)';
824
+ }}
825
+ onBlur={(e) => {
826
+ e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
827
+ e.currentTarget.style.boxShadow = 'none';
828
+ }}
829
+ />
830
+ <button
831
+ type="submit"
832
+ disabled={loading || !input.trim()}
833
+ style={{
834
+ ...STYLES.sendButton,
835
+ ...(loading || !input.trim() ? STYLES.sendButtonDisabled : {}),
836
+ }}
837
+ onMouseEnter={(e) => {
838
+ if (!loading && input.trim()) {
839
+ e.currentTarget.style.transform = 'translateY(-1px)';
840
+ e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
841
+ }
842
+ }}
843
+ onMouseLeave={(e) => {
844
+ if (!loading && input.trim()) {
845
+ e.currentTarget.style.transform = 'translateY(0)';
846
+ e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
847
+ }
848
+ }}
849
+ >
850
+ {loading ? '...' : 'Send'}
851
+ </button>
852
+ </form>
853
+
854
+ {error && <div style={STYLES.errorMessage}>{error}</div>}
855
+ </div>
856
+
857
+ {/* Add keyframes animation for loading pulse */}
858
+ <style>
859
+ {`
860
+ @keyframes pulse {
861
+ 0%, 100% { opacity: 0.4; }
862
+ 50% { opacity: 1; }
863
+ }
864
+ `}
865
+ </style>
866
+ </div>
867
+ );
868
+ };
869
+
870
+ export default StoryUIPanel;