@tpitre/story-ui 2.1.5 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.env.sample +82 -11
  2. package/README.md +130 -4
  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 +55 -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 +138 -6
  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 +638 -0
  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 +274 -115
  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/hybridStories.js +214 -0
  35. package/dist/mcp-server/routes/mcpRemote.d.ts +14 -0
  36. package/dist/mcp-server/routes/mcpRemote.d.ts.map +1 -0
  37. package/dist/mcp-server/routes/mcpRemote.js +489 -0
  38. package/dist/mcp-server/routes/memoryStories.d.ts +26 -0
  39. package/dist/mcp-server/routes/memoryStories.d.ts.map +1 -0
  40. package/dist/mcp-server/routes/memoryStories.js +13 -7
  41. package/dist/mcp-server/routes/providers.d.ts +89 -0
  42. package/dist/mcp-server/routes/providers.d.ts.map +1 -0
  43. package/dist/mcp-server/routes/providers.js +369 -0
  44. package/dist/mcp-server/routes/storySync.d.ts +26 -0
  45. package/dist/mcp-server/routes/storySync.d.ts.map +1 -0
  46. package/dist/mcp-server/routes/streamTypes.d.ts +110 -0
  47. package/dist/mcp-server/routes/streamTypes.d.ts.map +1 -0
  48. package/dist/mcp-server/routes/streamTypes.js +18 -0
  49. package/dist/mcp-server/sessionManager.d.ts +50 -0
  50. package/dist/mcp-server/sessionManager.d.ts.map +1 -0
  51. package/dist/mcp-server/sessionManager.js +125 -0
  52. package/dist/story-generator/componentBlacklist.d.ts +21 -0
  53. package/dist/story-generator/componentBlacklist.d.ts.map +1 -0
  54. package/dist/story-generator/componentBlacklist.js +4 -0
  55. package/dist/story-generator/componentDiscovery.d.ts +28 -0
  56. package/dist/story-generator/componentDiscovery.d.ts.map +1 -0
  57. package/dist/story-generator/componentRegistryGenerator.d.ts +49 -0
  58. package/dist/story-generator/componentRegistryGenerator.d.ts.map +1 -0
  59. package/dist/story-generator/componentRegistryGenerator.js +205 -0
  60. package/dist/story-generator/configLoader.d.ts +33 -0
  61. package/dist/story-generator/configLoader.d.ts.map +1 -0
  62. package/dist/story-generator/configLoader.js +8 -1
  63. package/dist/story-generator/considerationsLoader.d.ts +32 -0
  64. package/dist/story-generator/considerationsLoader.d.ts.map +1 -0
  65. package/dist/story-generator/considerationsLoader.js +2 -1
  66. package/dist/story-generator/documentation-sources.d.ts +28 -0
  67. package/dist/story-generator/documentation-sources.d.ts.map +1 -0
  68. package/dist/story-generator/documentationLoader.d.ts +64 -0
  69. package/dist/story-generator/documentationLoader.d.ts.map +1 -0
  70. package/dist/story-generator/documentationLoader.js +4 -3
  71. package/dist/story-generator/dynamicPackageDiscovery.d.ts +97 -0
  72. package/dist/story-generator/dynamicPackageDiscovery.d.ts.map +1 -0
  73. package/dist/story-generator/dynamicPackageDiscovery.js +31 -22
  74. package/dist/story-generator/enhancedComponentDiscovery.d.ts +125 -0
  75. package/dist/story-generator/enhancedComponentDiscovery.d.ts.map +1 -0
  76. package/dist/story-generator/enhancedComponentDiscovery.js +162 -21
  77. package/dist/story-generator/framework-adapters/angular-adapter.d.ts +40 -0
  78. package/dist/story-generator/framework-adapters/angular-adapter.d.ts.map +1 -0
  79. package/dist/story-generator/framework-adapters/angular-adapter.js +427 -0
  80. package/dist/story-generator/framework-adapters/base-adapter.d.ts +75 -0
  81. package/dist/story-generator/framework-adapters/base-adapter.d.ts.map +1 -0
  82. package/dist/story-generator/framework-adapters/base-adapter.js +147 -0
  83. package/dist/story-generator/framework-adapters/framework-detector.d.ts +55 -0
  84. package/dist/story-generator/framework-adapters/framework-detector.d.ts.map +1 -0
  85. package/dist/story-generator/framework-adapters/framework-detector.js +323 -0
  86. package/dist/story-generator/framework-adapters/index.d.ts +97 -0
  87. package/dist/story-generator/framework-adapters/index.d.ts.map +1 -0
  88. package/dist/story-generator/framework-adapters/index.js +198 -0
  89. package/dist/story-generator/framework-adapters/react-adapter.d.ts +40 -0
  90. package/dist/story-generator/framework-adapters/react-adapter.d.ts.map +1 -0
  91. package/dist/story-generator/framework-adapters/react-adapter.js +316 -0
  92. package/dist/story-generator/framework-adapters/svelte-adapter.d.ts +40 -0
  93. package/dist/story-generator/framework-adapters/svelte-adapter.d.ts.map +1 -0
  94. package/dist/story-generator/framework-adapters/svelte-adapter.js +372 -0
  95. package/dist/story-generator/framework-adapters/types.d.ts +182 -0
  96. package/dist/story-generator/framework-adapters/types.d.ts.map +1 -0
  97. package/dist/story-generator/framework-adapters/types.js +8 -0
  98. package/dist/story-generator/framework-adapters/vue-adapter.d.ts +36 -0
  99. package/dist/story-generator/framework-adapters/vue-adapter.d.ts.map +1 -0
  100. package/dist/story-generator/framework-adapters/vue-adapter.js +336 -0
  101. package/dist/story-generator/framework-adapters/web-components-adapter.d.ts +54 -0
  102. package/dist/story-generator/framework-adapters/web-components-adapter.d.ts.map +1 -0
  103. package/dist/story-generator/framework-adapters/web-components-adapter.js +387 -0
  104. package/dist/story-generator/generateStory.d.ts +7 -0
  105. package/dist/story-generator/generateStory.d.ts.map +1 -0
  106. package/dist/story-generator/gitignoreManager.d.ts +50 -0
  107. package/dist/story-generator/gitignoreManager.d.ts.map +1 -0
  108. package/dist/story-generator/gitignoreManager.js +7 -6
  109. package/dist/story-generator/imageProcessor.d.ts +80 -0
  110. package/dist/story-generator/imageProcessor.d.ts.map +1 -0
  111. package/dist/story-generator/imageProcessor.js +391 -0
  112. package/dist/story-generator/inMemoryStoryService.d.ts +89 -0
  113. package/dist/story-generator/inMemoryStoryService.d.ts.map +1 -0
  114. package/dist/story-generator/llm-providers/base-provider.d.ts +36 -0
  115. package/dist/story-generator/llm-providers/base-provider.d.ts.map +1 -0
  116. package/dist/story-generator/llm-providers/base-provider.js +135 -0
  117. package/dist/story-generator/llm-providers/claude-provider.d.ts +23 -0
  118. package/dist/story-generator/llm-providers/claude-provider.d.ts.map +1 -0
  119. package/dist/story-generator/llm-providers/claude-provider.js +414 -0
  120. package/dist/story-generator/llm-providers/gemini-provider.d.ts +24 -0
  121. package/dist/story-generator/llm-providers/gemini-provider.d.ts.map +1 -0
  122. package/dist/story-generator/llm-providers/gemini-provider.js +406 -0
  123. package/dist/story-generator/llm-providers/index.d.ts +63 -0
  124. package/dist/story-generator/llm-providers/index.d.ts.map +1 -0
  125. package/dist/story-generator/llm-providers/index.js +169 -0
  126. package/dist/story-generator/llm-providers/openai-provider.d.ts +24 -0
  127. package/dist/story-generator/llm-providers/openai-provider.d.ts.map +1 -0
  128. package/dist/story-generator/llm-providers/openai-provider.js +458 -0
  129. package/dist/story-generator/llm-providers/settings-manager.d.ts +75 -0
  130. package/dist/story-generator/llm-providers/settings-manager.d.ts.map +1 -0
  131. package/dist/story-generator/llm-providers/settings-manager.js +173 -0
  132. package/dist/story-generator/llm-providers/story-llm-service.d.ts +79 -0
  133. package/dist/story-generator/llm-providers/story-llm-service.d.ts.map +1 -0
  134. package/dist/story-generator/llm-providers/story-llm-service.js +240 -0
  135. package/dist/story-generator/llm-providers/types.d.ts +153 -0
  136. package/dist/story-generator/llm-providers/types.d.ts.map +1 -0
  137. package/dist/story-generator/llm-providers/types.js +8 -0
  138. package/dist/story-generator/logger.d.ts +14 -0
  139. package/dist/story-generator/logger.d.ts.map +1 -0
  140. package/dist/story-generator/logger.js +119 -0
  141. package/dist/story-generator/postProcessStory.d.ts +6 -0
  142. package/dist/story-generator/postProcessStory.d.ts.map +1 -0
  143. package/dist/story-generator/postProcessStory.js +8 -7
  144. package/dist/story-generator/productionGitignoreManager.d.ts +91 -0
  145. package/dist/story-generator/productionGitignoreManager.d.ts.map +1 -0
  146. package/dist/story-generator/productionGitignoreManager.js +11 -10
  147. package/dist/story-generator/promptGenerator.d.ts +48 -0
  148. package/dist/story-generator/promptGenerator.d.ts.map +1 -0
  149. package/dist/story-generator/promptGenerator.js +186 -1
  150. package/dist/story-generator/storyHistory.d.ts +44 -0
  151. package/dist/story-generator/storyHistory.d.ts.map +1 -0
  152. package/dist/story-generator/storySync.d.ts +68 -0
  153. package/dist/story-generator/storySync.d.ts.map +1 -0
  154. package/dist/story-generator/storyTracker.d.ts +48 -0
  155. package/dist/story-generator/storyTracker.d.ts.map +1 -0
  156. package/dist/story-generator/storyTracker.js +2 -1
  157. package/dist/story-generator/storyValidator.d.ts +6 -0
  158. package/dist/story-generator/storyValidator.d.ts.map +1 -0
  159. package/dist/story-generator/universalDesignSystemAdapter.d.ts +68 -0
  160. package/dist/story-generator/universalDesignSystemAdapter.d.ts.map +1 -0
  161. package/dist/story-generator/universalDesignSystemAdapter.js +141 -3
  162. package/dist/story-generator/urlRedirectService.d.ts +21 -0
  163. package/dist/story-generator/urlRedirectService.d.ts.map +1 -0
  164. package/dist/story-generator/urlRedirectService.js +140 -0
  165. package/dist/story-generator/validateStory.d.ts +19 -0
  166. package/dist/story-generator/validateStory.d.ts.map +1 -0
  167. package/dist/story-generator/validateStory.js +6 -2
  168. package/dist/story-generator/visionPrompts.d.ts +88 -0
  169. package/dist/story-generator/visionPrompts.d.ts.map +1 -0
  170. package/dist/story-generator/visionPrompts.js +462 -0
  171. package/dist/story-ui.config.d.ts +78 -0
  172. package/dist/story-ui.config.d.ts.map +1 -0
  173. package/dist/templates/StoryUI/StoryUIPanel.d.ts +4 -0
  174. package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -0
  175. package/dist/templates/StoryUI/StoryUIPanel.js +1874 -0
  176. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts +18 -0
  177. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts.map +1 -0
  178. package/dist/templates/StoryUI/StoryUIPanel.stories.js +37 -0
  179. package/dist/templates/StoryUI/index.d.ts +3 -0
  180. package/dist/templates/StoryUI/index.d.ts.map +1 -0
  181. package/dist/templates/StoryUI/index.js +2 -0
  182. package/package.json +35 -4
  183. package/templates/StoryUI/StoryUIPanel.tsx +1973 -388
  184. package/templates/StoryUI/index.tsx +1 -1
  185. package/templates/StoryUI/manager.tsx +264 -0
  186. package/templates/mcp-config-claude.json +11 -0
  187. package/templates/mcp-example.md +76 -0
  188. package/templates/production-app/.env.example +11 -0
  189. package/templates/production-app/index.html +66 -0
  190. package/templates/production-app/package.json +30 -0
  191. package/templates/production-app/public/favicon.svg +5 -0
  192. package/templates/production-app/src/App.tsx +1157 -0
  193. package/templates/production-app/src/LivePreviewRenderer.tsx +420 -0
  194. package/templates/production-app/src/componentRegistry.ts +315 -0
  195. package/templates/production-app/src/considerations.ts +16 -0
  196. package/templates/production-app/src/index.css +284 -0
  197. package/templates/production-app/src/main.tsx +25 -0
  198. package/templates/production-app/tsconfig.json +32 -0
  199. package/templates/production-app/tsconfig.node.json +11 -0
  200. package/templates/production-app/vite.config.ts +83 -0
  201. package/templates/react-import-rule.json +2 -2
  202. package/dist/index.js +0 -12
  203. 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 {
@@ -93,19 +381,28 @@ const syncWithActualStories = async (): Promise<ChatSession[]> => {
93
381
  // Load existing chats
94
382
  const existingChats = loadChats();
95
383
 
96
- // Create a map for quick lookup
384
+ // Create a map for quick lookup - using chat.id as the primary key
97
385
  const chatMap = new Map<string, ChatSession>();
98
386
  existingChats.forEach(chat => {
99
387
  chatMap.set(chat.id, chat);
100
- if (chat.fileName) {
101
- chatMap.set(chat.fileName, chat);
102
- }
103
388
  });
104
389
 
105
390
  // Update or add memory stories
106
391
  memoryStories.forEach((story: any) => {
107
392
  const storyId = story.storyId || story.fileName;
108
- const existingChat = chatMap.get(storyId) || chatMap.get(story.fileName);
393
+
394
+ // Look for existing chat by ID or by matching fileName
395
+ let existingChat = chatMap.get(storyId);
396
+
397
+ // If not found by ID, search by fileName
398
+ if (!existingChat && story.fileName) {
399
+ for (const [id, chat] of chatMap.entries()) {
400
+ if (chat.fileName === story.fileName) {
401
+ existingChat = chat;
402
+ break;
403
+ }
404
+ }
405
+ }
109
406
 
110
407
  if (existingChat) {
111
408
  // Update existing chat with latest info
@@ -148,47 +445,71 @@ const deleteStoryAndChat = async (chatId: string): Promise<boolean> => {
148
445
  // Remove .stories.tsx extension if present to get the actual story ID
149
446
  const storyId = chatId.replace(/\.stories\.tsx$/, '');
150
447
  console.log(`Attempting to delete story: chatId="${chatId}", storyId="${storyId}"`);
151
-
152
- // First try to delete from backend
153
- const response = await fetch(`${DELETE_API_BASE}/${storyId}`, {
154
- method: 'DELETE',
155
- headers: { 'Content-Type': 'application/json' }
156
- });
157
448
 
158
- if (!response.ok) {
159
- console.error('Failed to delete story from backend, trying legacy endpoint');
160
-
161
- // Try legacy endpoint as fallback
162
- const legacyResponse = await fetch(`http://localhost:${getApiPort()}/story-ui/delete`, {
163
- method: 'POST',
164
- headers: { 'Content-Type': 'application/json' },
165
- body: JSON.stringify({
166
- chatId: storyId,
167
- storyId: storyId
168
- })
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' }
169
456
  });
170
-
171
- if (!legacyResponse.ok) {
172
- console.error('Legacy delete endpoint also failed');
173
- 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`);
174
466
  }
467
+ } catch (fetchError) {
468
+ console.warn('Backend delete request failed, trying legacy endpoint:', fetchError);
175
469
  }
176
470
 
177
- // Check if response is JSON
178
- const contentType = response.headers.get('content-type');
179
- if (!contentType || !contentType.includes('application/json')) {
180
- console.error('Server returned non-JSON response, likely server not running or wrong port');
181
- 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
+ }
182
492
  }
183
493
 
184
- // 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
185
496
  const chats = loadChats().filter(chat => chat.id !== chatId);
186
497
  saveChats(chats);
498
+ console.log('Cleaned up localStorage chat entry');
187
499
 
188
500
  return true;
189
501
  } catch (error) {
190
502
  console.error('Error deleting story:', error);
191
- 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
+ }
192
513
  }
193
514
  };
194
515
 
@@ -229,9 +550,9 @@ const STYLES = {
229
550
 
230
551
  // Sidebar
231
552
  sidebar: {
232
- width: '280px',
233
- background: 'rgba(255, 255, 255, 0.05)',
234
- 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)',
235
556
  display: 'flex',
236
557
  flexDirection: 'column' as const,
237
558
  backdropFilter: 'blur(10px)',
@@ -240,70 +561,76 @@ const STYLES = {
240
561
  },
241
562
 
242
563
  sidebarCollapsed: {
243
- width: '60px',
564
+ width: '56px',
244
565
  },
245
566
 
246
567
  sidebarToggle: {
247
568
  width: '100%',
248
- padding: '10px 16px',
249
- background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
569
+ padding: '8px 12px',
570
+ background: '#3b82f6',
250
571
  color: 'white',
251
572
  border: 'none',
252
- borderRadius: '8px',
253
- fontSize: '14px',
573
+ borderRadius: '6px',
574
+ fontSize: '13px',
254
575
  fontWeight: '500',
255
576
  cursor: 'pointer',
256
- marginBottom: '8px',
577
+ marginBottom: '6px',
257
578
  transition: 'all 0.2s ease',
258
- boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
579
+ boxShadow: 'none',
259
580
  display: 'flex',
260
581
  alignItems: 'center',
261
582
  justifyContent: 'center',
262
- gap: '8px',
583
+ gap: '6px',
584
+ lineHeight: '1',
263
585
  },
264
586
 
265
587
  newChatButton: {
266
588
  width: '100%',
267
- padding: '10px 16px',
268
- background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
589
+ padding: '8px 12px',
590
+ background: '#3b82f6',
269
591
  color: 'white',
270
592
  border: 'none',
271
- borderRadius: '8px',
272
- fontSize: '14px',
593
+ borderRadius: '6px',
594
+ fontSize: '13px',
273
595
  fontWeight: '500',
274
596
  cursor: 'pointer',
275
- marginBottom: '16px',
597
+ marginBottom: '12px',
276
598
  transition: 'all 0.2s ease',
277
- 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',
278
605
  },
279
606
 
280
607
  chatItem: {
281
- padding: '12px 16px',
282
- marginBottom: '8px',
283
- background: 'rgba(255, 255, 255, 0.08)',
284
- borderRadius: '8px',
608
+ padding: '8px 10px',
609
+ marginBottom: '4px',
610
+ background: 'rgba(255, 255, 255, 0.05)',
611
+ borderRadius: '6px',
285
612
  cursor: 'pointer',
286
- transition: 'all 0.2s ease',
613
+ transition: 'all 0.15s ease',
287
614
  position: 'relative' as const,
288
- paddingRight: '40px',
615
+ paddingRight: '32px',
289
616
  },
290
617
 
291
618
  chatItemActive: {
292
- background: 'rgba(59, 130, 246, 0.2)',
293
- borderLeft: '3px solid #3b82f6',
619
+ background: 'rgba(59, 130, 246, 0.15)',
620
+ borderLeft: '2px solid #3b82f6',
294
621
  },
295
622
 
296
623
  chatItemTitle: {
297
- fontSize: '14px',
624
+ fontSize: '13px',
298
625
  fontWeight: '500',
299
- marginBottom: '4px',
626
+ marginBottom: '2px',
300
627
  whiteSpace: 'nowrap' as const,
301
628
  overflow: 'hidden',
302
629
  textOverflow: 'ellipsis',
303
630
  },
304
631
 
305
632
  chatItemTime: {
306
- fontSize: '12px',
633
+ fontSize: '11px',
307
634
  color: '#94a3b8',
308
635
  },
309
636
 
@@ -332,15 +659,15 @@ const STYLES = {
332
659
  },
333
660
 
334
661
  chatHeader: {
335
- padding: '20px 24px',
336
- borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
337
- 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)',
338
665
  backdropFilter: 'blur(10px)',
339
666
  },
340
667
 
341
668
  chatContainer: {
342
669
  flex: 1,
343
- padding: '24px',
670
+ padding: '16px',
344
671
  overflowY: 'auto' as const,
345
672
  scrollBehavior: 'smooth' as const,
346
673
  },
@@ -367,33 +694,33 @@ const STYLES = {
367
694
  // Message bubbles
368
695
  messageContainer: {
369
696
  display: 'flex',
370
- marginBottom: '16px',
697
+ marginBottom: '10px',
371
698
  },
372
699
 
373
700
  userMessage: {
374
- background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
701
+ background: '#3b82f6',
375
702
  color: '#ffffff',
376
- borderRadius: '18px 18px 4px 18px',
377
- padding: '12px 16px',
378
- maxWidth: '80%',
703
+ borderRadius: '16px 16px 4px 16px',
704
+ padding: '10px 14px',
705
+ maxWidth: '85%',
379
706
  marginLeft: 'auto',
380
707
  fontSize: '14px',
381
708
  lineHeight: '1.5',
382
709
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
383
- boxShadow: '0 2px 12px rgba(59, 130, 246, 0.3)',
710
+ boxShadow: 'none',
384
711
  wordWrap: 'break-word' as const,
385
712
  },
386
713
 
387
714
  aiMessage: {
388
715
  background: 'rgba(255, 255, 255, 0.95)',
389
716
  color: '#1f2937',
390
- borderRadius: '18px 18px 18px 4px',
391
- padding: '12px 16px',
392
- maxWidth: '80%',
717
+ borderRadius: '16px 16px 16px 4px',
718
+ padding: '10px 14px',
719
+ maxWidth: '85%',
393
720
  fontSize: '14px',
394
721
  lineHeight: '1.5',
395
722
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
396
- boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
723
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.08)',
397
724
  wordWrap: 'break-word' as const,
398
725
  whiteSpace: 'pre-wrap' as const,
399
726
  },
@@ -401,67 +728,67 @@ const STYLES = {
401
728
  loadingMessage: {
402
729
  background: 'rgba(255, 255, 255, 0.9)',
403
730
  color: '#6b7280',
404
- borderRadius: '18px 18px 18px 4px',
405
- padding: '12px 16px',
731
+ borderRadius: '16px 16px 16px 4px',
732
+ padding: '10px 14px',
406
733
  fontSize: '14px',
407
734
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
408
735
  display: 'flex',
409
736
  alignItems: 'center',
410
- gap: '8px',
737
+ gap: '6px',
411
738
  },
412
739
 
413
740
  // Input form
414
741
  inputForm: {
415
742
  display: 'flex',
416
743
  alignItems: 'center',
417
- gap: '12px',
418
- margin: '0 24px 24px 24px',
419
- padding: '16px',
420
- background: 'rgba(255, 255, 255, 0.05)',
421
- borderRadius: '12px',
422
- 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)',
423
750
  backdropFilter: 'blur(10px)',
424
751
  },
425
752
 
426
753
  textInput: {
427
754
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
428
755
  flex: 1,
429
- padding: '12px 16px',
430
- borderRadius: '8px',
431
- border: '1px solid rgba(255, 255, 255, 0.2)',
432
- fontSize: '14px',
756
+ padding: '10px 14px',
757
+ borderRadius: '6px',
758
+ border: '1px solid rgba(255, 255, 255, 0.15)',
759
+ fontSize: '13px',
433
760
  color: '#1f2937',
434
761
  background: '#ffffff',
435
762
  outline: 'none',
436
- transition: 'all 0.2s ease',
763
+ transition: 'all 0.15s ease',
437
764
  boxSizing: 'border-box' as const,
438
765
  },
439
766
 
440
767
  sendButton: {
441
768
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
442
- padding: '12px 20px',
443
- borderRadius: '8px',
769
+ padding: '10px 16px',
770
+ borderRadius: '6px',
444
771
  border: 'none',
445
- background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
772
+ background: '#10b981',
446
773
  color: '#ffffff',
447
- fontSize: '14px',
774
+ fontSize: '13px',
448
775
  fontWeight: '500',
449
776
  cursor: 'pointer',
450
777
  display: 'flex',
451
778
  alignItems: 'center',
452
- gap: '6px',
453
- transition: 'all 0.2s ease',
454
- boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
779
+ gap: '5px',
780
+ transition: 'all 0.15s ease',
781
+ boxShadow: 'none',
455
782
  },
456
783
 
457
784
  errorMessage: {
458
785
  background: 'rgba(248, 113, 113, 0.1)',
459
786
  color: '#f87171',
460
- padding: '12px 16px',
461
- borderRadius: '8px',
462
- fontSize: '14px',
463
- marginBottom: '16px',
464
- 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)',
465
792
  },
466
793
 
467
794
  loadingDots: {
@@ -478,299 +805,1376 @@ const STYLES = {
478
805
 
479
806
  codeBlock: {
480
807
  background: '#1e293b',
481
- padding: '12px 16px',
482
- borderRadius: '8px',
808
+ padding: '10px 12px',
809
+ borderRadius: '6px',
483
810
  fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
484
- fontSize: '13px',
485
- lineHeight: '1.6',
811
+ fontSize: '12px',
812
+ lineHeight: '1.5',
486
813
  overflowX: 'auto' as const,
487
- marginTop: '8px',
488
- border: '1px solid rgba(255, 255, 255, 0.1)',
814
+ marginTop: '6px',
815
+ border: '1px solid rgba(255, 255, 255, 0.08)',
489
816
  },
490
- };
491
817
 
492
- // Add custom style for loading animation
493
- const styleSheet = document.createElement('style');
494
- styleSheet.textContent = `
495
- @keyframes loadingDots {
496
- 0%, 20% { content: "."; }
497
- 40% { content: ".."; }
498
- 60%, 100% { content: "..."; }
499
- }
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
+ },
500
826
 
501
- .loading-dots::after {
502
- content: ".";
503
- animation: loadingDots 1.4s infinite;
504
- }
505
- `;
506
- 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
+ },
507
834
 
508
- // Helper function to format timestamp
509
- const formatTime = (timestamp: number): string => {
510
- const date = new Date(timestamp);
511
- const now = new Date();
512
- const diffMs = now.getTime() - date.getTime();
513
- const diffMins = Math.floor(diffMs / 60000);
514
- const diffHours = Math.floor(diffMs / 3600000);
515
- 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
+ },
516
844
 
517
- if (diffMins < 1) return 'just now';
518
- if (diffMins < 60) return `${diffMins}m ago`;
519
- if (diffHours < 24) return `${diffHours}h ago`;
520
- if (diffDays < 7) return `${diffDays}d ago`;
521
- return date.toLocaleDateString();
522
- };
845
+ intentStrategy: {
846
+ fontSize: '12px',
847
+ color: '#4b5563',
848
+ marginBottom: '4px',
849
+ },
523
850
 
524
- // Main component
525
- export function StoryUIPanel() {
526
- const [input, setInput] = useState('');
527
- const [conversation, setConversation] = useState<Message[]>([]);
528
- const [loading, setLoading] = useState(false);
529
- const [error, setError] = useState<string | null>(null);
530
- const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
531
- const [activeChatId, setActiveChatId] = useState<string | null>(null);
532
- const [activeTitle, setActiveTitle] = useState<string>('');
533
- const [sidebarOpen, setSidebarOpen] = useState(true);
534
- const [connectionStatus, setConnectionStatus] = useState<{ connected: boolean; error?: string }>({ connected: false });
535
- const chatEndRef = useRef<HTMLDivElement | null>(null);
536
- const inputRef = useRef<HTMLInputElement | null>(null);
851
+ intentComponents: {
852
+ display: 'flex',
853
+ flexWrap: 'wrap' as const,
854
+ gap: '4px',
855
+ marginTop: '6px',
856
+ },
537
857
 
538
- // Load and sync chats on mount
539
- useEffect(() => {
540
- const initializeChats = async () => {
541
- // Test connection first
542
- const connectionTest = await testMCPConnection();
543
- setConnectionStatus(connectionTest);
544
-
545
- if (connectionTest.connected) {
546
- const syncedChats = await syncWithActualStories();
547
- const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
548
- 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
+ },
549
866
 
550
- if (sortedChats.length > 0) {
551
- setConversation(sortedChats[0].conversation);
552
- setActiveChatId(sortedChats[0].id);
553
- setActiveTitle(sortedChats[0].title);
554
- }
555
- } else {
556
- // Load from local storage if server is not available
557
- const localChats = loadChats();
558
- setRecentChats(localChats);
559
- }
560
- };
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
+ },
561
875
 
562
- initializeChats();
563
- }, []);
876
+ progressFill: {
877
+ background: '#3b82f6',
878
+ height: '100%',
879
+ borderRadius: '3px',
880
+ transition: 'width 0.3s ease',
881
+ },
564
882
 
565
- // Scroll to bottom on new message
566
- useEffect(() => {
567
- if (chatEndRef.current) {
568
- chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
569
- }
570
- }, [conversation, loading]);
883
+ progressPhase: {
884
+ fontSize: '11px',
885
+ color: '#6b7280',
886
+ display: 'flex',
887
+ alignItems: 'center',
888
+ gap: '5px',
889
+ },
571
890
 
572
- const handleSend = async (e?: React.FormEvent) => {
573
- if (e) e.preventDefault();
574
- if (!input.trim()) return;
575
- setError(null);
576
- setLoading(true);
577
-
578
- // Test connection before sending
579
- const connectionTest = await testMCPConnection();
580
- setConnectionStatus(connectionTest);
581
-
582
- if (!connectionTest.connected) {
583
- setError(`Cannot connect to MCP server: ${connectionTest.error || 'Server not running'}`);
584
- setLoading(false);
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) => {
1223
+ try {
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;
1470
+ }
1471
+
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
+ }
1477
+
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
+ }
1492
+
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);
1511
+ }
1512
+ return prev.filter(img => img.id !== id);
1513
+ });
1514
+ };
1515
+
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');
585
1558
  return;
586
1559
  }
587
-
588
- const newConversation = [...conversation, { role: 'user' as const, content: input }];
589
- setConversation(newConversation);
590
- setInput('');
591
- try {
592
- const res = await fetch(MCP_API, {
593
- method: 'POST',
594
- headers: { 'Content-Type': 'application/json' },
595
- body: JSON.stringify({
596
- prompt: input,
597
- conversation: newConversation,
598
- fileName: activeChatId || undefined,
599
- }),
600
- });
601
-
602
- // Check if response is JSON
603
- const contentType = res.headers.get('content-type');
604
- if (!contentType || !contentType.includes('application/json')) {
605
- const text = await res.text();
606
- throw new Error(`Server returned non-JSON response (likely server not running or wrong port). Response: ${text.substring(0, 200)}...`);
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;
607
1570
  }
608
-
609
- const data = await res.json();
610
- if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
611
1571
 
612
- // Create user-friendly response message instead of showing raw markup
613
- let responseMessage: string;
614
- let statusIcon = '✅';
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
+ }
615
1586
 
616
- // Check for validation issues
617
- if (data.validation && data.validation.hasWarnings) {
618
- statusIcon = '⚠️';
619
- const warningCount = data.validation.warnings.length;
620
- const errorCount = data.validation.errors.length;
1587
+ if (errors.length > 0) {
1588
+ setError(errors.join('\n'));
1589
+ }
621
1590
 
622
- if (errorCount > 0) {
623
- statusIcon = '🔧';
624
- }
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;
1598
+
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]);
625
1603
  }
1604
+ }
626
1605
 
627
- if (data.isUpdate) {
628
- 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.`;
629
- } else {
630
- 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.`;
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
+ }
631
1632
 
632
- // IMPORTANT: Add a note about refreshing for new stories
633
- 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.';
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');
634
1649
  }
1650
+ }
1651
+
1652
+ if (errors.length > 0) {
1653
+ setError(errors.join('\n'));
1654
+ }
635
1655
 
636
- // Add validation information if there are issues
637
- if (data.validation && data.validation.hasWarnings) {
638
- responseMessage += '\n\n';
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]);
639
1664
 
640
- if (data.validation.errors.length > 0) {
641
- responseMessage += `🔧 **Auto-fixed ${data.validation.errors.length} syntax error(s):**\n`;
642
- responseMessage += data.validation.errors.slice(0, 3).map(error => ` • ${error}`).join('\n');
643
- if (data.validation.errors.length > 3) {
644
- 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
+ }
645
1684
  }
646
- responseMessage += '\n';
1685
+ } catch (e) {
1686
+ console.error('Failed to fetch providers:', e);
647
1687
  }
648
1688
 
649
- if (data.validation.warnings.length > 0) {
650
- responseMessage += `⚠️ **Warnings:**\n`;
651
- responseMessage += data.validation.warnings.slice(0, 2).map(warning => ` • ${warning}`).join('\n');
652
- if (data.validation.warnings.length > 2) {
653
- 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
+ }
654
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);
655
1712
  }
1713
+ } else {
1714
+ // Load from local storage if server is not available
1715
+ const localChats = loadChats();
1716
+ setRecentChats(localChats);
656
1717
  }
1718
+ };
1719
+
1720
+ initializeChats();
1721
+ }, []);
1722
+
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');
657
1753
 
658
- const aiMsg = { role: 'ai' as const, content: responseMessage };
659
- const updatedConversation = [...newConversation, aiMsg];
660
- setConversation(updatedConversation);
661
-
662
- // Determine if this is an update or new chat
663
- // Check if we have an active chat AND the backend indicates this is an update
664
- const isUpdate = activeChatId && conversation.length > 0 && (
665
- data.isUpdate ||
666
- data.fileName === activeChatId ||
667
- // Also check if fileName matches any existing chat's fileName
668
- recentChats.some(chat => chat.fileName === data.fileName && chat.id === activeChatId)
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'
669
1781
  );
670
1782
 
671
- console.log('Update detection:', {
672
- activeChatId,
673
- conversationLength: conversation.length,
674
- dataIsUpdate: data.isUpdate,
675
- dataFileName: data.fileName,
676
- isUpdate
677
- });
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
+ }
678
1796
 
679
- if (isUpdate) {
680
- // Update existing chat session
681
- const chatTitle = activeTitle; // Keep existing title for updates
682
- const updatedSession: ChatSession = {
683
- id: activeChatId,
684
- title: chatTitle,
685
- fileName: data.fileName || activeChatId,
686
- conversation: updatedConversation,
687
- lastUpdated: Date.now(),
688
- };
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
+ }
689
1802
 
690
- const chats = loadChats();
691
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
692
- if (chatIndex !== -1) {
693
- chats[chatIndex] = updatedSession;
694
- }
695
- saveChats(chats);
696
- setRecentChats(chats);
697
- 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._`);
698
1833
  } else {
699
- // Create new chat session - use storyId from backend for consistency
700
- const chatId = data.storyId || data.fileName || data.outPath || Date.now().toString();
701
- const chatTitle = data.title || input;
702
- setActiveChatId(chatId);
703
- setActiveTitle(chatTitle);
704
-
705
- const newSession: ChatSession = {
706
- id: chatId,
707
- title: chatTitle,
708
- fileName: data.fileName || '',
709
- conversation: updatedConversation,
710
- lastUpdated: Date.now(),
711
- };
1834
+ parts.push(`\n\n_Refresh Storybook (Cmd/Ctrl + R) to see new stories in the sidebar._`);
1835
+ }
1836
+ hasShownRefreshHint.current = true;
1837
+ }
712
1838
 
713
- const chats = loadChats().filter(c => c.id !== chatId);
714
- chats.unshift(newSession);
715
- if (chats.length > MAX_RECENT_CHATS) {
716
- chats.splice(MAX_RECENT_CHATS);
717
- }
718
- saveChats(chats);
719
- setRecentChats(chats);
720
- 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;
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);
721
1899
  }
1900
+ saveChats(chats);
1901
+ setRecentChats(chats);
1902
+ }
1903
+ }, [activeChatId, activeTitle, conversation.length]);
722
1904
 
