@tpitre/story-ui 2.2.0 → 2.3.1

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.
Files changed (188) hide show
  1. package/.env.sample +82 -11
  2. package/README.md +89 -0
  3. package/dist/cli/deploy.d.ts +17 -0
  4. package/dist/cli/deploy.d.ts.map +1 -0
  5. package/dist/cli/deploy.js +696 -0
  6. package/dist/cli/index.d.ts +3 -0
  7. package/dist/cli/index.d.ts.map +1 -0
  8. package/dist/cli/index.js +26 -2
  9. package/dist/cli/setup.d.ts +11 -0
  10. package/dist/cli/setup.d.ts.map +1 -0
  11. package/dist/cli/setup.js +437 -110
  12. package/dist/mcp-server/index.d.ts +2 -0
  13. package/dist/mcp-server/index.d.ts.map +1 -0
  14. package/dist/mcp-server/index.js +120 -2
  15. package/dist/mcp-server/mcp-stdio-server.d.ts +3 -0
  16. package/dist/mcp-server/mcp-stdio-server.d.ts.map +1 -0
  17. package/dist/mcp-server/mcp-stdio-server.js +8 -1
  18. package/dist/mcp-server/routes/claude.d.ts +3 -0
  19. package/dist/mcp-server/routes/claude.d.ts.map +1 -0
  20. package/dist/mcp-server/routes/claude.js +60 -23
  21. package/dist/mcp-server/routes/components.d.ts +4 -0
  22. package/dist/mcp-server/routes/components.d.ts.map +1 -0
  23. package/dist/mcp-server/routes/frameworks.d.ts +38 -0
  24. package/dist/mcp-server/routes/frameworks.d.ts.map +1 -0
  25. package/dist/mcp-server/routes/frameworks.js +183 -0
  26. package/dist/mcp-server/routes/generateStory.d.ts +3 -0
  27. package/dist/mcp-server/routes/generateStory.d.ts.map +1 -0
  28. package/dist/mcp-server/routes/generateStory.js +160 -76
  29. package/dist/mcp-server/routes/generateStoryStream.d.ts +12 -0
  30. package/dist/mcp-server/routes/generateStoryStream.d.ts.map +1 -0
  31. package/dist/mcp-server/routes/generateStoryStream.js +947 -0
  32. package/dist/mcp-server/routes/hybridStories.d.ts +18 -0
  33. package/dist/mcp-server/routes/hybridStories.d.ts.map +1 -0
  34. package/dist/mcp-server/routes/mcpRemote.d.ts +14 -0
  35. package/dist/mcp-server/routes/mcpRemote.d.ts.map +1 -0
  36. package/dist/mcp-server/routes/mcpRemote.js +489 -0
  37. package/dist/mcp-server/routes/memoryStories.d.ts +26 -0
  38. package/dist/mcp-server/routes/memoryStories.d.ts.map +1 -0
  39. package/dist/mcp-server/routes/providers.d.ts +89 -0
  40. package/dist/mcp-server/routes/providers.d.ts.map +1 -0
  41. package/dist/mcp-server/routes/providers.js +369 -0
  42. package/dist/mcp-server/routes/storySync.d.ts +26 -0
  43. package/dist/mcp-server/routes/storySync.d.ts.map +1 -0
  44. package/dist/mcp-server/routes/streamTypes.d.ts +110 -0
  45. package/dist/mcp-server/routes/streamTypes.d.ts.map +1 -0
  46. package/dist/mcp-server/routes/streamTypes.js +18 -0
  47. package/dist/mcp-server/sessionManager.d.ts +50 -0
  48. package/dist/mcp-server/sessionManager.d.ts.map +1 -0
  49. package/dist/story-generator/componentBlacklist.d.ts +21 -0
  50. package/dist/story-generator/componentBlacklist.d.ts.map +1 -0
  51. package/dist/story-generator/componentDiscovery.d.ts +28 -0
  52. package/dist/story-generator/componentDiscovery.d.ts.map +1 -0
  53. package/dist/story-generator/componentRegistryGenerator.d.ts +49 -0
  54. package/dist/story-generator/componentRegistryGenerator.d.ts.map +1 -0
  55. package/dist/story-generator/componentRegistryGenerator.js +205 -0
  56. package/dist/story-generator/configLoader.d.ts +33 -0
  57. package/dist/story-generator/configLoader.d.ts.map +1 -0
  58. package/dist/story-generator/considerationsLoader.d.ts +32 -0
  59. package/dist/story-generator/considerationsLoader.d.ts.map +1 -0
  60. package/dist/story-generator/documentation-sources.d.ts +28 -0
  61. package/dist/story-generator/documentation-sources.d.ts.map +1 -0
  62. package/dist/story-generator/documentationLoader.d.ts +64 -0
  63. package/dist/story-generator/documentationLoader.d.ts.map +1 -0
  64. package/dist/story-generator/dynamicPackageDiscovery.d.ts +97 -0
  65. package/dist/story-generator/dynamicPackageDiscovery.d.ts.map +1 -0
  66. package/dist/story-generator/enhancedComponentDiscovery.d.ts +125 -0
  67. package/dist/story-generator/enhancedComponentDiscovery.d.ts.map +1 -0
  68. package/dist/story-generator/enhancedComponentDiscovery.js +111 -11
  69. package/dist/story-generator/framework-adapters/angular-adapter.d.ts +40 -0
  70. package/dist/story-generator/framework-adapters/angular-adapter.d.ts.map +1 -0
  71. package/dist/story-generator/framework-adapters/angular-adapter.js +427 -0
  72. package/dist/story-generator/framework-adapters/base-adapter.d.ts +75 -0
  73. package/dist/story-generator/framework-adapters/base-adapter.d.ts.map +1 -0
  74. package/dist/story-generator/framework-adapters/base-adapter.js +147 -0
  75. package/dist/story-generator/framework-adapters/framework-detector.d.ts +55 -0
  76. package/dist/story-generator/framework-adapters/framework-detector.d.ts.map +1 -0
  77. package/dist/story-generator/framework-adapters/framework-detector.js +323 -0
  78. package/dist/story-generator/framework-adapters/index.d.ts +97 -0
  79. package/dist/story-generator/framework-adapters/index.d.ts.map +1 -0
  80. package/dist/story-generator/framework-adapters/index.js +198 -0
  81. package/dist/story-generator/framework-adapters/react-adapter.d.ts +40 -0
  82. package/dist/story-generator/framework-adapters/react-adapter.d.ts.map +1 -0
  83. package/dist/story-generator/framework-adapters/react-adapter.js +316 -0
  84. package/dist/story-generator/framework-adapters/svelte-adapter.d.ts +40 -0
  85. package/dist/story-generator/framework-adapters/svelte-adapter.d.ts.map +1 -0
  86. package/dist/story-generator/framework-adapters/svelte-adapter.js +372 -0
  87. package/dist/story-generator/framework-adapters/types.d.ts +182 -0
  88. package/dist/story-generator/framework-adapters/types.d.ts.map +1 -0
  89. package/dist/story-generator/framework-adapters/types.js +8 -0
  90. package/dist/story-generator/framework-adapters/vue-adapter.d.ts +36 -0
  91. package/dist/story-generator/framework-adapters/vue-adapter.d.ts.map +1 -0
  92. package/dist/story-generator/framework-adapters/vue-adapter.js +336 -0
  93. package/dist/story-generator/framework-adapters/web-components-adapter.d.ts +54 -0
  94. package/dist/story-generator/framework-adapters/web-components-adapter.d.ts.map +1 -0
  95. package/dist/story-generator/framework-adapters/web-components-adapter.js +387 -0
  96. package/dist/story-generator/generateStory.d.ts +7 -0
  97. package/dist/story-generator/generateStory.d.ts.map +1 -0
  98. package/dist/story-generator/gitignoreManager.d.ts +50 -0
  99. package/dist/story-generator/gitignoreManager.d.ts.map +1 -0
  100. package/dist/story-generator/imageProcessor.d.ts +80 -0
  101. package/dist/story-generator/imageProcessor.d.ts.map +1 -0
  102. package/dist/story-generator/imageProcessor.js +391 -0
  103. package/dist/story-generator/inMemoryStoryService.d.ts +89 -0
  104. package/dist/story-generator/inMemoryStoryService.d.ts.map +1 -0
  105. package/dist/story-generator/llm-providers/base-provider.d.ts +36 -0
  106. package/dist/story-generator/llm-providers/base-provider.d.ts.map +1 -0
  107. package/dist/story-generator/llm-providers/base-provider.js +135 -0
  108. package/dist/story-generator/llm-providers/claude-provider.d.ts +23 -0
  109. package/dist/story-generator/llm-providers/claude-provider.d.ts.map +1 -0
  110. package/dist/story-generator/llm-providers/claude-provider.js +414 -0
  111. package/dist/story-generator/llm-providers/gemini-provider.d.ts +24 -0
  112. package/dist/story-generator/llm-providers/gemini-provider.d.ts.map +1 -0
  113. package/dist/story-generator/llm-providers/gemini-provider.js +406 -0
  114. package/dist/story-generator/llm-providers/index.d.ts +63 -0
  115. package/dist/story-generator/llm-providers/index.d.ts.map +1 -0
  116. package/dist/story-generator/llm-providers/index.js +169 -0
  117. package/dist/story-generator/llm-providers/openai-provider.d.ts +24 -0
  118. package/dist/story-generator/llm-providers/openai-provider.d.ts.map +1 -0
  119. package/dist/story-generator/llm-providers/openai-provider.js +458 -0
  120. package/dist/story-generator/llm-providers/settings-manager.d.ts +75 -0
  121. package/dist/story-generator/llm-providers/settings-manager.d.ts.map +1 -0
  122. package/dist/story-generator/llm-providers/settings-manager.js +173 -0
  123. package/dist/story-generator/llm-providers/story-llm-service.d.ts +79 -0
  124. package/dist/story-generator/llm-providers/story-llm-service.d.ts.map +1 -0
  125. package/dist/story-generator/llm-providers/story-llm-service.js +240 -0
  126. package/dist/story-generator/llm-providers/types.d.ts +153 -0
  127. package/dist/story-generator/llm-providers/types.d.ts.map +1 -0
  128. package/dist/story-generator/llm-providers/types.js +8 -0
  129. package/dist/story-generator/logger.d.ts +14 -0
  130. package/dist/story-generator/logger.d.ts.map +1 -0
  131. package/dist/story-generator/logger.js +96 -29
  132. package/dist/story-generator/postProcessStory.d.ts +6 -0
  133. package/dist/story-generator/postProcessStory.d.ts.map +1 -0
  134. package/dist/story-generator/productionGitignoreManager.d.ts +91 -0
  135. package/dist/story-generator/productionGitignoreManager.d.ts.map +1 -0
  136. package/dist/story-generator/promptGenerator.d.ts +48 -0
  137. package/dist/story-generator/promptGenerator.d.ts.map +1 -0
  138. package/dist/story-generator/promptGenerator.js +186 -1
  139. package/dist/story-generator/storyHistory.d.ts +44 -0
  140. package/dist/story-generator/storyHistory.d.ts.map +1 -0
  141. package/dist/story-generator/storySync.d.ts +68 -0
  142. package/dist/story-generator/storySync.d.ts.map +1 -0
  143. package/dist/story-generator/storyTracker.d.ts +48 -0
  144. package/dist/story-generator/storyTracker.d.ts.map +1 -0
  145. package/dist/story-generator/storyValidator.d.ts +6 -0
  146. package/dist/story-generator/storyValidator.d.ts.map +1 -0
  147. package/dist/story-generator/universalDesignSystemAdapter.d.ts +68 -0
  148. package/dist/story-generator/universalDesignSystemAdapter.d.ts.map +1 -0
  149. package/dist/story-generator/universalDesignSystemAdapter.js +138 -1
  150. package/dist/story-generator/urlRedirectService.d.ts +21 -0
  151. package/dist/story-generator/urlRedirectService.d.ts.map +1 -0
  152. package/dist/story-generator/validateStory.d.ts +19 -0
  153. package/dist/story-generator/validateStory.d.ts.map +1 -0
  154. package/dist/story-generator/validateStory.js +6 -2
  155. package/dist/story-generator/visionPrompts.d.ts +88 -0
  156. package/dist/story-generator/visionPrompts.d.ts.map +1 -0
  157. package/dist/story-generator/visionPrompts.js +462 -0
  158. package/dist/story-ui.config.d.ts +78 -0
  159. package/dist/story-ui.config.d.ts.map +1 -0
  160. package/dist/templates/StoryUI/StoryUIPanel.d.ts +4 -0
  161. package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -0
  162. package/dist/templates/StoryUI/StoryUIPanel.js +1874 -0
  163. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts +18 -0
  164. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts.map +1 -0
  165. package/dist/templates/StoryUI/StoryUIPanel.stories.js +37 -0
  166. package/dist/templates/StoryUI/index.d.ts +3 -0
  167. package/dist/templates/StoryUI/index.d.ts.map +1 -0
  168. package/dist/templates/StoryUI/index.js +2 -0
  169. package/package.json +17 -3
  170. package/templates/StoryUI/StoryUIPanel.tsx +1960 -384
  171. package/templates/StoryUI/index.tsx +1 -1
  172. package/templates/StoryUI/manager.tsx +264 -0
  173. package/templates/production-app/.env.example +11 -0
  174. package/templates/production-app/index.html +66 -0
  175. package/templates/production-app/package.json +30 -0
  176. package/templates/production-app/public/favicon.svg +5 -0
  177. package/templates/production-app/src/App.tsx +1560 -0
  178. package/templates/production-app/src/LivePreviewRenderer.tsx +420 -0
  179. package/templates/production-app/src/componentRegistry.ts +315 -0
  180. package/templates/production-app/src/considerations.ts +16 -0
  181. package/templates/production-app/src/index.css +284 -0
  182. package/templates/production-app/src/main.tsx +25 -0
  183. package/templates/production-app/tsconfig.json +32 -0
  184. package/templates/production-app/tsconfig.node.json +11 -0
  185. package/templates/production-app/vite.config.ts +83 -0
  186. package/templates/react-import-rule.json +2 -2
  187. package/dist/index.js +0 -12
  188. package/dist/story-ui.config.loader.js +0 -205
@@ -1,9 +1,239 @@
1
- import React, { useState, useRef, useEffect } from 'react';
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
+ }
31
+
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
+ }
39
+
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 5px',
49
+ borderRadius: '3px',
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 ? '12px' : 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
+ };
2
125
 