723
- } catch (err: unknown) {
724
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
725
- setError(errorMessage);
726
- const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
727
- setConversation(errorConversation);
728
-
729
- // IMPORTANT: Create/update chat session even on error so retries continue the same conversation
730
- const isUpdate = activeChatId && conversation.length > 0;
731
-
732
- if (isUpdate) {
733
- // Update existing chat with error
734
- const updatedSession: ChatSession = {
735
- id: activeChatId,
736
- title: activeTitle,
737
- fileName: activeChatId,
738
- conversation: errorConversation,
739
- lastUpdated: Date.now(),
740
- };
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
+ }
741
1925
 
742
- const chats = loadChats();
743
- const chatIndex = chats.findIndex(c => c.id === activeChatId);
744
- if (chatIndex !== -1) {
745
- chats[chatIndex] = updatedSession;
1926
+ // Capture images before clearing
1927
+ const imagesToSend = [...attachedImages];
1928
+ const hasImages = imagesToSend.length > 0;
1929
+
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}`);
1983
+ }
1984
+
1985
+ const reader = response.body?.getReader();
1986
+ if (!reader) {
1987
+ throw new Error('No response body');
1988
+ }
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);
746
2047
  }
747
- saveChats(chats);
748
- setRecentChats(chats);
749
- } else {
750
- // Create new chat session for error (so retries can continue it)
751
- const chatId = `error-${Date.now()}`;
752
- const chatTitle = input.length > 30 ? input.substring(0, 30) + '...' : input;
753
- setActiveChatId(chatId);
754
- setActiveTitle(chatTitle);
755
-
756
- const newSession: ChatSession = {
757
- id: chatId,
758
- title: chatTitle,
759
- fileName: '',
760
- conversation: errorConversation,
761
- lastUpdated: Date.now(),
762
- };
763
2048
 
764
- const chats = loadChats();
765
- chats.unshift(newSession);
766
- if (chats.length > MAX_RECENT_CHATS) {
767
- chats.splice(MAX_RECENT_CHATS);
2049
+ } catch (err: unknown) {
2050
+ if ((err as Error).name === 'AbortError') {
2051
+ console.log('Request aborted');
2052
+ return;
768
2053
  }
769
- saveChats(chats);
770
- setRecentChats(chats);
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);
771
2177
  }
772
- } finally {
773
- setLoading(false);
774
2178
  }
775
2179
  };
776
2180
 
@@ -835,7 +2239,8 @@ export function StoryUIPanel() {
835
2239
  e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.3)';
836
2240
  }}
837
2241
  >
838
- Chats
2242
+ <span style={{ lineHeight: '0.5', display: 'inline-block', alignItems: 'center', width: '10px', height: '10px' }}>☰</span>
2243
+ <span>Chats</span>
839
2244
  </button>
840
2245
  <button
841
2246
  onClick={handleNewChat}
@@ -849,7 +2254,8 @@ export function StoryUIPanel() {
849
2254
  e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.2)';
850
2255
  }}
851
2256
  >
852
- + New Chat
2257
+ <span style={{ lineHeight: '0.5', display: 'inline-block', alignItems: 'center', width: '10px', height: '10px' }}>+</span>
2258
+ <span>New Chat</span>
853
2259
  </button>
854
2260
  {recentChats.length > 0 && (
855
2261
  <div style={{
@@ -901,34 +2307,53 @@ export function StoryUIPanel() {
901
2307
  </div>
902
2308
  )}
903
2309
  {!sidebarOpen && (
904
- <div style={{ padding: '16px' }}>
2310
+ <div style={{ padding: '8px', display: 'flex', justifyContent: 'center' }}>
905
2311
  <button
906
2312
  onClick={() => setSidebarOpen(true)}
907
2313
  style={{
908
2314
  ...STYLES.sidebarToggle,
909
- width: '40px',
910
- height: '40px',
2315
+ width: '38px',
2316
+ height: '38px',
911
2317
  padding: '0',
912
2318
  fontSize: '16px',
2319
+ borderRadius: '8px',
913
2320
  }}
914
2321
  title="Expand sidebar"
915
2322
  onMouseEnter={(e) => {
916
2323
  e.currentTarget.style.transform = 'scale(1.05)';
917
- e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.4)';
2324
+ e.currentTarget.style.background = '#2563eb';
918
2325
  }}
919
2326
  onMouseLeave={(e) => {
920
2327
  e.currentTarget.style.transform = 'scale(1)';
921
- e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.3)';
2328
+ e.currentTarget.style.background = '#3b82f6';
922
2329
  }}
923
2330
  >
924
-
2331
+ <span style={{ lineHeight: '0.4', display: 'inline-block', height: '10px' }}>☰</span>
925
2332
  </button>
926
2333
  </div>
927
2334
  )}
928
2335
  </div>
929
2336
 
930
2337
  {/* Main content */}
931
- <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
+
932
2357
  <div style={STYLES.chatHeader}>
933
2358
  <h1 style={{
934
2359
  fontSize: '24px',
@@ -958,12 +2383,74 @@ export function StoryUIPanel() {
958
2383
  backgroundColor: connectionStatus.connected ? '#10b981' : '#f87171'
959
2384
  }}></div>
960
2385
  <span style={{ color: connectionStatus.connected ? '#10b981' : '#f87171' }}>
961
- {connectionStatus.connected
962
- ? `Connected to MCP server (port ${getApiPort()})`
2386
+ {connectionStatus.connected
2387
+ ? `Connected to ${getConnectionDisplayText()}`
963
2388
  : `Disconnected: ${connectionStatus.error || 'Server not running'}`
964
2389
  }
965
2390
  </span>
966
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
+ )}
967
2454
  </div>
968
2455
 
969
2456
  <div style={STYLES.chatContainer}>
@@ -985,30 +2472,125 @@ export function StoryUIPanel() {
985
2472
  {conversation.map((msg, i) => (
986
2473
  <div key={i} style={STYLES.messageContainer}>
987
2474
  <div style={msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage}>
988
- {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
+ )}
989
2489
  </div>
990
2490
  </div>
991
2491
  ))}
992
2492
 
993
2493
  {loading && (
994
2494
  <div style={STYLES.messageContainer}>
995
- <div style={STYLES.loadingMessage}>
996
- <span>Generating story</span>
997
- <span className="loading-dots"></span>
998
- </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
+ )}
999
2503
  </div>
1000
2504
  )}
1001
2505
 
1002
2506
  <div ref={chatEndRef} />
1003
2507
  </div>
1004
2508
 
1005
- <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
+
1006
2584
  <input
1007
2585
  ref={inputRef}
1008
2586
  type="text"
1009
2587
  value={input}
1010
2588
  onChange={e => setInput(e.target.value)}
1011
- 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
+ }
1012
2594
  style={STYLES.textInput}
1013
2595
  onFocus={(e) => {
1014
2596
  e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
@@ -1021,10 +2603,10 @@ export function StoryUIPanel() {
1021
2603
  />
1022
2604
  <button
1023
2605
  type="submit"
1024
- disabled={loading || !input.trim()}
2606
+ disabled={loading || (!input.trim() && attachedImages.length === 0)}
1025
2607
  style={{
1026
2608
  ...STYLES.sendButton,
1027
- ...(loading || !input.trim() ? {
2609
+ ...(loading || (!input.trim() && attachedImages.length === 0) ? {
1028
2610
  opacity: 0.5,
1029
2611
  cursor: 'not-allowed',
1030
2612
  background: '#6b7280',
@@ -1032,7 +2614,7 @@ export function StoryUIPanel() {
1032
2614
  } : {})
1033
2615
  }}
1034
2616
  onMouseEnter={(e) => {
1035
- if (!loading && input.trim()) {
2617
+ if (!loading && (input.trim() || attachedImages.length > 0)) {
1036
2618
  e.currentTarget.style.transform = 'scale(1.05)';
1037
2619
  e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
1038
2620
  }
@@ -1052,3 +2634,6 @@ export function StoryUIPanel() {
1052
2634
  </div>
1053
2635
  );
1054
2636
  }
2637
+
2638
+ export default StoryUIPanel;
2639
+ export { StoryUIPanel };