3
126
  // Message type
4
127
  interface Message {
5
128
  role: 'user' | 'ai';
6
129
  content: string;
130
+ isStreaming?: boolean;
131
+ streamingData?: StreamingState;
132
+ attachedImages?: AttachedImage[];
133
+ }
134
+
135
+ // Attached image type for upload
136
+ interface AttachedImage {
137
+ id: string;
138
+ file: File;
139
+ preview: string;
140
+ base64?: string;
141
+ }
142
+
143
+ // Provider info from /story-ui/providers API
144
+ interface ProviderInfo {
145
+ type: string;
146
+ name: string;
147
+ configured: boolean;
148
+ models: string[];
149
+ }
150
+
151
+ interface ProvidersResponse {
152
+ providers: ProviderInfo[];
153
+ current: {
154
+ provider: string;
155
+ model: string;
156
+ supportsVision: boolean;
157
+ supportsStreaming: boolean;
158
+ };
159
+ }
160
+
161
+ // Streaming event types (matching backend streamTypes.ts)
162
+ type StreamEventType = 'intent' | 'progress' | 'validation' | 'retry' | 'completion' | 'error';
163
+
164
+ interface IntentPreview {
165
+ requestType: 'new' | 'modification';
166
+ framework: string;
167
+ detectedDesignSystem: string | null;
168
+ strategy: string;
169
+ estimatedComponents: string[];
170
+ promptAnalysis: {
171
+ hasVisionInput: boolean;
172
+ hasConversationContext: boolean;
173
+ hasPreviousCode: boolean;
174
+ };
175
+ }
176
+
177
+ interface ProgressUpdate {
178
+ step: number;
179
+ totalSteps: number;
180
+ phase: 'config_loaded' | 'components_discovered' | 'prompt_built' | 'llm_thinking' | 'code_extracted' | 'validating' | 'post_processing' | 'saving';
181
+ message: string;
182
+ details?: Record<string, unknown>;
183
+ }
184
+
185
+ interface ValidationFeedback {
186
+ isValid: boolean;
187
+ errors: string[];
188
+ warnings: string[];
189
+ autoFixApplied: boolean;
190
+ fixDetails?: string[];
191
+ }
192
+
193
+ interface RetryInfo {
194
+ attempt: number;
195
+ maxAttempts: number;
196
+ reason: string;
197
+ errors: string[];
198
+ }
199
+
200
+ interface CompletionFeedback {
201
+ success: boolean;
202
+ title: string;
203
+ fileName: string;
204
+ storyId: string;
205
+ summary: { action: 'created' | 'updated' | 'failed'; description: string };
206
+ componentsUsed: { name: string; reason?: string }[];
207
+ layoutChoices: { pattern: string; reason: string }[];
208
+ styleChoices: { property: string; value: string; reason?: string }[];
209
+ suggestions?: string[];
210
+ validation: ValidationFeedback;
211
+ code: string;
212
+ metrics: { totalTimeMs: number; llmCallsCount: number; tokensUsed?: number };
213
+ }
214
+
215
+ interface ErrorFeedback {
216
+ code: string;
217
+ message: string;
218
+ details?: string;
219
+ recoverable: boolean;
220
+ suggestion?: string;
221
+ }
222
+
223
+ interface StreamEvent {
224
+ type: StreamEventType;
225
+ timestamp: number;
226
+ data: IntentPreview | ProgressUpdate | ValidationFeedback | RetryInfo | CompletionFeedback | ErrorFeedback;
227
+ }
228
+
229
+ // State for tracking streaming progress
230
+ interface StreamingState {
231
+ intent?: IntentPreview;
232
+ progress?: ProgressUpdate;
233
+ validation?: ValidationFeedback;
234
+ retry?: RetryInfo;
235
+ completion?: CompletionFeedback;
236
+ error?: ErrorFeedback;
7
237
  }
8
238
 
9
239
  // Session type
@@ -15,33 +245,91 @@ interface ChatSession {
15
245
  lastUpdated: number;
16
246
  }
17
247
 
18
- // Determine the MCP API port.
19
- // 1. Check multiple possible environment variables/overrides in order of preference
20
- // 2. Check VITE_STORY_UI_PORT from environment
21
- // 3. Check window.__STORY_UI_PORT__ set by host application
22
- // 4. Otherwise fall back to the default 4001.
23
- const getApiPort = () => {
24
- // Check for Vite environment variable
248
+ // Determine the MCP API base URL.
249
+ // Priority order:
250
+ // 1. VITE_STORY_UI_EDGE_URL - Edge Worker URL for cloud deployments
251
+ // 2. window.__STORY_UI_EDGE_URL__ - Runtime override for edge URL
252
+ // 3. VITE_STORY_UI_PORT - Custom port for localhost
253
+ // 4. window.__STORY_UI_PORT__ - Legacy port override
254
+ // 5. window.STORY_UI_MCP_PORT - MCP port override
255
+ // 6. Default to localhost:4001
256
+ const getApiBaseUrl = () => {
257
+ // Check for Edge Worker URL (cloud deployment)
258
+ const edgeUrl = (import.meta as any).env?.VITE_STORY_UI_EDGE_URL;
259
+ if (edgeUrl) return edgeUrl.replace(/\/$/, ''); // Remove trailing slash
260
+
261
+ // Check for window override for edge URL (support both naming conventions)
262
+ const windowEdgeUrl = (window as any).__STORY_UI_EDGE_URL__ || (window as any).STORY_UI_EDGE_URL;
263
+ if (windowEdgeUrl) return windowEdgeUrl.replace(/\/$/, '');
264
+
265
+ // Check for Vite port environment variable
25
266
  const vitePort = (import.meta as any).env?.VITE_STORY_UI_PORT;
26
- if (vitePort) return String(vitePort);
27
-
267
+ if (vitePort) return `http://localhost:${vitePort}`;
268
+
28
269
  // Check for window override (legacy support)
29
270
  const windowOverride = (window as any).__STORY_UI_PORT__;
30
- if (windowOverride) return String(windowOverride);
31
-
271
+ if (windowOverride) return `http://localhost:${windowOverride}`;
272
+
32
273
  // Check for MCP port override set by stories file
33
274
  const mcpOverride = (window as any).STORY_UI_MCP_PORT;
34
- if (mcpOverride) return String(mcpOverride);
35
-
36
- return '4001';
275
+ if (mcpOverride) return `http://localhost:${mcpOverride}`;
276
+
277
+ return 'http://localhost:4001';
278
+ };
279
+
280
+ // Helper to check if we're using Edge mode (cloud deployment)
281
+ const isEdgeMode = () => {
282
+ const baseUrl = getApiBaseUrl();
283
+ return baseUrl.includes('workers.dev') || baseUrl.includes('pages.dev') ||
284
+ baseUrl.startsWith('https://') && !baseUrl.includes('localhost');
285
+ };
286
+
287
+ // Legacy helper for backwards compatibility
288
+ const getApiPort = () => {
289
+ const baseUrl = getApiBaseUrl();
290
+ const match = baseUrl.match(/:(\d+)$/);
291
+ return match ? match[1] : '4001';
292
+ };
293
+
294
+ // Get connection display text
295
+ const getConnectionDisplayText = () => {
296
+ const baseUrl = getApiBaseUrl();
297
+ if (isEdgeMode()) {
298
+ // Extract domain for Edge URL
299
+ try {
300
+ const url = new URL(baseUrl);
301
+ return `Edge Worker (${url.hostname})`;
302
+ } catch {
303
+ return 'Edge Worker';
304
+ }
305
+ }
306
+ return `MCP server (port ${getApiPort()})`;
37
307
  };
38
308
 
39
- const MCP_API = `http://localhost:${getApiPort()}/story-ui/generate`;
40
- const STORIES_API = `http://localhost:${getApiPort()}/story-ui/stories`;
41
- const DELETE_API_BASE = `http://localhost:${getApiPort()}/story-ui/stories`;
309
+ const API_BASE = getApiBaseUrl();
310
+ const MCP_API = `${API_BASE}/story-ui/generate`;
311
+ const MCP_STREAM_API = `${API_BASE}/story-ui/generate-stream`;
312
+ const STORIES_API = `${API_BASE}/story-ui/stories`;
313
+ const DELETE_API_BASE = `${API_BASE}/story-ui/stories`;
314
+ const PROVIDERS_API = `${API_BASE}/story-ui/providers`;
315
+ // Considerations API URL - includes storybookOrigin param for Edge mode
316
+ const getConsiderationsApiUrl = () => {
317
+ const baseUrl = `${API_BASE}/story-ui/considerations`;
318
+ if (isEdgeMode()) {
319
+ // In Edge mode, tell the Edge Worker where to fetch considerations from
320
+ // The Storybook origin is where the panel is running (window.location.origin)
321
+ const storybookOrigin = window.location.origin;
322
+ return `${baseUrl}?storybookOrigin=${encodeURIComponent(storybookOrigin)}`;
323
+ }
324
+ return baseUrl;
325
+ };
326
+ const CONSIDERATIONS_API = getConsiderationsApiUrl();
42
327
  const STORAGE_KEY = `story-ui-chats-${window.location.port}`;
43
328
  const MAX_RECENT_CHATS = 20;
44
329
 
330
+ // Feature flag: Enable streaming mode (can be toggled for testing)
331
+ const USE_STREAMING = true;
332
+
45
333
  // Load from localStorage
46
334
  const loadChats = (): ChatSession[] => {
47
335
  try {
@@ -157,47 +445,71 @@ const deleteStoryAndChat = async (chatId: string): Promise<boolean> => {
157
445
  // Remove .stories.tsx extension if present to get the actual story ID
158
446
  const storyId = chatId.replace(/\.stories\.tsx$/, '');
159
447
  console.log(`Attempting to delete story: chatId="${chatId}", storyId="${storyId}"`);
160
-
161
- // First try to delete from backend
162
- const response = await fetch(`${DELETE_API_BASE}/${storyId}`, {
163
- method: 'DELETE',
164
- headers: { 'Content-Type': 'application/json' }
165
- });
166
448
 
167
- if (!response.ok) {
168
- console.error('Failed to delete story from backend, trying legacy endpoint');
169
-
170
- // Try legacy endpoint as fallback
171
- const legacyResponse = await fetch(`http://localhost:${getApiPort()}/story-ui/delete`, {
172
- method: 'POST',
173
- headers: { 'Content-Type': 'application/json' },
174
- body: JSON.stringify({
175
- chatId: storyId,
176
- storyId: storyId
177
- })
449
+ let serverDeleteSucceeded = false;
450
+
451
+ // First try to delete from backend
452
+ try {
453
+ const response = await fetch(`${DELETE_API_BASE}/${storyId}`, {
454
+ method: 'DELETE',
455
+ headers: { 'Content-Type': 'application/json' }
178
456
  });
179
-
180
- if (!legacyResponse.ok) {
181
- console.error('Legacy delete endpoint also failed');
182
- return false;
457
+
458
+ // 404 means story doesn't exist on server - that's OK, we can still clean up localStorage
459
+ if (response.ok || response.status === 404) {
460
+ serverDeleteSucceeded = true;
461
+ if (response.status === 404) {
462
+ console.log('Story not found on server (may have been a failed generation), cleaning up localStorage');
463
+ }
464
+ } else {
465
+ console.warn(`Backend delete returned ${response.status}, trying legacy endpoint`);
183
466
  }
467
+ } catch (fetchError) {
468
+ console.warn('Backend delete request failed, trying legacy endpoint:', fetchError);
184
469
  }
185
470
 
186
- // Check if response is JSON
187
- const contentType = response.headers.get('content-type');
188
- if (!contentType || !contentType.includes('application/json')) {
189
- console.error('Server returned non-JSON response, likely server not running or wrong port');
190
- return false;
471
+ // Try legacy endpoint as fallback only if primary didn't succeed
472
+ if (!serverDeleteSucceeded) {
473
+ try {
474
+ const legacyResponse = await fetch(`${API_BASE}/story-ui/delete`, {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json' },
477
+ body: JSON.stringify({
478
+ chatId: storyId,
479
+ storyId: storyId
480
+ })
481
+ });
482
+
483
+ // 404 is also OK for legacy endpoint
484
+ if (legacyResponse.ok || legacyResponse.status === 404) {
485
+ serverDeleteSucceeded = true;
486
+ } else {
487
+ console.warn('Legacy delete endpoint also returned non-success status');
488
+ }
489
+ } catch (legacyError) {
490
+ console.warn('Legacy delete request failed:', legacyError);
491
+ }
191
492
  }
192
493
 
193
- // Then remove from local storage
494
+ // Always clean up localStorage - the chat/story data is primarily client-side
495
+ // Even if server delete failed, we should allow users to clean up their chat history
194
496
  const chats = loadChats().filter(chat => chat.id !== chatId);
195
497
  saveChats(chats);
498
+ console.log('Cleaned up localStorage chat entry');
196
499
 
197
500
  return true;
198
501
  } catch (error) {
199
502
  console.error('Error deleting story:', error);
200
- return false;
503
+ // Still try to clean up localStorage even on error
504
+ try {
505
+ const chats = loadChats().filter(chat => chat.id !== chatId);
506
+ saveChats(chats);
507
+ console.log('Cleaned up localStorage despite error');
508
+ return true;
509
+ } catch (localError) {
510
+ console.error('Failed to clean up localStorage:', localError);
511
+ return false;
512
+ }
201
513
  }
202
514
  };
203
515
 
@@ -238,9 +550,9 @@ const STYLES = {
238
550
 
239
551
  // Sidebar
240
552
  sidebar: {
241
- width: '280px',
242
- background: 'rgba(255, 255, 255, 0.05)',
243
- borderRight: '1px solid rgba(255, 255, 255, 0.1)',
553
+ width: '240px',
554
+ background: 'rgba(255, 255, 255, 0.03)',
555
+ borderRight: '1px solid rgba(255, 255, 255, 0.08)',
244
556
  display: 'flex',
245
557
  flexDirection: 'column' as const,
246
558
  backdropFilter: 'blur(10px)',
@@ -249,70 +561,76 @@ const STYLES = {
249
561
  },
250
562
 
251
563
  sidebarCollapsed: {
252
- width: '60px',
564
+ width: '56px',
253
565
  },
254
566
 
255
567
  sidebarToggle: {
256
568
  width: '100%',
257
- padding: '10px 16px',
258
- background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
569
+ padding: '8px 12px',
570
+ background: '#3b82f6',
259
571
  color: 'white',
260
572
  border: 'none',
261
- borderRadius: '8px',
262
- fontSize: '14px',
573
+ borderRadius: '6px',
574
+ fontSize: '13px',
263
575
  fontWeight: '500',
264
576
  cursor: 'pointer',
265
- marginBottom: '8px',
577
+ marginBottom: '6px',
266
578
  transition: 'all 0.2s ease',
267
- boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
579
+ boxShadow: 'none',
268
580
  display: 'flex',
269
581
  alignItems: 'center',
270
582
  justifyContent: 'center',
271
- gap: '8px',
583
+ gap: '6px',
584
+ lineHeight: '1',
272
585
  },
273
586
 
274
587
  newChatButton: {
275
588
  width: '100%',
276
- padding: '10px 16px',
277
- background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
589
+ padding: '8px 12px',
590
+ background: '#3b82f6',
278
591
  color: 'white',
279
592
  border: 'none',
280
- borderRadius: '8px',
281
- fontSize: '14px',
593
+ borderRadius: '6px',
594
+ fontSize: '13px',
282
595
  fontWeight: '500',
283
596
  cursor: 'pointer',
284
- marginBottom: '16px',
597
+ marginBottom: '12px',
285
598
  transition: 'all 0.2s ease',
286
- boxShadow: '0 2px 8px rgba(59, 130, 246, 0.2)',
599
+ boxShadow: 'none',
600
+ display: 'flex',
601
+ alignItems: 'center',
602
+ justifyContent: 'center',
603
+ gap: '6px',
604
+ lineHeight: '1',
287
605
  },
288
606
 
289
607
  chatItem: {
290
- padding: '12px 16px',
291
- marginBottom: '8px',
292
- background: 'rgba(255, 255, 255, 0.08)',
293
- borderRadius: '8px',
608
+ padding: '8px 10px',
609
+ marginBottom: '4px',
610
+ background: 'rgba(255, 255, 255, 0.05)',
611
+ borderRadius: '6px',
294
612
  cursor: 'pointer',
295
- transition: 'all 0.2s ease',
613
+ transition: 'all 0.15s ease',
296
614
  position: 'relative' as const,
297
- paddingRight: '40px',
615
+ paddingRight: '32px',
298
616
  },
299
617
 
300
618
  chatItemActive: {
301
- background: 'rgba(59, 130, 246, 0.2)',
302
- borderLeft: '3px solid #3b82f6',
619
+ background: 'rgba(59, 130, 246, 0.15)',
620
+ borderLeft: '2px solid #3b82f6',
303
621
  },
304
622
 
305
623
  chatItemTitle: {
306
- fontSize: '14px',
624
+ fontSize: '13px',
307
625
  fontWeight: '500',
308
- marginBottom: '4px',
626
+ marginBottom: '2px',
309
627
  whiteSpace: 'nowrap' as const,
310
628
  overflow: 'hidden',
311
629
  textOverflow: 'ellipsis',
312
630
  },
313
631
 
314
632
  chatItemTime: {
315
- fontSize: '12px',
633
+ fontSize: '11px',
316
634
  color: '#94a3b8',
317
635
  },
318
636
 
@@ -341,15 +659,15 @@ const STYLES = {
341
659
  },
342
660
 
343
661
  chatHeader: {
344
- padding: '20px 24px',
345
- borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
346
- background: 'rgba(255, 255, 255, 0.05)',
662
+ padding: '12px 16px',
663
+ borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
664
+ background: 'rgba(255, 255, 255, 0.03)',
347
665
  backdropFilter: 'blur(10px)',
348
666
  },
349
667
 
350
668
  chatContainer: {
351
669
  flex: 1,
352
- padding: '24px',
670
+ padding: '16px',
353
671
  overflowY: 'auto' as const,
354
672
  scrollBehavior: 'smooth' as const,
355
673
  },
@@ -376,33 +694,33 @@ const STYLES = {
376
694
  // Message bubbles
377
695
  messageContainer: {
378
696
  display: 'flex',
379
- marginBottom: '16px',
697
+ marginBottom: '10px',
380
698
  },
381
699
 
382
700
  userMessage: {
383
- background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
701
+ background: '#3b82f6',
384
702
  color: '#ffffff',
385
- borderRadius: '18px 18px 4px 18px',
386
- padding: '12px 16px',
387
- maxWidth: '80%',
703
+ borderRadius: '16px 16px 4px 16px',
704
+ padding: '10px 14px',
705
+ maxWidth: '85%',
388
706
  marginLeft: 'auto',
389
707
  fontSize: '14px',
390
708
  lineHeight: '1.5',
391
709
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
392
- boxShadow: '0 2px 12px rgba(59, 130, 246, 0.3)',
710
+ boxShadow: 'none',
393
711
  wordWrap: 'break-word' as const,
394
712
  },
395
713
 
396
714
  aiMessage: {
397
715
  background: 'rgba(255, 255, 255, 0.95)',
398
716
  color: '#1f2937',
399
- borderRadius: '18px 18px 18px 4px',
400
- padding: '12px 16px',
401
- maxWidth: '80%',
717
+ borderRadius: '16px 16px 16px 4px',
718
+ padding: '10px 14px',
719
+ maxWidth: '85%',
402
720
  fontSize: '14px',
403
721
  lineHeight: '1.5',
404
722
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
405
- boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
723
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.08)',
406
724
  wordWrap: 'break-word' as const,
407
725
  whiteSpace: 'pre-wrap' as const,
408
726
  },
@@ -410,67 +728,67 @@ const STYLES = {
410
728
  loadingMessage: {
411
729
  background: 'rgba(255, 255, 255, 0.9)',
412
730
  color: '#6b7280',
413
- borderRadius: '18px 18px 18px 4px',
414
- padding: '12px 16px',
731
+ borderRadius: '16px 16px 16px 4px',
732
+ padding: '10px 14px',
415
733
  fontSize: '14px',
416
734
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
417
735
  display: 'flex',
418
736
  alignItems: 'center',
419
- gap: '8px',
737
+ gap: '6px',
420
738
  },
421
739
 
422
740
  // Input form
423
741
  inputForm: {
424
742
  display: 'flex',
425
743
  alignItems: 'center',
426
- gap: '12px',
427
- margin: '0 24px 24px 24px',
428
- padding: '16px',
429
- background: 'rgba(255, 255, 255, 0.05)',
430
- borderRadius: '12px',
431
- border: '1px solid rgba(255, 255, 255, 0.1)',
744
+ gap: '10px',
745
+ margin: '0 16px 16px 16px',
746
+ padding: '10px',
747
+ background: 'rgba(255, 255, 255, 0.03)',
748
+ borderRadius: '10px',
749
+ border: '1px solid rgba(255, 255, 255, 0.08)',
432
750
  backdropFilter: 'blur(10px)',
433
751
  },
434
752
 
435
753
  textInput: {
436
754
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
437
755
  flex: 1,
438
- padding: '12px 16px',
439
- borderRadius: '8px',
440
- border: '1px solid rgba(255, 255, 255, 0.2)',
441
- fontSize: '14px',
756
+ padding: '10px 14px',
757
+ borderRadius: '6px',
758
+ border: '1px solid rgba(255, 255, 255, 0.15)',
759
+ fontSize: '13px',
442
760
  color: '#1f2937',
443
761
  background: '#ffffff',
444
762
  outline: 'none',
445
- transition: 'all 0.2s ease',
763
+ transition: 'all 0.15s ease',
446
764
  boxSizing: 'border-box' as const,
447
765
  },
448
766
 
449
767
  sendButton: {
450
768
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
451
- padding: '12px 20px',
452
- borderRadius: '8px',
769
+ padding: '10px 16px',
770
+ borderRadius: '6px',
453
771
  border: 'none',
454
- background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
772
+ background: '#10b981',
455
773
  color: '#ffffff',
456
- fontSize: '14px',
774
+ fontSize: '13px',
457
775
  fontWeight: '500',
458
776
  cursor: 'pointer',
459
777
  display: 'flex',
460
778
  alignItems: 'center',
461
- gap: '6px',
462
- transition: 'all 0.2s ease',
463
- boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
779
+ gap: '5px',
780
+ transition: 'all 0.15s ease',
781
+ boxShadow: 'none',
464
782
  },
465
783
 
466
784
  errorMessage: {
467
785
  background: 'rgba(248, 113, 113, 0.1)',
468
786
  color: '#f87171',
469
- padding: '12px 16px',
470
- borderRadius: '8px',
471
- fontSize: '14px',
472
- marginBottom: '16px',
473
- border: '1px solid rgba(248, 113, 113, 0.3)',
787
+ padding: '8px 12px',
788
+ borderRadius: '6px',
789
+ fontSize: '13px',
790
+ marginBottom: '10px',
791
+ border: '1px solid rgba(248, 113, 113, 0.2)',
474
792
  },
475
793
 
476
794
  loadingDots: {
@@ -487,299 +805,1376 @@ const STYLES = {
487
805
 
488
806
  codeBlock: {
489
807
  background: '#1e293b',
490
- padding: '12px 16px',
491
- borderRadius: '8px',
808
+ padding: '10px 12px',
809
+ borderRadius: '6px',
492
810
  fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
493
- fontSize: '13px',
494
- lineHeight: '1.6',
811
+ fontSize: '12px',
812
+ lineHeight: '1.5',
495
813
  overflowX: 'auto' as const,
496
- marginTop: '8px',
497
- border: '1px solid rgba(255, 255, 255, 0.1)',
814
+ marginTop: '6px',
815
+ border: '1px solid rgba(255, 255, 255, 0.08)',
498
816
  },
499
- };
500
817
 
501
- // Add custom style for loading animation
502
- const styleSheet = document.createElement('style');
503
- styleSheet.textContent = `
504
- @keyframes loadingDots {
505
- 0%, 20% { content: "."; }
506
- 40% { content: ".."; }
507
- 60%, 100% { content: "..."; }
508
- }
818
+ // Streaming progress styles
819
+ streamingContainer: {
820
+ background: 'rgba(255, 255, 255, 0.95)',
821
+ borderRadius: '16px 16px 16px 4px',
822
+ padding: '12px',
823
+ maxWidth: '85%',
824
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.08)',
825
+ },
509
826
 
510
- .loading-dots::after {
511
- content: ".";
512
- animation: loadingDots 1.4s infinite;
513
- }
514
- `;
515
- document.head.appendChild(styleSheet);
827
+ intentPreview: {
828
+ background: 'rgba(59, 130, 246, 0.08)',
829
+ borderRadius: '8px',
830
+ padding: '10px',
831
+ marginBottom: '10px',
832
+ border: '1px solid rgba(59, 130, 246, 0.15)',
833
+ },
516
834
 
517
- // Helper function to format timestamp
518
- const formatTime = (timestamp: number): string => {
519
- const date = new Date(timestamp);
520
- const now = new Date();
521
- const diffMs = now.getTime() - date.getTime();
522
- const diffMins = Math.floor(diffMs / 60000);
523
- const diffHours = Math.floor(diffMs / 3600000);
524
- const diffDays = Math.floor(diffMs / 86400000);
835
+ intentTitle: {
836
+ fontSize: '13px',
837
+ fontWeight: '600',
838
+ color: '#1e40af',
839
+ marginBottom: '6px',
840
+ display: 'flex',
841
+ alignItems: 'center',
842
+ gap: '5px',
843
+ },
525
844
 
526
- if (diffMins < 1) return 'just now';
527
- if (diffMins < 60) return `${diffMins}m ago`;
528
- if (diffHours < 24) return `${diffHours}h ago`;
529
- if (diffDays < 7) return `${diffDays}d ago`;
530
- return date.toLocaleDateString();
531
- };
845
+ intentStrategy: {
846
+ fontSize: '12px',
847
+ color: '#4b5563',
848
+ marginBottom: '4px',
849
+ },
532
850
 
533
- // Main component
534
- export function StoryUIPanel() {
535
- const [input, setInput] = useState('');
536
- const [conversation, setConversation] = useState<Message[]>([]);
537
- const [loading, setLoading] = useState(false);
538
- const [error, setError] = useState<string | null>(null);
539
- const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
540
- const [activeChatId, setActiveChatId] = useState<string | null>(null);
541
- const [activeTitle, setActiveTitle] = useState<string>('');
542
- const [sidebarOpen, setSidebarOpen] = useState(true);
543
- const [connectionStatus, setConnectionStatus] = useState<{ connected: boolean; error?: string }>({ connected: false });
544
- const chatEndRef = useRef<HTMLDivElement | null>(null);
545
- const inputRef = useRef<HTMLInputElement | null>(null);
851
+ intentComponents: {
852
+ display: 'flex',
853
+ flexWrap: 'wrap' as const,
854
+ gap: '4px',
855
+ marginTop: '6px',
856
+ },
546
857
 
547
- // Load and sync chats on mount
548
- useEffect(() => {
549
- const initializeChats = async () => {
550
- // Test connection first
551
- const connectionTest = await testMCPConnection();
552
- setConnectionStatus(connectionTest);
553
-
554
- if (connectionTest.connected) {
555
- const syncedChats = await syncWithActualStories();
556
- const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
557
- setRecentChats(sortedChats);
858
+ componentTag: {
859
+ background: 'rgba(59, 130, 246, 0.12)',
860
+ color: '#1d4ed8',
861
+ fontSize: '10px',
862
+ padding: '2px 6px',
863
+ borderRadius: '10px',
864
+ fontWeight: '500',
865
+ },
558
866
 
559
- if (sortedChats.length > 0) {
560
- setConversation(sortedChats[0].conversation);
561
- setActiveChatId(sortedChats[0].id);
562
- setActiveTitle(sortedChats[0].title);
563
- }
564
- } else {
565
- // Load from local storage if server is not available
566
- const localChats = loadChats();
567
- setRecentChats(localChats);
568
- }
569
- };
867
+ progressBar: {
868
+ background: 'rgba(0, 0, 0, 0.08)',
869
+ borderRadius: '3px',
870
+ height: '4px',
871
+ marginTop: '10px',
872
+ marginBottom: '6px',
873
+ overflow: 'hidden',
874
+ },
570
875
 
571
- initializeChats();
572
- }, []);
876
+ progressFill: {
877
+ background: '#3b82f6',
878
+ height: '100%',
879
+ borderRadius: '3px',
880
+ transition: 'width 0.3s ease',
881
+ },
573
882
 
574
- // Scroll to bottom on new message
575
- useEffect(() => {
576
- if (chatEndRef.current) {
577
- chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
578
- }
579
- }, [conversation, loading]);
883
+ progressPhase: {
884
+ fontSize: '11px',
885
+ color: '#6b7280',
886
+ display: 'flex',
887
+ alignItems: 'center',
888
+ gap: '5px',
889
+ },
580
890
 
581
- const handleSend = async (e?: React.FormEvent) => {
582
- if (e) e.preventDefault();
583
- if (!input.trim()) return;
584
- setError(null);
585
- setLoading(true);
586
-
587
- // Test connection before sending
588
- const connectionTest = await testMCPConnection();
589
- setConnectionStatus(connectionTest);
590
-
591
- if (!connectionTest.connected) {
592
- setError(`Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}`);
593
- setLoading(false);
594
- return;
595
- }
596
-
597
- const newConversation = [...conversation, { role: 'user' as const, content: input }];
598
- setConversation(newConversation);
599
- setInput('');
891
+ phaseIcon: {
892
+ fontSize: '12px',
893
+ },
894
+
895
+ validationBox: {
896
+ marginTop: '8px',
897
+ padding: '8px',
898
+ borderRadius: '6px',
899
+ fontSize: '11px',
900
+ },
901
+
902
+ validationSuccess: {
903
+ background: 'rgba(16, 185, 129, 0.08)',
904
+ border: '1px solid rgba(16, 185, 129, 0.15)',
905
+ color: '#047857',
906
+ },
907
+
908
+ validationWarning: {
909
+ background: 'rgba(245, 158, 11, 0.08)',
910
+ border: '1px solid rgba(245, 158, 11, 0.15)',
911
+ color: '#b45309',
912
+ },
913
+
914
+ validationError: {
915
+ background: 'rgba(239, 68, 68, 0.08)',
916
+ border: '1px solid rgba(239, 68, 68, 0.15)',
917
+ color: '#dc2626',
918
+ },
919
+
920
+ retryBadge: {
921
+ background: 'rgba(245, 158, 11, 0.12)',
922
+ color: '#b45309',
923
+ fontSize: '10px',
924
+ padding: '2px 8px',
925
+ borderRadius: '10px',
926
+ display: 'inline-flex',
927
+ alignItems: 'center',
928
+ gap: '3px',
929
+ marginTop: '6px',
930
+ },
931
+
932
+ completionSummary: {
933
+ marginTop: '10px',
934
+ paddingTop: '10px',
935
+ borderTop: '1px solid rgba(0, 0, 0, 0.08)',
936
+ },
937
+
938
+ summaryTitle: {
939
+ fontSize: '14px',
940
+ fontWeight: '600',
941
+ color: '#111827',
942
+ marginBottom: '6px',
943
+ display: 'flex',
944
+ alignItems: 'center',
945
+ gap: '6px',
946
+ },
947
+
948
+ summaryDescription: {
949
+ fontSize: '12px',
950
+ color: '#4b5563',
951
+ lineHeight: '1.5',
952
+ },
953
+
954
+ metricsRow: {
955
+ display: 'flex',
956
+ gap: '12px',
957
+ marginTop: '8px',
958
+ fontSize: '10px',
959
+ color: '#6b7280',
960
+ },
961
+
962
+ metric: {
963
+ display: 'flex',
964
+ alignItems: 'center',
965
+ gap: '3px',
966
+ },
967
+
968
+ // Code viewer styles for generated stories
969
+ codeViewerContainer: {
970
+ marginTop: '12px',
971
+ borderTop: '1px solid rgba(0, 0, 0, 0.08)',
972
+ paddingTop: '12px',
973
+ },
974
+
975
+ codeViewerToggle: {
976
+ display: 'flex',
977
+ alignItems: 'center',
978
+ justifyContent: 'space-between',
979
+ padding: '8px 12px',
980
+ background: 'rgba(59, 130, 246, 0.08)',
981
+ borderRadius: '6px',
982
+ cursor: 'pointer',
983
+ border: '1px solid rgba(59, 130, 246, 0.15)',
984
+ transition: 'all 0.2s ease',
985
+ fontSize: '13px',
986
+ fontWeight: '500',
987
+ color: '#1e40af',
988
+ },
989
+
990
+ codeViewerToggleHover: {
991
+ background: 'rgba(59, 130, 246, 0.15)',
992
+ },
993
+
994
+ codeViewerContent: {
995
+ marginTop: '10px',
996
+ background: '#1e293b',
997
+ borderRadius: '8px',
998
+ overflow: 'hidden',
999
+ border: '1px solid rgba(255, 255, 255, 0.08)',
1000
+ },
1001
+
1002
+ codeViewerHeader: {
1003
+ display: 'flex',
1004
+ alignItems: 'center',
1005
+ justifyContent: 'space-between',
1006
+ padding: '8px 12px',
1007
+ background: 'rgba(0, 0, 0, 0.2)',
1008
+ borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
1009
+ },
1010
+
1011
+ codeViewerFileName: {
1012
+ fontSize: '12px',
1013
+ color: '#94a3b8',
1014
+ fontFamily: 'ui-monospace, monospace',
1015
+ },
1016
+
1017
+ copyButton: {
1018
+ padding: '4px 10px',
1019
+ fontSize: '11px',
1020
+ fontWeight: '500',
1021
+ color: '#e2e8f0',
1022
+ background: 'rgba(59, 130, 246, 0.3)',
1023
+ border: '1px solid rgba(59, 130, 246, 0.5)',
1024
+ borderRadius: '4px',
1025
+ cursor: 'pointer',
1026
+ transition: 'all 0.2s ease',
1027
+ },
1028
+
1029
+ copyButtonSuccess: {
1030
+ background: 'rgba(34, 197, 94, 0.3)',
1031
+ borderColor: 'rgba(34, 197, 94, 0.5)',
1032
+ color: '#86efac',
1033
+ },
1034
+
1035
+ codeViewerPre: {
1036
+ margin: 0,
1037
+ padding: '12px',
1038
+ fontSize: '11px',
1039
+ lineHeight: '1.5',
1040
+ fontFamily: 'ui-monospace, Consolas, Monaco, monospace',
1041
+ color: '#e2e8f0',
1042
+ overflowX: 'auto' as const,
1043
+ maxHeight: '400px',
1044
+ overflowY: 'auto' as const,
1045
+ },
1046
+
1047
+ // Image upload styles
1048
+ uploadButton: {
1049
+ display: 'flex',
1050
+ alignItems: 'center',
1051
+ justifyContent: 'center',
1052
+ width: '36px',
1053
+ height: '36px',
1054
+ borderRadius: '6px',
1055
+ border: '1px solid rgba(255, 255, 255, 0.15)',
1056
+ background: 'rgba(255, 255, 255, 0.08)',
1057
+ color: '#e2e8f0',
1058
+ cursor: 'pointer',
1059
+ transition: 'all 0.2s ease',
1060
+ flexShrink: 0,
1061
+ },
1062
+
1063
+ uploadButtonHover: {
1064
+ background: 'rgba(59, 130, 246, 0.2)',
1065
+ borderColor: 'rgba(59, 130, 246, 0.5)',
1066
+ },
1067
+
1068
+ imagePreviewContainer: {
1069
+ display: 'flex',
1070
+ flexWrap: 'wrap' as const,
1071
+ gap: '6px',
1072
+ padding: '8px 12px',
1073
+ background: 'rgba(255, 255, 255, 0.03)',
1074
+ borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
1075
+ margin: '0 16px',
1076
+ borderRadius: '6px 6px 0 0',
1077
+ },
1078
+
1079
+ imagePreviewItem: {
1080
+ position: 'relative' as const,
1081
+ width: '56px',
1082
+ height: '56px',
1083
+ borderRadius: '6px',
1084
+ overflow: 'hidden',
1085
+ border: '1px solid rgba(255, 255, 255, 0.15)',
1086
+ background: '#1e293b',
1087
+ },
1088
+
1089
+ imagePreviewImg: {
1090
+ width: '100%',
1091
+ height: '100%',
1092
+ objectFit: 'cover' as const,
1093
+ },
1094
+
1095
+ imageRemoveButton: {
1096
+ position: 'absolute' as const,
1097
+ top: '2px',
1098
+ right: '2px',
1099
+ width: '18px',
1100
+ height: '18px',
1101
+ borderRadius: '50%',
1102
+ background: 'rgba(239, 68, 68, 0.9)',
1103
+ color: 'white',
1104
+ border: 'none',
1105
+ fontSize: '12px',
1106
+ cursor: 'pointer',
1107
+ display: 'flex',
1108
+ alignItems: 'center',
1109
+ justifyContent: 'center',
1110
+ lineHeight: 1,
1111
+ },
1112
+
1113
+ imagePreviewLabel: {
1114
+ display: 'flex',
1115
+ alignItems: 'center',
1116
+ gap: '6px',
1117
+ fontSize: '12px',
1118
+ color: '#94a3b8',
1119
+ marginRight: 'auto',
1120
+ },
1121
+
1122
+ userMessageImages: {
1123
+ display: 'flex',
1124
+ gap: '6px',
1125
+ marginTop: '6px',
1126
+ flexWrap: 'wrap' as const,
1127
+ },
1128
+
1129
+ userMessageImage: {
1130
+ width: '40px',
1131
+ height: '40px',
1132
+ borderRadius: '6px',
1133
+ objectFit: 'cover' as const,
1134
+ border: '1px solid rgba(255, 255, 255, 0.25)',
1135
+ },
1136
+
1137
+ // Drag and drop overlay
1138
+ dropOverlay: {
1139
+ position: 'absolute' as const,
1140
+ top: 0,
1141
+ left: 0,
1142
+ right: 0,
1143
+ bottom: 0,
1144
+ background: 'rgba(59, 130, 246, 0.12)',
1145
+ border: '2px dashed rgba(59, 130, 246, 0.4)',
1146
+ borderRadius: '10px',
1147
+ display: 'flex',
1148
+ alignItems: 'center',
1149
+ justifyContent: 'center',
1150
+ zIndex: 100,
1151
+ backdropFilter: 'blur(3px)',
1152
+ },
1153
+
1154
+ dropOverlayText: {
1155
+ background: 'rgba(59, 130, 246, 0.85)',
1156
+ color: 'white',
1157
+ padding: '12px 24px',
1158
+ borderRadius: '8px',
1159
+ fontSize: '14px',
1160
+ fontWeight: '500',
1161
+ display: 'flex',
1162
+ alignItems: 'center',
1163
+ gap: '8px',
1164
+ boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
1165
+ },
1166
+ };
1167
+
1168
+ // Add custom style for loading animation
1169
+ const styleSheet = document.createElement('style');
1170
+ styleSheet.textContent = `
1171
+ @keyframes loadingDots {
1172
+ 0%, 20% { content: "."; }
1173
+ 40% { content: ".."; }
1174
+ 60%, 100% { content: "..."; }
1175
+ }
1176
+
1177
+ .loading-dots::after {
1178
+ content: ".";
1179
+ animation: loadingDots 1.4s infinite;
1180
+ }
1181
+ `;
1182
+ document.head.appendChild(styleSheet);
1183
+
1184
+ // Helper function to format timestamp
1185
+ const formatTime = (timestamp: number): string => {
1186
+ const date = new Date(timestamp);
1187
+ const now = new Date();
1188
+ const diffMs = now.getTime() - date.getTime();
1189
+ const diffMins = Math.floor(diffMs / 60000);
1190
+ const diffHours = Math.floor(diffMs / 3600000);
1191
+ const diffDays = Math.floor(diffMs / 86400000);
1192
+
1193
+ if (diffMins < 1) return 'just now';
1194
+ if (diffMins < 60) return `${diffMins}m ago`;
1195
+ if (diffHours < 24) return `${diffHours}h ago`;
1196
+ if (diffDays < 7) return `${diffDays}d ago`;
1197
+ return date.toLocaleDateString();
1198
+ };
1199
+
1200
+ // Helper to get phase icon and text
1201
+ const getPhaseInfo = (phase: ProgressUpdate['phase']): { icon: string; text: string } => {
1202
+ const phases: Record<ProgressUpdate['phase'], { icon: string; text: string }> = {
1203
+ config_loaded: { icon: '⚙️', text: 'Loading configuration' },
1204
+ components_discovered: { icon: '🔍', text: 'Discovering components' },
1205
+ prompt_built: { icon: '📝', text: 'Building prompt' },
1206
+ llm_thinking: { icon: '🤔', text: 'AI is thinking' },
1207
+ code_extracted: { icon: '📦', text: 'Extracting code' },
1208
+ validating: { icon: '✅', text: 'Validating output' },
1209
+ post_processing: { icon: '🔧', text: 'Processing' },
1210
+ saving: { icon: '💾', text: 'Saving story' },
1211
+ };
1212
+ return phases[phase] || { icon: '⏳', text: 'Working' };
1213
+ };
1214
+
1215
+ // Streaming Progress Message Component
1216
+ const StreamingProgressMessage: React.FC<{ streamingData: StreamingState }> = ({ streamingData }) => {
1217
+ const { intent, progress, validation, retry, completion, error } = streamingData;
1218
+ const [showCode, setShowCode] = useState(true); // Show code by default for better UX
1219
+ const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle');
1220
+
1221
+ // Handle copy to clipboard
1222
+ const handleCopyCode = async (code: string) => {
600
1223
  try {
601
- const res = await fetch(MCP_API, {
602
- method: 'POST',
603
- headers: { 'Content-Type': 'application/json' },
604
- body: JSON.stringify({
605
- prompt: input,
606
- conversation: newConversation,
607
- fileName: activeChatId || undefined,
608
- }),
609
- });
610
-
611
- // Check if response is JSON
612
- const contentType = res.headers.get('content-type');
613
- if (!contentType || !contentType.includes('application/json')) {
614
- const text = await res.text();
615
- throw new Error(`Server returned non-JSON response (likely server not running or wrong port). Response: ${text.substring(0, 200)}...`);
1224
+ await navigator.clipboard.writeText(code);
1225
+ setCopyStatus('copied');
1226
+ setTimeout(() => setCopyStatus('idle'), 2000);
1227
+ } catch (err) {
1228
+ console.error('Failed to copy:', err);
1229
+ }
1230
+ };
1231
+
1232
+ // If completed, show completion summary
1233
+ if (completion) {
1234
+ return (
1235
+ <div style={STYLES.streamingContainer}>
1236
+ <div style={STYLES.completionSummary}>
1237
+ <div style={STYLES.summaryTitle}>
1238
+ {completion.success ? '✅' : '❌'} {completion.title}
1239
+ </div>
1240
+ <div style={STYLES.summaryDescription}>
1241
+ {completion.summary.description}
1242
+ </div>
1243
+
1244
+ {/* Components Used */}
1245
+ {completion.componentsUsed.length > 0 && (
1246
+ <div style={{ marginTop: '10px' }}>
1247
+ <div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '6px' }}>Components used:</div>
1248
+ <div style={STYLES.intentComponents}>
1249
+ {completion.componentsUsed.map((comp, i) => (
1250
+ <span key={i} style={STYLES.componentTag}>{comp.name}</span>
1251
+ ))}
1252
+ </div>
1253
+ </div>
1254
+ )}
1255
+
1256
+ {/* Layout Choices */}
1257
+ {completion.layoutChoices.length > 0 && (
1258
+ <div style={{ marginTop: '10px' }}>
1259
+ <div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '6px' }}>Layout:</div>
1260
+ <div style={{ fontSize: '12px', color: '#4b5563' }}>
1261
+ {completion.layoutChoices.map(l => l.pattern).join(', ')}
1262
+ </div>
1263
+ </div>
1264
+ )}
1265
+
1266
+ {/* Validation Status */}
1267
+ {completion.validation && !completion.validation.isValid && (
1268
+ <div style={{ ...STYLES.validationBox, ...STYLES.validationWarning }}>
1269
+ ⚠️ {completion.validation.autoFixApplied ? 'Auto-fixed issues' : 'Minor issues detected'}
1270
+ </div>
1271
+ )}
1272
+
1273
+ {/* Suggestions */}
1274
+ {completion.suggestions && completion.suggestions.length > 0 && (
1275
+ <div style={{ marginTop: '10px', fontSize: '12px', color: '#6b7280' }}>
1276
+ 💡 {completion.suggestions[0]}
1277
+ </div>
1278
+ )}
1279
+
1280
+ {/* Metrics */}
1281
+ {completion.metrics && (
1282
+ <div style={STYLES.metricsRow}>
1283
+ <span style={STYLES.metric}>⏱️ {(completion.metrics.totalTimeMs / 1000).toFixed(1)}s</span>
1284
+ <span style={STYLES.metric}>🔄 {completion.metrics.llmCallsCount} LLM calls</span>
1285
+ </div>
1286
+ )}
1287
+
1288
+ {/* Code Viewer - Show the generated story code */}
1289
+ {completion.code && (
1290
+ <div style={STYLES.codeViewerContainer}>
1291
+ <div
1292
+ style={STYLES.codeViewerToggle}
1293
+ onClick={() => setShowCode(!showCode)}
1294
+ role="button"
1295
+ tabIndex={0}
1296
+ onKeyDown={(e) => e.key === 'Enter' && setShowCode(!showCode)}
1297
+ >
1298
+ <span>{showCode ? '▼' : '▶'} View Generated Code</span>
1299
+ <span style={{ fontSize: '11px', color: '#6366f1' }}>{completion.fileName}</span>
1300
+ </div>
1301
+ {showCode && (
1302
+ <div style={STYLES.codeViewerContent}>
1303
+ <div style={STYLES.codeViewerHeader}>
1304
+ <span style={STYLES.codeViewerFileName}>{completion.fileName}</span>
1305
+ <button
1306
+ style={{
1307
+ ...STYLES.copyButton,
1308
+ ...(copyStatus === 'copied' ? STYLES.copyButtonSuccess : {})
1309
+ }}
1310
+ onClick={() => handleCopyCode(completion.code)}
1311
+ >
1312
+ {copyStatus === 'copied' ? '✓ Copied!' : 'Copy Code'}
1313
+ </button>
1314
+ </div>
1315
+ <pre style={STYLES.codeViewerPre}>
1316
+ <code>{completion.code}</code>
1317
+ </pre>
1318
+ </div>
1319
+ )}
1320
+ </div>
1321
+ )}
1322
+ </div>
1323
+ </div>
1324
+ );
1325
+ }
1326
+
1327
+ // If error, show error
1328
+ if (error) {
1329
+ return (
1330
+ <div style={STYLES.streamingContainer}>
1331
+ <div style={{ ...STYLES.validationBox, ...STYLES.validationError }}>
1332
+ <strong>❌ {error.message}</strong>
1333
+ {error.details && <div style={{ marginTop: '4px' }}>{error.details}</div>}
1334
+ {error.suggestion && <div style={{ marginTop: '8px' }}>💡 {error.suggestion}</div>}
1335
+ </div>
1336
+ </div>
1337
+ );
1338
+ }
1339
+
1340
+ // Show progress
1341
+ return (
1342
+ <div style={STYLES.streamingContainer}>
1343
+ {/* Intent Preview */}
1344
+ {intent && (
1345
+ <div style={STYLES.intentPreview}>
1346
+ <div style={STYLES.intentTitle}>
1347
+ {intent.requestType === 'modification' ? '✏️' : '✨'}
1348
+ {intent.requestType === 'modification' ? ' Modifying Story' : ' Creating New Story'}
1349
+ </div>
1350
+ <div style={STYLES.intentStrategy}>{intent.strategy}</div>
1351
+ {intent.detectedDesignSystem && (
1352
+ <div style={{ fontSize: '12px', color: '#6b7280' }}>
1353
+ Design system: <strong>{intent.detectedDesignSystem}</strong>
1354
+ </div>
1355
+ )}
1356
+ {intent.estimatedComponents.length > 0 && (
1357
+ <div style={STYLES.intentComponents}>
1358
+ {intent.estimatedComponents.map((comp, i) => (
1359
+ <span key={i} style={STYLES.componentTag}>{comp}</span>
1360
+ ))}
1361
+ </div>
1362
+ )}
1363
+ </div>
1364
+ )}
1365
+
1366
+ {/* Progress Bar */}
1367
+ {progress && (
1368
+ <>
1369
+ <div style={STYLES.progressBar}>
1370
+ <div
1371
+ style={{
1372
+ ...STYLES.progressFill,
1373
+ width: `${(progress.step / progress.totalSteps) * 100}%`
1374
+ }}
1375
+ />
1376
+ </div>
1377
+ <div style={STYLES.progressPhase}>
1378
+ <span style={STYLES.phaseIcon}>{getPhaseInfo(progress.phase).icon}</span>
1379
+ <span>{progress.message || getPhaseInfo(progress.phase).text}</span>
1380
+ <span style={{ marginLeft: 'auto', color: '#9ca3af' }}>
1381
+ {progress.step}/{progress.totalSteps}
1382
+ </span>
1383
+ </div>
1384
+ </>
1385
+ )}
1386
+
1387
+ {/* Retry Badge */}
1388
+ {retry && (
1389
+ <div style={STYLES.retryBadge}>
1390
+ 🔄 Retry {retry.attempt}/{retry.maxAttempts}: {retry.reason}
1391
+ </div>
1392
+ )}
1393
+
1394
+ {/* Validation Feedback */}
1395
+ {validation && !validation.isValid && (
1396
+ <div style={{ ...STYLES.validationBox, ...STYLES.validationWarning }}>
1397
+ {validation.autoFixApplied ? '🔧 Auto-fixing issues...' : '⚠️ Validation issues found'}
1398
+ {validation.errors.slice(0, 2).map((err, i) => (
1399
+ <div key={i} style={{ marginTop: '4px', fontSize: '11px' }}>• {err}</div>
1400
+ ))}
1401
+ </div>
1402
+ )}
1403
+
1404
+ {/* Loading indicator when no specific phase */}
1405
+ {!progress && !intent && (
1406
+ <div style={STYLES.progressPhase}>
1407
+ <span className="loading-dots">Connecting</span>
1408
+ </div>
1409
+ )}
1410
+ </div>
1411
+ );
1412
+ };
1413
+
1414
+ // Main component
1415
+ function StoryUIPanel() {
1416
+ const [input, setInput] = useState('');
1417
+ const [conversation, setConversation] = useState<Message[]>([]);
1418
+ const [loading, setLoading] = useState(false);
1419
+ const [error, setError] = useState<string | null>(null);
1420
+ const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
1421
+ const [activeChatId, setActiveChatId] = useState<string | null>(null);
1422
+ const [activeTitle, setActiveTitle] = useState<string>('');
1423
+ const [sidebarOpen, setSidebarOpen] = useState(true);
1424
+ const [connectionStatus, setConnectionStatus] = useState<{ connected: boolean; error?: string }>({ connected: false });
1425
+ const [availableProviders, setAvailableProviders] = useState<ProviderInfo[]>([]);
1426
+ const [selectedProvider, setSelectedProvider] = useState<string>('');
1427
+ const [selectedModel, setSelectedModel] = useState<string>('');
1428
+ const [streamingState, setStreamingState] = useState<StreamingState | null>(null);
1429
+ const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
1430
+ const [considerations, setConsiderations] = useState<string>('');
1431
+ const chatEndRef = useRef<HTMLDivElement | null>(null);
1432
+ const inputRef = useRef<HTMLInputElement | null>(null);
1433
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
1434
+ const abortControllerRef = useRef<AbortController | null>(null);
1435
+
1436
+ // Maximum images allowed
1437
+ const MAX_IMAGES = 4;
1438
+ const MAX_IMAGE_SIZE_MB = 20;
1439
+
1440
+ // Helper to convert file to base64
1441
+ const fileToBase64 = (file: File): Promise<string> => {
1442
+ return new Promise((resolve, reject) => {
1443
+ const reader = new FileReader();
1444
+ reader.readAsDataURL(file);
1445
+ reader.onload = () => {
1446
+ const result = reader.result as string;
1447
+ // Extract base64 data (remove data:image/...;base64, prefix)
1448
+ const base64 = result.split(',')[1];
1449
+ resolve(base64);
1450
+ };
1451
+ reader.onerror = error => reject(error);
1452
+ });
1453
+ };
1454
+
1455
+ // Handle file selection
1456
+ const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
1457
+ const files = e.target.files;
1458
+ if (!files) return;
1459
+
1460
+ const newImages: AttachedImage[] = [];
1461
+ const errors: string[] = [];
1462
+
1463
+ for (let i = 0; i < files.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
1464
+ const file = files[i];
1465
+
1466
+ // Validate file type
1467
+ if (!file.type.startsWith('image/')) {
1468
+ errors.push(`${file.name}: Not an image file`);
1469
+ continue;
616
1470
  }
617
-
618
- const data = await res.json();
619
- if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
620
1471
 
621
- // Create user-friendly response message instead of showing raw markup
622
- let responseMessage: string;
623
- let statusIcon = '✅';
1472
+ // Validate file size
1473
+ if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1474
+ errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1475
+ continue;
1476
+ }
624
1477
 
625
- // Check for validation issues
626
- if (data.validation && data.validation.hasWarnings) {
627
- statusIcon = '⚠️';
628
- const warningCount = data.validation.warnings.length;
629
- const errorCount = data.validation.errors.length;
1478
+ try {
1479
+ const base64 = await fileToBase64(file);
1480
+ const preview = URL.createObjectURL(file);
1481
+
1482
+ newImages.push({
1483
+ id: `${Date.now()}-${i}`,
1484
+ file,
1485
+ preview,
1486
+ base64,
1487
+ });
1488
+ } catch (err) {
1489
+ errors.push(`${file.name}: Failed to process`);
1490
+ }
1491
+ }
630
1492
 
631
- if (errorCount > 0) {
632
- statusIcon = '🔧';
633
- }
1493
+ if (errors.length > 0) {
1494
+ setError(errors.join('\n'));
1495
+ }
1496
+
1497
+ setAttachedImages(prev => [...prev, ...newImages]);
1498
+
1499
+ // Reset file input
1500
+ if (fileInputRef.current) {
1501
+ fileInputRef.current.value = '';
1502
+ }
1503
+ };
1504
+
1505
+ // Remove attached image
1506
+ const removeAttachedImage = (id: string) => {
1507
+ setAttachedImages(prev => {
1508
+ const removed = prev.find(img => img.id === id);
1509
+ if (removed) {
1510
+ URL.revokeObjectURL(removed.preview);
634
1511
  }
1512
+ return prev.filter(img => img.id !== id);
1513
+ });
1514
+ };
635
1515
 
636
- if (data.isUpdate) {
637
- responseMessage = `${statusIcon} 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.`;
638
- } else {
639
- responseMessage = `${statusIcon} 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.`;
1516
+ // Clear all attached images
1517
+ const clearAttachedImages = () => {
1518
+ attachedImages.forEach(img => URL.revokeObjectURL(img.preview));
1519
+ setAttachedImages([]);
1520
+ };
1521
+
1522
+ // Drag and drop state
1523
+ const [isDragging, setIsDragging] = useState(false);
1524
+
1525
+ // Handle drag events
1526
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
1527
+ e.preventDefault();
1528
+ e.stopPropagation();
1529
+ if (e.dataTransfer.types.includes('Files')) {
1530
+ setIsDragging(true);
1531
+ }
1532
+ }, []);
1533
+
1534
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
1535
+ e.preventDefault();
1536
+ e.stopPropagation();
1537
+ // Only set to false if we're leaving the main container
1538
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
1539
+ setIsDragging(false);
1540
+ }
1541
+ }, []);
1542
+
1543
+ const handleDragOver = useCallback((e: React.DragEvent) => {
1544
+ e.preventDefault();
1545
+ e.stopPropagation();
1546
+ }, []);
1547
+
1548
+ const handleDrop = useCallback(async (e: React.DragEvent) => {
1549
+ e.preventDefault();
1550
+ e.stopPropagation();
1551
+ setIsDragging(false);
1552
+
1553
+ const files = Array.from(e.dataTransfer.files);
1554
+ const imageFiles = files.filter(f => f.type.startsWith('image/'));
1555
+
1556
+ if (imageFiles.length === 0) {
1557
+ setError('Please drop image files only');
1558
+ return;
1559
+ }
1560
+
1561
+ const newImages: AttachedImage[] = [];
1562
+ const errors: string[] = [];
1563
+
1564
+ for (let i = 0; i < imageFiles.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
1565
+ const file = imageFiles[i];
1566
+
1567
+ if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1568
+ errors.push(`${file.name}: File too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1569
+ continue;
1570
+ }
1571
+
1572
+ try {
1573
+ const base64 = await fileToBase64(file);
1574
+ const preview = URL.createObjectURL(file);
1575
+
1576
+ newImages.push({
1577
+ id: `${Date.now()}-${i}`,
1578
+ file,
1579
+ preview,
1580
+ base64,
1581
+ });
1582
+ } catch (err) {
1583
+ errors.push(`${file.name}: Failed to process`);
1584
+ }
1585
+ }
1586
+
1587
+ if (errors.length > 0) {
1588
+ setError(errors.join('\n'));
1589
+ }
1590
+
1591
+ setAttachedImages(prev => [...prev, ...newImages]);
1592
+ }, [attachedImages.length, fileToBase64]);
1593
+
1594
+ // Handle clipboard paste for images
1595
+ const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
1596
+ const items = e.clipboardData?.items;
1597
+ if (!items) return;
640
1598
 
641
- // IMPORTANT: Add a note about refreshing for new stories
642
- responseMessage += '\n\n💡 **Note**: If you don\'t see the story immediately, you may need to refresh your Storybook page (Cmd/Ctrl + R) for new stories to appear in the sidebar.';
1599
+ const imageItems: DataTransferItem[] = [];
1600
+ for (let i = 0; i < items.length; i++) {
1601
+ if (items[i].type.startsWith('image/')) {
1602
+ imageItems.push(items[i]);
643
1603
  }
1604
+ }
1605
+
1606
+ if (imageItems.length === 0) return;
1607
+
1608
+ // Prevent default text paste behavior when pasting images
1609
+ e.preventDefault();
1610
+
1611
+ if (attachedImages.length >= MAX_IMAGES) {
1612
+ setError(`Maximum ${MAX_IMAGES} images allowed`);
1613
+ return;
1614
+ }
1615
+
1616
+ const newImages: AttachedImage[] = [];
1617
+ const errors: string[] = [];
1618
+
1619
+ for (let i = 0; i < imageItems.length && (attachedImages.length + newImages.length) < MAX_IMAGES; i++) {
1620
+ const item = imageItems[i];
1621
+ const file = item.getAsFile();
1622
+
1623
+ if (!file) {
1624
+ errors.push('Failed to get image from clipboard');
1625
+ continue;
1626
+ }
1627
+
1628
+ if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
1629
+ errors.push(`Pasted image too large (max ${MAX_IMAGE_SIZE_MB}MB)`);
1630
+ continue;
1631
+ }
1632
+
1633
+ try {
1634
+ const base64 = await fileToBase64(file);
1635
+ const preview = URL.createObjectURL(file);
1636
+
1637
+ // Create a meaningful name for pasted images
1638
+ const timestamp = new Date().toISOString().slice(11, 19).replace(/:/g, '-');
1639
+ const renamedFile = new File([file], `pasted-image-${timestamp}.${file.type.split('/')[1] || 'png'}`, { type: file.type });
1640
+
1641
+ newImages.push({
1642
+ id: `paste-${Date.now()}-${i}`,
1643
+ file: renamedFile,
1644
+ preview,
1645
+ base64,
1646
+ });
1647
+ } catch (err) {
1648
+ errors.push('Failed to process pasted image');
1649
+ }
1650
+ }
644
1651
 
645
- // Add validation information if there are issues
646
- if (data.validation && data.validation.hasWarnings) {
647
- responseMessage += '\n\n';
1652
+ if (errors.length > 0) {
1653
+ setError(errors.join('\n'));
1654
+ }
1655
+
1656
+ if (newImages.length > 0) {
1657
+ setAttachedImages(prev => [...prev, ...newImages]);
1658
+ // Clear any existing error on successful paste
1659
+ if (errors.length === 0) {
1660
+ setError(null);
1661
+ }
1662
+ }
1663
+ }, [attachedImages.length, fileToBase64]);
648
1664
 
649
- if (data.validation.errors.length > 0) {
650
- responseMessage += `🔧 **Auto-fixed ${data.validation.errors.length} syntax error(s):**\n`;
651
- responseMessage += data.validation.errors.slice(0, 3).map(error => ` • ${error}`).join('\n');
652
- if (data.validation.errors.length > 3) {
653
- responseMessage += `\n • ... and ${data.validation.errors.length - 3} more`;
1665
+ // Load and sync chats on mount
1666
+ useEffect(() => {
1667
+ const initializeChats = async () => {
1668
+ // Test connection first
1669
+ const connectionTest = await testMCPConnection();
1670
+ setConnectionStatus(connectionTest);
1671
+
1672
+ if (connectionTest.connected) {
1673
+ // Fetch available providers
1674
+ try {
1675
+ const providersRes = await fetch(PROVIDERS_API);
1676
+ if (providersRes.ok) {
1677
+ const providersData: ProvidersResponse = await providersRes.json();
1678
+ setAvailableProviders(providersData.providers.filter(p => p.configured));
1679
+ // Set initial selection from server defaults
1680
+ if (providersData.current) {
1681
+ setSelectedProvider(providersData.current.provider.toLowerCase());
1682
+ setSelectedModel(providersData.current.model);
1683
+ }
654
1684
  }
655
- responseMessage += '\n';
1685
+ } catch (e) {
1686
+ console.error('Failed to fetch providers:', e);
656
1687
  }
657
1688
 
658
- if (data.validation.warnings.length > 0) {
659
- responseMessage += `⚠️ **Warnings:**\n`;
660
- responseMessage += data.validation.warnings.slice(0, 2).map(warning => ` • ${warning}`).join('\n');
661
- if (data.validation.warnings.length > 2) {
662
- responseMessage += `\n • ... and ${data.validation.warnings.length - 2} more`;
1689
+ // Fetch design system considerations for environment parity
1690
+ // This ensures production gets the same considerations as local development
1691
+ try {
1692
+ const considerationsRes = await fetch(CONSIDERATIONS_API);
1693
+ if (considerationsRes.ok) {
1694
+ const considerationsData = await considerationsRes.json();
1695
+ if (considerationsData.hasConsiderations && considerationsData.considerations) {
1696
+ setConsiderations(considerationsData.considerations);
1697
+ console.log(`Loaded considerations from ${considerationsData.source}`);
1698
+ }
663
1699
  }
1700
+ } catch (e) {
1701
+ console.error('Failed to fetch considerations:', e);
1702
+ }
1703
+
1704
+ const syncedChats = await syncWithActualStories();
1705
+ const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
1706
+ setRecentChats(sortedChats);
1707
+
1708
+ if (sortedChats.length > 0) {
1709
+ setConversation(sortedChats[0].conversation);
1710
+ setActiveChatId(sortedChats[0].id);
1711
+ setActiveTitle(sortedChats[0].title);
664
1712
  }
1713
+ } else {
1714
+ // Load from local storage if server is not available
1715
+ const localChats = loadChats();
1716
+ setRecentChats(localChats);
665
1717
  }
1718
+ };
1719
+
1720
+ initializeChats();
1721
+ }, []);
666
1722
 
667
- const aiMsg = { role: 'ai' as const, content: responseMessage };
668
- const updatedConversation = [...newConversation, aiMsg];
669
- setConversation(updatedConversation);
670
-
671
- // Determine if this is an update or new chat
672
- // Check if we have an active chat AND the backend indicates this is an update
673
- const isUpdate = activeChatId && conversation.length > 0 && (
674
- data.isUpdate ||
675
- data.fileName === activeChatId ||
676
- // Also check if fileName matches any existing chat's fileName
677
- recentChats.some(chat => chat.fileName === data.fileName && chat.id === activeChatId)
1723
+ // Scroll to bottom on new message
1724
+ useEffect(() => {
1725
+ if (chatEndRef.current) {
1726
+ chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
1727
+ }
1728
+ }, [conversation, loading]);
1729
+
1730
+ // Helper function for non-streaming fallback
1731
+ const handleSendNonStreaming = async (userInput: string, newConversation: Message[]) => {
1732
+ const res = await fetch(MCP_API, {
1733
+ method: 'POST',
1734
+ headers: { 'Content-Type': 'application/json' },
1735
+ body: JSON.stringify({
1736
+ prompt: userInput,
1737
+ conversation: newConversation,
1738
+ fileName: activeChatId || undefined,
1739
+ provider: selectedProvider || undefined,
1740
+ model: selectedModel || undefined,
1741
+ considerations: considerations || undefined,
1742
+ }),
1743
+ });
1744
+
1745
+ const contentType = res.headers.get('content-type');
1746
+ if (!contentType || !contentType.includes('application/json')) {
1747
+ const text = await res.text();
1748
+ throw new Error(`Server returned non-JSON response. Response: ${text.substring(0, 200)}...`);
1749
+ }
1750
+
1751
+ const data = await res.json();
1752
+ if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
1753
+
1754
+ return data;
1755
+ };
1756
+
1757
+ // Helper function to build a conversational response from completion data
1758
+ // Uses special markers [SUCCESS], [ERROR], [TIP], [WRENCH] that renderMarkdown converts to icons
1759
+ // Track whether we've shown the refresh hint in this session
1760
+ const hasShownRefreshHint = useRef(false);
1761
+
1762
+ const buildConversationalResponse = (completion: CompletionFeedback, isUpdate: boolean): string => {
1763
+ const parts: string[] = [];
1764
+ const statusMarker = completion.success ? '[SUCCESS]' : '[ERROR]';
1765
+
1766
+ // Lead with the result - more conversational
1767
+ if (isUpdate) {
1768
+ parts.push(`${statusMarker} **Updated: "${completion.title}"**`);
1769
+ } else {
1770
+ parts.push(`${statusMarker} **Created: "${completion.title}"**`);
1771
+ }
1772
+
1773
+ // Build component insights with reasons when available
1774
+ const componentCount = completion.componentsUsed?.length || 0;
1775
+ if (componentCount > 0) {
1776
+ const componentList = completion.componentsUsed!.slice(0, 5);
1777
+
1778
+ // Check if we have meaningful reasons (not just "Used in composition")
1779
+ const componentsWithReasons = componentList.filter(c =>
1780
+ c.reason && c.reason !== 'Used in composition'
678
1781
  );
679
1782
 
680
- console.log('Update detection:', {
681
- activeChatId,
682
- conversationLength: conversation.length,
683
- dataIsUpdate: data.isUpdate,
684
- dataFileName: data.fileName,
685
- isUpdate
686
- });
1783
+ if (componentsWithReasons.length > 0) {
1784
+ // Show components with their reasons
1785
+ const insights = componentsWithReasons
1786
+ .slice(0, 3)
1787
+ .map(c => `\`${c.name}\` - ${c.reason?.toLowerCase()}`)
1788
+ .join(', ');
1789
+ parts.push(`\nUsed ${insights}${componentCount > 3 ? ` and ${componentCount - 3} more` : ''}.`);
1790
+ } else {
1791
+ // Fallback to simple list
1792
+ const names = componentList.map(c => `\`${c.name}\``).join(', ');
1793
+ parts.push(`\nBuilt with ${names}${componentCount > 5 ? '...' : ''}.`);
1794
+ }
1795
+ }
687
1796
 
688
- if (isUpdate) {
689
- // Update existing chat session
690
- const chatTitle = activeTitle; // Keep existing title for updates
691
- const updatedSession: ChatSession = {
692
- id: activeChatId,
693
- title: chatTitle,
694
- fileName: data.fileName || activeChatId,
695
- conversation: updatedConversation,
696
- lastUpdated: Date.now(),
697
- };
1797
+ // Add layout decisions with educational context
1798
+ if (completion.layoutChoices && completion.layoutChoices.length > 0) {
1799
+ const primaryLayout = completion.layoutChoices[0];
1800
+ parts.push(`\n\n**Layout:** ${primaryLayout.pattern} - ${primaryLayout.reason.charAt(0).toLowerCase()}${primaryLayout.reason.slice(1)}.`);
1801
+ }
698
1802
 
699
- const chats = loadChats();
700
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
701
- if (chatIndex !== -1) {
702
- chats[chatIndex] = updatedSession;
703
- }
704
- saveChats(chats);
705
- setRecentChats(chats);
706
- console.log('Updated existing chat:', activeChatId);
1803
+ // Add style choices only if they add value
1804
+ if (completion.styleChoices && completion.styleChoices.length > 0) {
1805
+ const notableStyles = completion.styleChoices.filter(s =>
1806
+ s.reason && s.reason !== 'Semantic color from design system'
1807
+ );
1808
+ if (notableStyles.length > 0) {
1809
+ const styleInfo = notableStyles[0];
1810
+ parts.push(` Applied \`${styleInfo.value}\` for ${styleInfo.reason?.toLowerCase() || 'visual consistency'}.`);
1811
+ }
1812
+ }
1813
+
1814
+ // Add validation fixes notice
1815
+ if (completion.validation?.autoFixApplied) {
1816
+ parts.push(`\n\n[WRENCH] **Auto-fixed:** Minor syntax issues were automatically corrected.`);
1817
+ }
1818
+
1819
+ // Add suggestions only if meaningful
1820
+ if (completion.suggestions && completion.suggestions.length > 0) {
1821
+ const suggestion = completion.suggestions[0];
1822
+ // Only show if it's not the generic "review the generated code" message
1823
+ if (!suggestion.toLowerCase().includes('review the generated code')) {
1824
+ parts.push(`\n\n[TIP] **Tip:** ${suggestion}`);
1825
+ }
1826
+ }
1827
+
1828
+ // Show refresh hint only once per session for new stories (local mode only)
1829
+ // In Edge mode, stories are stored in Durable Objects, not on filesystem
1830
+ if (!isUpdate && !hasShownRefreshHint.current) {
1831
+ if (isEdgeMode()) {
1832
+ parts.push(`\n\n_Story saved to cloud. View code in chat history above._`);
707
1833
  } else {
708
- // Create new chat session - use storyId from backend for consistency
709
- const chatId = data.storyId || data.fileName || data.outPath || Date.now().toString();
710
- const chatTitle = data.title || input;
711
- setActiveChatId(chatId);
712
- setActiveTitle(chatTitle);
713
-
714
- const newSession: ChatSession = {
715
- id: chatId,
716
- title: chatTitle,
717
- fileName: data.fileName || '',
718
- conversation: updatedConversation,
719
- lastUpdated: Date.now(),
720
- };
1834
+ parts.push(`\n\n_Refresh Storybook (Cmd/Ctrl + R) to see new stories in the sidebar._`);
1835
+ }
1836
+ hasShownRefreshHint.current = true;
1837
+ }
721
1838
 
722
- const chats = loadChats().filter(c => c.id !== chatId);
723
- chats.unshift(newSession);
724
- if (chats.length > MAX_RECENT_CHATS) {
725
- chats.splice(MAX_RECENT_CHATS);
726
- }
727
- saveChats(chats);
728
- setRecentChats(chats);
729
- console.log('Created new chat:', chatId);
1839
+ // Add metrics in a subtle way (if available)
1840
+ if (completion.metrics?.totalTimeMs) {
1841
+ const seconds = (completion.metrics.totalTimeMs / 1000).toFixed(1);
1842
+ parts.push(`\n\n_${seconds}s_`);
1843
+ }
1844
+
1845
+ return parts.join('');
1846
+ };
1847
+
1848
+ // Helper function to finalize conversation after streaming completes
1849
+ const finalizeStreamingConversation = useCallback((
1850
+ newConversation: Message[],
1851
+ completion: CompletionFeedback,
1852
+ userInput: string
1853
+ ) => {
1854
+ // Build conversational response using rich completion data
1855
+ const isUpdate = completion.summary.action === 'updated';
1856
+ const responseMessage = buildConversationalResponse(completion, isUpdate);
1857
+
1858
+ const aiMsg: Message = { role: 'ai', content: responseMessage };
1859
+ const updatedConversation = [...newConversation, aiMsg];
1860
+ setConversation(updatedConversation);
1861
+
1862
+ // Update chat session
1863
+ const isExistingSession = activeChatId && conversation.length > 0;
1864
+
1865
+ if (isExistingSession && activeChatId) {
1866
+ const updatedSession: ChatSession = {
1867
+ id: activeChatId,
1868
+ title: activeTitle,
1869
+ fileName: completion.fileName || activeChatId,
1870
+ conversation: updatedConversation,
1871
+ lastUpdated: Date.now(),
1872
+ };
1873
+
1874
+ const chats = loadChats();
1875
+ const chatIndex = chats.findIndex(c => c.id === activeChatId);
1876
+ if (chatIndex !== -1) {
1877
+ chats[chatIndex] = updatedSession;
730
1878
  }
1879
+ saveChats(chats);
1880
+ setRecentChats(chats);
1881
+ } else {
1882
+ const chatId = completion.storyId || completion.fileName || Date.now().toString();
1883
+ const chatTitle = completion.title || userInput;
1884
+ setActiveChatId(chatId);
1885
+ setActiveTitle(chatTitle);
1886
+
1887
+ const newSession: ChatSession = {
1888
+ id: chatId,
1889
+ title: chatTitle,
1890
+ fileName: completion.fileName || '',
1891
+ conversation: updatedConversation,
1892
+ lastUpdated: Date.now(),
1893
+ };
1894
+
1895
+ const chats = loadChats().filter(c => c.id !== chatId);
1896
+ chats.unshift(newSession);
1897
+ if (chats.length > MAX_RECENT_CHATS) {
1898
+ chats.splice(MAX_RECENT_CHATS);
1899
+ }
1900
+ saveChats(chats);
1901
+ setRecentChats(chats);
1902
+ }
1903
+ }, [activeChatId, activeTitle, conversation.length]);
731
1904
 
732
- } catch (err: unknown) {
733
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
734
- setError(errorMessage);
735
- const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
736
- setConversation(errorConversation);
737
-
738
- // IMPORTANT: Create/update chat session even on error so retries continue the same conversation
739
- const isUpdate = activeChatId && conversation.length > 0;
740
-
741
- if (isUpdate) {
742
- // Update existing chat with error
743
- const updatedSession: ChatSession = {
744
- id: activeChatId,
745
- title: activeTitle,
746
- fileName: activeChatId,
747
- conversation: errorConversation,
748
- lastUpdated: Date.now(),
749
- };
1905
+ const handleSend = async (e?: React.FormEvent) => {
1906
+ if (e) e.preventDefault();
1907
+ // Allow sending with either text or images
1908
+ if (!input.trim() && attachedImages.length === 0) return;
1909
+
1910
+ // Use input text or default vision prompt if only images
1911
+ const userInput = input.trim() || (attachedImages.length > 0 ? 'Create a component that matches this design' : '');
1912
+ setError(null);
1913
+ setLoading(true);
1914
+ setStreamingState(null);
1915
+
1916
+ // Test connection before sending
1917
+ const connectionTest = await testMCPConnection();
1918
+ setConnectionStatus(connectionTest);
1919
+
1920
+ if (!connectionTest.connected) {
1921
+ setError(`Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}`);
1922
+ setLoading(false);
1923
+ return;
1924
+ }
1925
+
1926
+ // Capture images before clearing
1927
+ const imagesToSend = [...attachedImages];
1928
+ const hasImages = imagesToSend.length > 0;
750
1929
 
751
- const chats = loadChats();
752
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
753
- if (chatIndex !== -1) {
754
- chats[chatIndex] = updatedSession;
1930
+ // Create user message with images
1931
+ const userMessage: Message = {
1932
+ role: 'user',
1933
+ content: userInput,
1934
+ attachedImages: hasImages ? imagesToSend : undefined
1935
+ };
1936
+ const newConversation: Message[] = [...conversation, userMessage];
1937
+ setConversation(newConversation);
1938
+ setInput('');
1939
+ clearAttachedImages();
1940
+
1941
+ // Use streaming if enabled
1942
+ if (USE_STREAMING) {
1943
+ try {
1944
+ // Cancel any existing request
1945
+ if (abortControllerRef.current) {
1946
+ abortControllerRef.current.abort();
1947
+ }
1948
+ abortControllerRef.current = new AbortController();
1949
+
1950
+ // Initialize streaming state
1951
+ setStreamingState({});
1952
+
1953
+ // Prepare images for API request
1954
+ const imagePayload = hasImages
1955
+ ? imagesToSend.map(img => ({
1956
+ type: 'base64' as const,
1957
+ data: img.base64,
1958
+ mediaType: img.file.type,
1959
+ }))
1960
+ : undefined;
1961
+
1962
+ const response = await fetch(MCP_STREAM_API, {
1963
+ method: 'POST',
1964
+ headers: { 'Content-Type': 'application/json' },
1965
+ body: JSON.stringify({
1966
+ prompt: userInput,
1967
+ conversation: newConversation,
1968
+ fileName: activeChatId || undefined,
1969
+ isUpdate: activeChatId && conversation.length > 0,
1970
+ originalTitle: activeTitle || undefined,
1971
+ storyId: activeChatId || undefined,
1972
+ images: imagePayload,
1973
+ visionMode: hasImages ? 'screenshot_to_story' : undefined,
1974
+ provider: selectedProvider || undefined,
1975
+ model: selectedModel || undefined,
1976
+ considerations: considerations || undefined,
1977
+ }),
1978
+ signal: abortControllerRef.current.signal,
1979
+ });
1980
+
1981
+ if (!response.ok) {
1982
+ throw new Error(`Streaming request failed: ${response.status}`);
755
1983
  }
756
- saveChats(chats);
757
- setRecentChats(chats);
758
- } else {
759
- // Create new chat session for error (so retries can continue it)
760
- const chatId = `error-${Date.now()}`;
761
- const chatTitle = input.length > 30 ? input.substring(0, 30) + '...' : input;
762
- setActiveChatId(chatId);
763
- setActiveTitle(chatTitle);
764
-
765
- const newSession: ChatSession = {
766
- id: chatId,
767
- title: chatTitle,
768
- fileName: '',
769
- conversation: errorConversation,
770
- lastUpdated: Date.now(),
771
- };
772
1984
 
773
- const chats = loadChats();
774
- chats.unshift(newSession);
775
- if (chats.length > MAX_RECENT_CHATS) {
776
- chats.splice(MAX_RECENT_CHATS);
1985
+ const reader = response.body?.getReader();
1986
+ if (!reader) {
1987
+ throw new Error('No response body');
777
1988
  }
778
- saveChats(chats);
779
- setRecentChats(chats);
1989
+
1990
+ const decoder = new TextDecoder();
1991
+ let buffer = '';
1992
+ let completionData: CompletionFeedback | null = null;
1993
+ let errorData: ErrorFeedback | null = null;
1994
+
1995
+ while (true) {
1996
+ const { done, value } = await reader.read();
1997
+ if (done) break;
1998
+
1999
+ buffer += decoder.decode(value, { stream: true });
2000
+
2001
+ // Parse SSE events from buffer
2002
+ const lines = buffer.split('\n');
2003
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
2004
+
2005
+ for (const line of lines) {
2006
+ if (line.startsWith('data: ')) {
2007
+ try {
2008
+ const event: StreamEvent = JSON.parse(line.slice(6));
2009
+
2010
+ // Update streaming state based on event type
2011
+ switch (event.type) {
2012
+ case 'intent':
2013
+ setStreamingState(prev => ({ ...prev, intent: event.data as IntentPreview }));
2014
+ break;
2015
+ case 'progress':
2016
+ setStreamingState(prev => ({ ...prev, progress: event.data as ProgressUpdate }));
2017
+ break;
2018
+ case 'validation':
2019
+ setStreamingState(prev => ({ ...prev, validation: event.data as ValidationFeedback }));
2020
+ break;
2021
+ case 'retry':
2022
+ setStreamingState(prev => ({ ...prev, retry: event.data as RetryInfo }));
2023
+ break;
2024
+ case 'completion':
2025
+ completionData = event.data as CompletionFeedback;
2026
+ setStreamingState(prev => ({ ...prev, completion: event.data as CompletionFeedback }));
2027
+ break;
2028
+ case 'error':
2029
+ errorData = event.data as ErrorFeedback;
2030
+ setStreamingState(prev => ({ ...prev, error: event.data as ErrorFeedback }));
2031
+ break;
2032
+ }
2033
+ } catch (parseError) {
2034
+ console.warn('Failed to parse SSE event:', line, parseError);
2035
+ }
2036
+ }
2037
+ }
2038
+ }
2039
+
2040
+ // Handle completion or error
2041
+ if (completionData) {
2042
+ finalizeStreamingConversation(newConversation, completionData, userInput);
2043
+ } else if (errorData) {
2044
+ setError(errorData.message);
2045
+ const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorData.message}\n\n${errorData.suggestion || ''}` }];
2046
+ setConversation(errorConversation);
2047
+ }
2048
+
2049
+ } catch (err: unknown) {
2050
+ if ((err as Error).name === 'AbortError') {
2051
+ console.log('Request aborted');
2052
+ return;
2053
+ }
2054
+
2055
+ // Fall back to non-streaming on error
2056
+ console.warn('Streaming failed, falling back to non-streaming:', err);
2057
+ setStreamingState(null);
2058
+
2059
+ try {
2060
+ const data = await handleSendNonStreaming(userInput, newConversation);
2061
+
2062
+ // Process non-streaming response (same as before)
2063
+ let responseMessage: string;
2064
+ const statusIcon = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '🔧' : '⚠️') : '✅';
2065
+
2066
+ // Build conversational response for fallback
2067
+ if (data.isUpdate) {
2068
+ responseMessage = `${statusIcon} **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._`;
2069
+ } else {
2070
+ responseMessage = `${statusIcon} **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💡 **Note**: If you don't see the story immediately, you may need to refresh your Storybook page (Cmd/Ctrl + R).`;
2071
+ }
2072
+
2073
+ const aiMsg: Message = { role: 'ai', content: responseMessage };
2074
+ const updatedConversation = [...newConversation, aiMsg];
2075
+ setConversation(updatedConversation);
2076
+
2077
+ // Update chat session
2078
+ const isUpdate = activeChatId && conversation.length > 0;
2079
+ if (isUpdate && activeChatId) {
2080
+ const updatedSession: ChatSession = {
2081
+ id: activeChatId,
2082
+ title: activeTitle,
2083
+ fileName: data.fileName || activeChatId,
2084
+ conversation: updatedConversation,
2085
+ lastUpdated: Date.now(),
2086
+ };
2087
+ const chats = loadChats();
2088
+ const chatIndex = chats.findIndex(c => c.id === activeChatId);
2089
+ if (chatIndex !== -1) chats[chatIndex] = updatedSession;
2090
+ saveChats(chats);
2091
+ setRecentChats(chats);
2092
+ } else {
2093
+ const chatId = data.storyId || data.fileName || Date.now().toString();
2094
+ setActiveChatId(chatId);
2095
+ setActiveTitle(data.title || userInput);
2096
+ const newSession: ChatSession = {
2097
+ id: chatId,
2098
+ title: data.title || userInput,
2099
+ fileName: data.fileName || '',
2100
+ conversation: updatedConversation,
2101
+ lastUpdated: Date.now(),
2102
+ };
2103
+ const chats = loadChats().filter(c => c.id !== chatId);
2104
+ chats.unshift(newSession);
2105
+ if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
2106
+ saveChats(chats);
2107
+ setRecentChats(chats);
2108
+ }
2109
+ } catch (fallbackErr: unknown) {
2110
+ const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : 'Unknown error';
2111
+ setError(errorMessage);
2112
+ const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
2113
+ setConversation(errorConversation);
2114
+ }
2115
+ } finally {
2116
+ setLoading(false);
2117
+ setStreamingState(null);
2118
+ abortControllerRef.current = null;
2119
+ }
2120
+ } else {
2121
+ // Non-streaming mode (original implementation)
2122
+ try {
2123
+ const data = await handleSendNonStreaming(userInput, newConversation);
2124
+
2125
+ let responseMessage: string;
2126
+ const statusIcon = data.validation?.hasWarnings ? (data.validation.errors?.length > 0 ? '🔧' : '⚠️') : '✅';
2127
+
2128
+ // Build conversational response for non-streaming mode
2129
+ if (data.isUpdate) {
2130
+ responseMessage = `${statusIcon} **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._`;
2131
+ } else {
2132
+ responseMessage = `${statusIcon} **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💡 **Note**: If you don't see the story immediately, you may need to refresh your Storybook page (Cmd/Ctrl + R).`;
2133
+ }
2134
+
2135
+ const aiMsg: Message = { role: 'ai', content: responseMessage };
2136
+ const updatedConversation = [...newConversation, aiMsg];
2137
+ setConversation(updatedConversation);
2138
+
2139
+ const isUpdate = activeChatId && conversation.length > 0;
2140
+ if (isUpdate && activeChatId) {
2141
+ const updatedSession: ChatSession = {
2142
+ id: activeChatId,
2143
+ title: activeTitle,
2144
+ fileName: data.fileName || activeChatId,
2145
+ conversation: updatedConversation,
2146
+ lastUpdated: Date.now(),
2147
+ };
2148
+ const chats = loadChats();
2149
+ const chatIndex = chats.findIndex(c => c.id === activeChatId);
2150
+ if (chatIndex !== -1) chats[chatIndex] = updatedSession;
2151
+ saveChats(chats);
2152
+ setRecentChats(chats);
2153
+ } else {
2154
+ const chatId = data.storyId || data.fileName || Date.now().toString();
2155
+ setActiveChatId(chatId);
2156
+ setActiveTitle(data.title || userInput);
2157
+ const newSession: ChatSession = {
2158
+ id: chatId,
2159
+ title: data.title || userInput,
2160
+ fileName: data.fileName || '',
2161
+ conversation: updatedConversation,
2162
+ lastUpdated: Date.now(),
2163
+ };
2164
+ const chats = loadChats().filter(c => c.id !== chatId);
2165
+ chats.unshift(newSession);
2166
+ if (chats.length > MAX_RECENT_CHATS) chats.splice(MAX_RECENT_CHATS);
2167
+ saveChats(chats);
2168
+ setRecentChats(chats);
2169
+ }
2170
+ } catch (err: unknown) {
2171
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2172
+ setError(errorMessage);
2173
+ const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
2174
+ setConversation(errorConversation);
2175
+ } finally {
2176
+ setLoading(false);
780
2177
  }
781
- } finally {
782
- setLoading(false);
783
2178
  }
784
2179
  };
785
2180
 
@@ -844,7 +2239,8 @@ export function StoryUIPanel() {
844
2239
  e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.3)';
845
2240
  }}
846
2241
  >
847
- Chats
2242
+ <span style={{ lineHeight: '0.5', display: 'inline-block', alignItems: 'center', width: '10px', height: '10px' }}>☰</span>
2243
+ <span>Chats</span>
848
2244
  </button>
849
2245
  <button
850
2246
  onClick={handleNewChat}
@@ -858,7 +2254,8 @@ export function StoryUIPanel() {
858
2254
  e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.2)';
859
2255
  }}
860
2256
  >
861
- + New Chat
2257
+ <span style={{ lineHeight: '0.5', display: 'inline-block', alignItems: 'center', width: '10px', height: '10px' }}>+</span>
2258
+ <span>New Chat</span>
862
2259
  </button>
863
2260
  {recentChats.length > 0 && (
864
2261
  <div style={{
@@ -910,34 +2307,53 @@ export function StoryUIPanel() {
910
2307
  </div>
911
2308
  )}
912
2309
  {!sidebarOpen && (
913
- <div style={{ padding: '16px' }}>
2310
+ <div style={{ padding: '8px', display: 'flex', justifyContent: 'center' }}>
914
2311
  <button
915
2312
  onClick={() => setSidebarOpen(true)}
916
2313
  style={{
917
2314
  ...STYLES.sidebarToggle,
918
- width: '40px',
919
- height: '40px',
2315
+ width: '38px',
2316
+ height: '38px',
920
2317
  padding: '0',
921
2318
  fontSize: '16px',
2319
+ borderRadius: '8px',
922
2320
  }}
923
2321
  title="Expand sidebar"
924
2322
  onMouseEnter={(e) => {
925
2323
  e.currentTarget.style.transform = 'scale(1.05)';
926
- e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.4)';
2324
+ e.currentTarget.style.background = '#2563eb';
927
2325
  }}
928
2326
  onMouseLeave={(e) => {
929
2327
  e.currentTarget.style.transform = 'scale(1)';
930
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.3)';
2328
+ e.currentTarget.style.background = '#3b82f6';
931
2329
  }}
932
2330
  >
933
-
2331
+ <span style={{ lineHeight: '0.4', display: 'inline-block', height: '10px' }}>☰</span>
934
2332
  </button>
935
2333
  </div>
936
2334
  )}
937
2335
  </div>
938
2336
 
939
2337
  {/* Main content */}
940
- <div style={STYLES.mainContent}>
2338
+ <div
2339
+ style={{ ...STYLES.mainContent, position: 'relative' as const }}
2340
+ onDragEnter={handleDragEnter}
2341
+ onDragLeave={handleDragLeave}
2342
+ onDragOver={handleDragOver}
2343
+ onDrop={handleDrop}
2344
+ >
2345
+ {/* Drop zone overlay */}
2346
+ {isDragging && (
2347
+ <div style={STYLES.dropOverlay}>
2348
+ <div style={STYLES.dropOverlayText}>
2349
+ <svg width={24} height={24} viewBox="0 0 24 24" fill="currentColor">
2350
+ <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"/>
2351
+ </svg>
2352
+ Drop images here
2353
+ </div>
2354
+ </div>
2355
+ )}
2356
+
941
2357
  <div style={STYLES.chatHeader}>
942
2358
  <h1 style={{
943
2359
  fontSize: '24px',
@@ -967,12 +2383,74 @@ export function StoryUIPanel() {
967
2383
  backgroundColor: connectionStatus.connected ? '#10b981' : '#f87171'
968
2384
  }}></div>
969
2385
  <span style={{ color: connectionStatus.connected ? '#10b981' : '#f87171' }}>
970
- {connectionStatus.connected
971
- ? `Connected to MCP server (port ${getApiPort()})`
2386
+ {connectionStatus.connected
2387
+ ? `Connected to ${getConnectionDisplayText()}`
972
2388
  : `Disconnected: ${connectionStatus.error || 'Server not running'}`
973
2389
  }
974
2390
  </span>
975
2391
  </div>
2392
+
2393
+ {/* LLM Provider/Model Selection */}
2394
+ {connectionStatus.connected && availableProviders.length > 0 && (
2395
+ <div style={{
2396
+ display: 'flex',
2397
+ gap: '12px',
2398
+ marginTop: '12px',
2399
+ flexWrap: 'wrap'
2400
+ }}>
2401
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
2402
+ <label style={{ fontSize: '12px', color: '#94a3b8' }}>Provider:</label>
2403
+ <select
2404
+ value={selectedProvider}
2405
+ onChange={(e) => {
2406
+ const newProvider = e.target.value;
2407
+ setSelectedProvider(newProvider);
2408
+ // Reset model to first available for new provider
2409
+ const provider = availableProviders.find(p => p.type === newProvider);
2410
+ if (provider && provider.models.length > 0) {
2411
+ setSelectedModel(provider.models[0]);
2412
+ }
2413
+ }}
2414
+ style={{
2415
+ background: '#1e293b',
2416
+ border: '1px solid #334155',
2417
+ borderRadius: '6px',
2418
+ color: '#e2e8f0',
2419
+ padding: '4px 8px',
2420
+ fontSize: '12px',
2421
+ cursor: 'pointer'
2422
+ }}
2423
+ >
2424
+ {availableProviders.map(p => (
2425
+ <option key={p.type} value={p.type}>{p.name}</option>
2426
+ ))}
2427
+ </select>
2428
+ </div>
2429
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
2430
+ <label style={{ fontSize: '12px', color: '#94a3b8' }}>Model:</label>
2431
+ <select
2432
+ value={selectedModel}
2433
+ onChange={(e) => setSelectedModel(e.target.value)}
2434
+ style={{
2435
+ background: '#1e293b',
2436
+ border: '1px solid #334155',
2437
+ borderRadius: '6px',
2438
+ color: '#e2e8f0',
2439
+ padding: '4px 8px',
2440
+ fontSize: '12px',
2441
+ cursor: 'pointer',
2442
+ maxWidth: '200px'
2443
+ }}
2444
+ >
2445
+ {availableProviders
2446
+ .find(p => p.type === selectedProvider)
2447
+ ?.models.map(model => (
2448
+ <option key={model} value={model}>{model}</option>
2449
+ ))}
2450
+ </select>
2451
+ </div>
2452
+ </div>
2453
+ )}
976
2454
  </div>
977
2455
 
978
2456
  <div style={STYLES.chatContainer}>
@@ -994,30 +2472,125 @@ export function StoryUIPanel() {
994
2472
  {conversation.map((msg, i) => (
995
2473
  <div key={i} style={STYLES.messageContainer}>
996
2474
  <div style={msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage}>
997
- {msg.content}
2475
+ {msg.role === 'ai' ? renderMarkdown(msg.content) : msg.content}
2476
+ {/* Show attached images in user messages */}
2477
+ {msg.role === 'user' && msg.attachedImages && msg.attachedImages.length > 0 && (
2478
+ <div style={STYLES.userMessageImages}>
2479
+ {msg.attachedImages.map((img) => (
2480
+ <img
2481
+ key={img.id}
2482
+ src={img.preview}
2483
+ alt="attached"
2484
+ style={STYLES.userMessageImage}
2485
+ />
2486
+ ))}
2487
+ </div>
2488
+ )}
998
2489
  </div>
999
2490
  </div>
1000
2491
  ))}
1001
2492
 
1002
2493
  {loading && (
1003
2494
  <div style={STYLES.messageContainer}>
1004
- <div style={STYLES.loadingMessage}>
1005
- <span>Generating story</span>
1006
- <span className="loading-dots"></span>
1007
- </div>
2495
+ {streamingState ? (
2496
+ <StreamingProgressMessage streamingData={streamingState} />
2497
+ ) : (
2498
+ <div style={STYLES.loadingMessage}>
2499
+ <span>Generating story</span>
2500
+ <span className="loading-dots"></span>
2501
+ </div>
2502
+ )}
1008
2503
  </div>
1009
2504
  )}
1010
2505
 
1011
2506
  <div ref={chatEndRef} />
1012
2507
  </div>
1013
2508
 
1014
- <form onSubmit={handleSend} style={STYLES.inputForm}>
2509
+ {/* Hidden file input */}
2510
+ <input
2511
+ ref={fileInputRef}
2512
+ type="file"
2513
+ accept="image/*"
2514
+ multiple
2515
+ style={{ display: 'none' }}
2516
+ onChange={handleFileSelect}
2517
+ />
2518
+
2519
+ {/* Image preview area */}
2520
+ {attachedImages.length > 0 && (
2521
+ <div style={STYLES.imagePreviewContainer}>
2522
+ <span style={STYLES.imagePreviewLabel}>
2523
+ <svg width={14} height={14} viewBox="0 0 24 24" fill="currentColor">
2524
+ <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"/>
2525
+ </svg>
2526
+ {attachedImages.length} image{attachedImages.length > 1 ? 's' : ''} attached
2527
+ </span>
2528
+ {attachedImages.map((img) => (
2529
+ <div key={img.id} style={STYLES.imagePreviewItem}>
2530
+ <img src={img.preview} alt="preview" style={STYLES.imagePreviewImg} />
2531
+ <button
2532
+ type="button"
2533
+ style={STYLES.imageRemoveButton}
2534
+ onClick={() => removeAttachedImage(img.id)}
2535
+ title="Remove image"
2536
+ >
2537
+ ×
2538
+ </button>
2539
+ </div>
2540
+ ))}
2541
+ </div>
2542
+ )}
2543
+
2544
+ <form onSubmit={handleSend} style={{
2545
+ ...STYLES.inputForm,
2546
+ ...(attachedImages.length > 0 ? {
2547
+ marginTop: 0,
2548
+ borderTopLeftRadius: 0,
2549
+ borderTopRightRadius: 0,
2550
+ } : {})
2551
+ }}>
2552
+ {/* Upload button */}
2553
+ <button
2554
+ type="button"
2555
+ onClick={() => fileInputRef.current?.click()}
2556
+ disabled={loading || attachedImages.length >= MAX_IMAGES}
2557
+ style={{
2558
+ ...STYLES.uploadButton,
2559
+ ...(attachedImages.length >= MAX_IMAGES ? {
2560
+ opacity: 0.5,
2561
+ cursor: 'not-allowed',
2562
+ } : {})
2563
+ }}
2564
+ title={attachedImages.length >= MAX_IMAGES
2565
+ ? `Maximum ${MAX_IMAGES} images`
2566
+ : 'Attach images (screenshots, designs)'
2567
+ }
2568
+ onMouseEnter={(e) => {
2569
+ if (attachedImages.length < MAX_IMAGES && !loading) {
2570
+ e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)';
2571
+ e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
2572
+ }
2573
+ }}
2574
+ onMouseLeave={(e) => {
2575
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
2576
+ e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
2577
+ }}
2578
+ >
2579
+ <svg width={20} height={20} viewBox="0 0 24 24" fill="currentColor">
2580
+ <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"/>
2581
+ </svg>
2582
+ </button>
2583
+
1015
2584
  <input
1016
2585
  ref={inputRef}
1017
2586
  type="text"
1018
2587
  value={input}
1019
2588
  onChange={e => setInput(e.target.value)}
1020
- placeholder="Describe a UI component..."
2589
+ onPaste={handlePaste}
2590
+ placeholder={attachedImages.length > 0
2591
+ ? "Describe what to create from these images..."
2592
+ : "Describe a UI component..."
2593
+ }
1021
2594
  style={STYLES.textInput}
1022
2595
  onFocus={(e) => {
1023
2596
  e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
@@ -1030,10 +2603,10 @@ export function StoryUIPanel() {
1030
2603
  />
1031
2604
  <button
1032
2605
  type="submit"
1033
- disabled={loading || !input.trim()}
2606
+ disabled={loading || (!input.trim() && attachedImages.length === 0)}
1034
2607
  style={{
1035
2608
  ...STYLES.sendButton,
1036
- ...(loading || !input.trim() ? {
2609
+ ...(loading || (!input.trim() && attachedImages.length === 0) ? {
1037
2610
  opacity: 0.5,
1038
2611
  cursor: 'not-allowed',
1039
2612
  background: '#6b7280',
@@ -1041,7 +2614,7 @@ export function StoryUIPanel() {
1041
2614
  } : {})
1042
2615
  }}
1043
2616
  onMouseEnter={(e) => {
1044
- if (!loading && input.trim()) {
2617
+ if (!loading && (input.trim() || attachedImages.length > 0)) {
1045
2618
  e.currentTarget.style.transform = 'scale(1.05)';
1046
2619
  e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
1047
2620
  }
@@ -1061,3 +2634,6 @@ export function StoryUIPanel() {
1061
2634
  </div>
1062
2635
  );
1063
2636
  }
2637
+
2638
+ export default StoryUIPanel;
2639
+ export { StoryUIPanel };