@tpitre/story-ui 2.3.0 → 2.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "AI-powered Storybook story generator with dynamic component discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,6 +38,28 @@ interface Conversation {
38
38
  updatedAt: number;
39
39
  }
40
40
 
41
+ interface ProviderOption {
42
+ id: string;
43
+ name: string;
44
+ isDefault?: boolean;
45
+ }
46
+
47
+ interface ModelOption {
48
+ id: string;
49
+ name: string;
50
+ provider?: string;
51
+ }
52
+
53
+ interface ServerConfig {
54
+ providers: ProviderOption[];
55
+ currentProvider: string;
56
+ models: ModelOption[];
57
+ currentModel: string;
58
+ isConfigured: boolean;
59
+ loading: boolean;
60
+ error: string | null;
61
+ }
62
+
41
63
  // ============================================================================
42
64
  // CONSTANTS & CONFIG
43
65
  // ============================================================================
@@ -85,11 +107,16 @@ const useLocalStorage = <T,>(key: string, initialValue: T): [T, React.Dispatch<R
85
107
  }
86
108
  });
87
109
 
88
- const setValue: React.Dispatch<React.SetStateAction<T>> = (value) => {
89
- const valueToStore = value instanceof Function ? value(storedValue) : value;
110
+ // Use a ref to always have access to the latest value for functional updates
111
+ const storedValueRef = useRef(storedValue);
112
+ storedValueRef.current = storedValue;
113
+
114
+ const setValue: React.Dispatch<React.SetStateAction<T>> = useCallback((value) => {
115
+ const valueToStore = value instanceof Function ? value(storedValueRef.current) : value;
116
+ storedValueRef.current = valueToStore;
90
117
  setStoredValue(valueToStore);
91
118
  window.localStorage.setItem(key, JSON.stringify(valueToStore));
92
- };
119
+ }, [key]);
93
120
 
94
121
  return [storedValue, setValue];
95
122
  };
@@ -133,6 +160,95 @@ const useResizable = (initialWidth: number, minWidth: number, maxWidth: number)
133
160
  return { width, startResize };
134
161
  };
135
162
 
163
+ // Default models for fallback
164
+ const DEFAULT_MODELS: Record<string, ModelOption[]> = {
165
+ claude: [
166
+ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', provider: 'claude' },
167
+ { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'claude' },
168
+ { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', provider: 'claude' },
169
+ ],
170
+ openai: [
171
+ { id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
172
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai' },
173
+ { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
174
+ ],
175
+ gemini: [
176
+ { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'gemini' },
177
+ { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', provider: 'gemini' },
178
+ { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', provider: 'gemini' },
179
+ ],
180
+ };
181
+
182
+ const useServerConfig = () => {
183
+ const [config, setConfig] = useState<ServerConfig>({
184
+ providers: [],
185
+ currentProvider: 'claude',
186
+ models: DEFAULT_MODELS.claude,
187
+ currentModel: DEFAULT_MODELS.claude[0].id,
188
+ isConfigured: false,
189
+ loading: true,
190
+ error: null,
191
+ });
192
+
193
+ const fetchConfig = useCallback(async () => {
194
+ setConfig(prev => ({ ...prev, loading: true, error: null }));
195
+ try {
196
+ const response = await fetch(`${SERVER_URL}/story-ui/providers`);
197
+ if (response.ok) {
198
+ const data = await response.json();
199
+ const providers = data.providers || [];
200
+ const defaultProvider = providers.find((p: ProviderOption) => p.isDefault)?.id || providers[0]?.id || 'claude';
201
+ const models = DEFAULT_MODELS[defaultProvider] || DEFAULT_MODELS.claude;
202
+ setConfig({
203
+ providers,
204
+ currentProvider: defaultProvider,
205
+ models,
206
+ currentModel: models[0]?.id || '',
207
+ isConfigured: true,
208
+ loading: false,
209
+ error: null,
210
+ });
211
+ } else {
212
+ throw new Error('Failed to fetch providers');
213
+ }
214
+ } catch (err) {
215
+ // Fallback to default Claude config
216
+ setConfig({
217
+ providers: [{ id: 'claude', name: 'Claude (Anthropic)', isDefault: true }],
218
+ currentProvider: 'claude',
219
+ models: DEFAULT_MODELS.claude,
220
+ currentModel: DEFAULT_MODELS.claude[0].id,
221
+ isConfigured: true,
222
+ loading: false,
223
+ error: null,
224
+ });
225
+ }
226
+ }, []);
227
+
228
+ useEffect(() => {
229
+ fetchConfig();
230
+ }, [fetchConfig]);
231
+
232
+ const changeProvider = useCallback((providerId: string) => {
233
+ const models = DEFAULT_MODELS[providerId] || DEFAULT_MODELS.claude;
234
+ setConfig(prev => ({
235
+ ...prev,
236
+ currentProvider: providerId,
237
+ models,
238
+ currentModel: models[0]?.id || '',
239
+ }));
240
+ }, []);
241
+
242
+ const changeModel = useCallback((modelId: string) => {
243
+ setConfig(prev => ({
244
+ ...prev,
245
+ currentModel: modelId,
246
+ }));
247
+ }, []);
248
+
249
+ return { ...config, refetch: fetchConfig, changeProvider, changeModel };
250
+ };
251
+
136
252
  // ============================================================================
137
253
  // ICON COMPONENTS
138
254
  // ============================================================================
@@ -193,12 +309,120 @@ const Icons = {
193
309
  <path d="M18 6L6 18M6 6l12 12" />
194
310
  </svg>
195
311
  ),
312
+ ChevronDown: () => (
313
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
314
+ <path d="M6 9l6 6 6-6" />
315
+ </svg>
316
+ ),
196
317
  };
197
318
 
198
319
  // ============================================================================
199
320
  // SUB-COMPONENTS
200
321
  // ============================================================================
201
322
 
323
+ // Dropdown component for provider/model selection
324
+ const Dropdown: React.FC<{
325
+ label: string;
326
+ value: string;
327
+ options: { id: string; name: string }[];
328
+ onChange: (value: string) => void;
329
+ disabled?: boolean;
330
+ }> = ({ label, value, options, onChange, disabled }) => {
331
+ const [isOpen, setIsOpen] = useState(false);
332
+ const ref = useRef<HTMLDivElement>(null);
333
+ const selectedOption = options.find(o => o.id === value);
334
+
335
+ useEffect(() => {
336
+ const handleClickOutside = (e: MouseEvent) => {
337
+ if (ref.current && !ref.current.contains(e.target as Node)) {
338
+ setIsOpen(false);
339
+ }
340
+ };
341
+ document.addEventListener('mousedown', handleClickOutside);
342
+ return () => document.removeEventListener('mousedown', handleClickOutside);
343
+ }, []);
344
+
345
+ return (
346
+ <div ref={ref} style={{ position: 'relative', width: '100%' }}>
347
+ <button
348
+ onClick={() => !disabled && setIsOpen(!isOpen)}
349
+ disabled={disabled}
350
+ style={{
351
+ display: 'flex',
352
+ alignItems: 'center',
353
+ justifyContent: 'space-between',
354
+ width: '100%',
355
+ padding: '8px 10px',
356
+ background: THEME.bgElevated,
357
+ border: `1px solid ${THEME.border}`,
358
+ borderRadius: '6px',
359
+ color: disabled ? THEME.textSubtle : THEME.text,
360
+ fontSize: '12px',
361
+ cursor: disabled ? 'not-allowed' : 'pointer',
362
+ transition: 'all 0.2s',
363
+ opacity: disabled ? 0.6 : 1,
364
+ }}
365
+ >
366
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px' }}>
367
+ <span style={{ color: THEME.textSubtle, fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
368
+ {label}
369
+ </span>
370
+ <span style={{ fontWeight: 500 }}>{selectedOption?.name || 'Select'}</span>
371
+ </div>
372
+ <Icons.ChevronDown />
373
+ </button>
374
+ {isOpen && !disabled && (
375
+ <div
376
+ style={{
377
+ position: 'absolute',
378
+ bottom: '100%',
379
+ left: 0,
380
+ right: 0,
381
+ marginBottom: '4px',
382
+ maxHeight: '200px',
383
+ overflowY: 'auto',
384
+ background: THEME.bgElevated,
385
+ border: `1px solid ${THEME.border}`,
386
+ borderRadius: '6px',
387
+ boxShadow: '0 -4px 12px rgba(0,0,0,0.3)',
388
+ zIndex: 100,
389
+ }}
390
+ >
391
+ {options.map(option => (
392
+ <button
393
+ key={option.id}
394
+ onClick={() => {
395
+ onChange(option.id);
396
+ setIsOpen(false);
397
+ }}
398
+ style={{
399
+ display: 'block',
400
+ width: '100%',
401
+ padding: '8px 10px',
402
+ background: option.id === value ? THEME.accentMuted : 'transparent',
403
+ border: 'none',
404
+ textAlign: 'left',
405
+ color: THEME.text,
406
+ fontSize: '12px',
407
+ cursor: 'pointer',
408
+ transition: 'background 0.15s',
409
+ }}
410
+ onMouseEnter={e => {
411
+ if (option.id !== value) e.currentTarget.style.background = THEME.bgHover;
412
+ }}
413
+ onMouseLeave={e => {
414
+ e.currentTarget.style.background = option.id === value ? THEME.accentMuted : 'transparent';
415
+ }}
416
+ >
417
+ {option.name}
418
+ </button>
419
+ ))}
420
+ </div>
421
+ )}
422
+ </div>
423
+ );
424
+ };
425
+
202
426
  const LoadingDots: React.FC = () => (
203
427
  <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
204
428
  {[0, 1, 2].map(i => (
@@ -390,6 +614,9 @@ const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
390
614
  // ============================================================================
391
615
 
392
616
  const App: React.FC = () => {
617
+ // Server configuration (providers, models)
618
+ const serverConfig = useServerConfig();
619
+
393
620
  const [conversations, setConversations] = useLocalStorage<Conversation[]>('storyui_conversations', []);
394
621
  const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
395
622
  const [inputValue, setInputValue] = useState('');
@@ -405,45 +632,57 @@ const App: React.FC = () => {
405
632
 
406
633
  const activeConversation = conversations.find(c => c.id === activeConversationId);
407
634
 
635
+ // Filter out empty conversations for display (deferred creation)
636
+ const displayConversations = conversations.filter(c => c.messages.length > 0);
637
+
408
638
  // Auto-scroll messages
409
639
  useEffect(() => {
410
640
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
411
641
  }, [activeConversation?.messages]);
412
642
 
413
- // Initialize first conversation
643
+ // Initialize - select existing conversation or stay in "new chat" mode
414
644
  useEffect(() => {
415
- if (conversations.length > 0 && !activeConversationId) {
416
- setActiveConversationId(conversations[0].id);
417
- const lastMsgWithCode = [...conversations[0].messages].reverse().find(m => m.generatedCode);
645
+ // Only select an existing conversation if we don't have one selected
646
+ // and there are conversations with messages
647
+ if (!activeConversationId && displayConversations.length > 0) {
648
+ setActiveConversationId(displayConversations[0].id);
649
+ const lastMsgWithCode = [...displayConversations[0].messages].reverse().find(m => m.generatedCode);
418
650
  setPreviewCode(lastMsgWithCode?.generatedCode || null);
419
- } else if (conversations.length === 0) {
420
- createNewConversation();
421
651
  }
422
- }, [conversations.length, activeConversationId]);
652
+ // Don't create an empty conversation - we'll create one when user sends first message
653
+ }, [displayConversations.length, activeConversationId]);
654
+
655
+ // Start a new chat - just clear state, don't create empty conversation
656
+ const startNewChat = useCallback(() => {
657
+ setActiveConversationId(null);
658
+ setPreviewCode(null);
659
+ setImages([]);
660
+ inputRef.current?.focus();
661
+ }, []);
423
662
 
424
- const createNewConversation = useCallback(() => {
663
+ // Actually create a conversation when first message is sent
664
+ const createConversationWithMessage = useCallback((message: Message): string => {
425
665
  const newConversation: Conversation = {
426
666
  id: generateId(),
427
- title: 'New Conversation',
428
- messages: [],
667
+ title: message.content.substring(0, 40),
668
+ messages: [message],
429
669
  createdAt: Date.now(),
430
670
  updatedAt: Date.now(),
431
671
  };
432
672
  setConversations(prev => [newConversation, ...prev]);
433
- setActiveConversationId(newConversation.id);
434
- setPreviewCode(null);
435
- setImages([]);
436
- inputRef.current?.focus();
673
+ return newConversation.id;
437
674
  }, [setConversations]);
438
675
 
439
676
  const deleteConversation = (id: string) => {
440
677
  setConversations(prev => prev.filter(c => c.id !== id));
441
678
  if (activeConversationId === id) {
442
- const remaining = conversations.filter(c => c.id !== id);
679
+ const remaining = displayConversations.filter(c => c.id !== id);
443
680
  if (remaining.length > 0) {
444
681
  setActiveConversationId(remaining[0].id);
445
682
  } else {
446
- createNewConversation();
683
+ // Go to "new chat" mode instead of creating empty conversation
684
+ setActiveConversationId(null);
685
+ setPreviewCode(null);
447
686
  }
448
687
  }
449
688
  };
@@ -522,6 +761,8 @@ MODIFICATION RULES:
522
761
  3. Preserve existing styling, layout, and components not mentioned
523
762
  4. Output the COMPLETE modified JSX (not just the changed parts)
524
763
 
764
+ IMPORTANT: User requests OVERRIDE design system defaults. If the user asks to change colors, styling, or any other aspect, follow their request exactly - even if it differs from the design system guidelines above.
765
+
525
766
  OUTPUT: Start immediately with < and output only the complete modified JSX.`;
526
767
  } else {
527
768
  // New generation prompt
@@ -548,6 +789,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
548
789
  messages: conversationHistory,
549
790
  systemPrompt,
550
791
  prefillAssistant,
792
+ model: serverConfig.currentModel,
551
793
  maxTokens: 4096,
552
794
  images: imageAttachments.map(img => ({
553
795
  type: img.type,
@@ -588,7 +830,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
588
830
  };
589
831
 
590
832
  const sendMessage = async () => {
591
- if (!inputValue.trim() || !activeConversationId || isGenerating) return;
833
+ if (!inputValue.trim() || isGenerating) return;
592
834
 
593
835
  const userMessage: Message = {
594
836
  id: generateId(),
@@ -598,17 +840,26 @@ OUTPUT: Start immediately with < and output only JSX.`;
598
840
  images: images.length > 0 ? [...images] : undefined,
599
841
  };
600
842
 
601
- setConversations(prev => prev.map(conv => {
602
- if (conv.id === activeConversationId) {
603
- return {
604
- ...conv,
605
- messages: [...conv.messages, userMessage],
606
- updatedAt: Date.now(),
607
- title: conv.messages.length === 0 ? inputValue.trim().substring(0, 40) : conv.title,
608
- };
609
- }
610
- return conv;
611
- }));
843
+ // Determine if we need to create a new conversation or add to existing
844
+ let conversationId = activeConversationId;
845
+
846
+ if (!conversationId) {
847
+ // Create new conversation with the first message
848
+ conversationId = createConversationWithMessage(userMessage);
849
+ setActiveConversationId(conversationId);
850
+ } else {
851
+ // Add message to existing conversation
852
+ setConversations(prev => prev.map(conv => {
853
+ if (conv.id === conversationId) {
854
+ return {
855
+ ...conv,
856
+ messages: [...conv.messages, userMessage],
857
+ updatedAt: Date.now(),
858
+ };
859
+ }
860
+ return conv;
861
+ }));
862
+ }
612
863
 
613
864
  const currentImages = [...images];
614
865
  setInputValue('');
@@ -627,7 +878,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
627
878
  };
628
879
 
629
880
  setConversations(prev => prev.map(conv => {
630
- if (conv.id === activeConversationId) {
881
+ if (conv.id === conversationId) {
631
882
  return {
632
883
  ...conv,
633
884
  messages: [...conv.messages, assistantMessage],
@@ -650,7 +901,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
650
901
  };
651
902
 
652
903
  setConversations(prev => prev.map(conv => {
653
- if (conv.id === activeConversationId) {
904
+ if (conv.id === conversationId) {
654
905
  return {
655
906
  ...conv,
656
907
  messages: [...conv.messages, errorMessage],
@@ -740,7 +991,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
740
991
  {/* New Chat Button */}
741
992
  <div style={{ padding: sidebarCollapsed ? '8px' : '12px' }}>
742
993
  <button
743
- onClick={createNewConversation}
994
+ onClick={startNewChat}
744
995
  style={{
745
996
  width: '100%',
746
997
  padding: sidebarCollapsed ? '10px' : '10px 14px',
@@ -765,7 +1016,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
765
1016
  {/* Conversation List */}
766
1017
  {!sidebarCollapsed && (
767
1018
  <div style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
768
- {conversations.map(conv => (
1019
+ {displayConversations.map(conv => (
769
1020
  <div
770
1021
  key={conv.id}
771
1022
  onClick={() => {
@@ -815,15 +1066,62 @@ OUTPUT: Start immediately with < and output only JSX.`;
815
1066
  </div>
816
1067
  )}
817
1068
 
818
- {/* Components Badge */}
1069
+ {/* Sidebar Footer - Provider/Model Info */}
819
1070
  {!sidebarCollapsed && (
820
1071
  <div style={{
821
1072
  padding: '12px',
822
1073
  borderTop: `1px solid ${THEME.border}`,
823
- fontSize: '11px',
824
- color: THEME.textSubtle,
1074
+ display: 'flex',
1075
+ flexDirection: 'column',
1076
+ gap: '6px',
825
1077
  }}>
826
- <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
1078
+ {/* Provider Label */}
1079
+ <div style={{ fontSize: '12px', color: THEME.textMuted }}>
1080
+ <span style={{ color: THEME.textSubtle, textTransform: 'uppercase', fontSize: '10px', letterSpacing: '0.5px' }}>Provider: </span>
1081
+ <span style={{ color: THEME.text, fontWeight: 500 }}>
1082
+ {serverConfig.providers.find(p => p.id === serverConfig.currentProvider)?.name || 'Claude (Anthropic)'}
1083
+ </span>
1084
+ </div>
1085
+
1086
+ {/* Model Dropdown */}
1087
+ <div style={{ fontSize: '12px', color: THEME.textMuted }}>
1088
+ <span style={{ color: THEME.textSubtle, textTransform: 'uppercase', fontSize: '10px', letterSpacing: '0.5px' }}>Model</span>
1089
+ </div>
1090
+ <select
1091
+ value={serverConfig.currentModel}
1092
+ onChange={(e) => serverConfig.changeModel(e.target.value)}
1093
+ disabled={serverConfig.loading}
1094
+ style={{
1095
+ width: '100%',
1096
+ padding: '8px 10px',
1097
+ background: THEME.bgElevated,
1098
+ border: `1px solid ${THEME.border}`,
1099
+ borderRadius: '6px',
1100
+ color: THEME.text,
1101
+ fontSize: '12px',
1102
+ cursor: 'pointer',
1103
+ outline: 'none',
1104
+ appearance: 'none',
1105
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")`,
1106
+ backgroundRepeat: 'no-repeat',
1107
+ backgroundPosition: 'right 8px center',
1108
+ paddingRight: '28px',
1109
+ }}
1110
+ >
1111
+ {serverConfig.models.map(model => (
1112
+ <option key={model.id} value={model.id}>{model.id}</option>
1113
+ ))}
1114
+ </select>
1115
+
1116
+ {/* Components Count */}
1117
+ <div style={{
1118
+ display: 'flex',
1119
+ alignItems: 'center',
1120
+ gap: '6px',
1121
+ fontSize: '11px',
1122
+ color: THEME.textSubtle,
1123
+ paddingTop: '4px',
1124
+ }}>
827
1125
  <div style={{
828
1126
  width: '6px',
829
1127
  height: '6px',
@@ -847,7 +1145,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
847
1145
  }}>
848
1146
  {/* Messages */}
849
1147
  <div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
850
- {activeConversation?.messages.length === 0 && (
1148
+ {(!activeConversation || activeConversation.messages.length === 0) && (
851
1149
  <div style={{
852
1150
  padding: '32px 16px',
853
1151
  textAlign: 'center',
@@ -970,19 +1268,68 @@ OUTPUT: Start immediately with < and output only JSX.`;
970
1268
  <div ref={messagesEndRef} />
971
1269
  </div>
972
1270
 
973
- {/* Input Area */}
1271
+ {/* Input Area - ChatGPT/Lovable Style */}
974
1272
  <div style={{
975
1273
  padding: '16px',
976
1274
  borderTop: `1px solid ${THEME.border}`,
977
1275
  background: THEME.bgSurface,
978
1276
  }}>
979
- <ImageUploadArea
980
- images={images}
981
- onImagesChange={setImages}
982
- disabled={isGenerating}
983
- />
1277
+ {/* Image thumbnails if any */}
1278
+ {images.length > 0 && (
1279
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' }}>
1280
+ {images.map(img => (
1281
+ <div
1282
+ key={img.id}
1283
+ style={{
1284
+ position: 'relative',
1285
+ width: '64px',
1286
+ height: '64px',
1287
+ borderRadius: '8px',
1288
+ overflow: 'hidden',
1289
+ border: `1px solid ${THEME.border}`,
1290
+ }}
1291
+ >
1292
+ <img
1293
+ src={img.data}
1294
+ alt={img.name}
1295
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
1296
+ />
1297
+ <button
1298
+ onClick={() => setImages(images.filter(i => i.id !== img.id))}
1299
+ style={{
1300
+ position: 'absolute',
1301
+ top: '2px',
1302
+ right: '2px',
1303
+ width: '18px',
1304
+ height: '18px',
1305
+ borderRadius: '50%',
1306
+ background: 'rgba(0,0,0,0.7)',
1307
+ border: 'none',
1308
+ color: '#fff',
1309
+ cursor: 'pointer',
1310
+ display: 'flex',
1311
+ alignItems: 'center',
1312
+ justifyContent: 'center',
1313
+ fontSize: '10px',
1314
+ }}
1315
+ >
1316
+ <Icons.X />
1317
+ </button>
1318
+ </div>
1319
+ ))}
1320
+ </div>
1321
+ )}
984
1322
 
985
- <div style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
1323
+ {/* Combined input container matching reference */}
1324
+ <div style={{
1325
+ display: 'flex',
1326
+ flexDirection: 'column',
1327
+ background: THEME.bgElevated,
1328
+ border: `1px solid ${THEME.border}`,
1329
+ borderRadius: '12px',
1330
+ overflow: 'hidden',
1331
+ }}>
1332
+ {/* Text input area */}
986
1333
  <textarea
987
1334
  ref={inputRef}
988
1335
  value={inputValue}
@@ -991,37 +1338,95 @@ OUTPUT: Start immediately with < and output only JSX.`;
991
1338
  placeholder="Describe the component you want to create..."
992
1339
  disabled={isGenerating}
993
1340
  style={{
994
- flex: 1,
995
- padding: '12px 14px',
996
- background: THEME.bgElevated,
997
- border: `1px solid ${THEME.border}`,
998
- borderRadius: '10px',
1341
+ width: '100%',
1342
+ padding: '14px 16px',
1343
+ background: 'transparent',
1344
+ border: 'none',
999
1345
  color: THEME.text,
1000
1346
  fontSize: '14px',
1001
1347
  resize: 'none',
1002
1348
  outline: 'none',
1003
1349
  fontFamily: 'inherit',
1004
- minHeight: '44px',
1350
+ minHeight: '24px',
1005
1351
  maxHeight: '120px',
1006
1352
  }}
1007
1353
  rows={1}
1008
1354
  />
1009
- <button
1010
- onClick={sendMessage}
1011
- disabled={isGenerating || !inputValue.trim()}
1012
- style={{
1013
- padding: '12px',
1014
- background: (isGenerating || !inputValue.trim()) ? THEME.bgElevated : THEME.accent,
1015
- border: 'none',
1016
- borderRadius: '10px',
1017
- color: (isGenerating || !inputValue.trim()) ? THEME.textSubtle : '#fff',
1018
- cursor: (isGenerating || !inputValue.trim()) ? 'not-allowed' : 'pointer',
1019
- minWidth: '44px',
1020
- height: '44px',
1021
- }}
1022
- >
1023
- <Icons.Send />
1024
- </button>
1355
+
1356
+ {/* Bottom row with attach button and send */}
1357
+ <div style={{
1358
+ display: 'flex',
1359
+ alignItems: 'center',
1360
+ justifyContent: 'space-between',
1361
+ padding: '8px 12px',
1362
+ borderTop: `1px solid ${THEME.border}`,
1363
+ }}>
1364
+ {/* + Attach button */}
1365
+ <label
1366
+ style={{
1367
+ display: 'flex',
1368
+ alignItems: 'center',
1369
+ gap: '6px',
1370
+ padding: '6px 12px',
1371
+ background: THEME.bgHover,
1372
+ border: `1px solid ${THEME.borderSubtle}`,
1373
+ borderRadius: '6px',
1374
+ color: THEME.textMuted,
1375
+ fontSize: '13px',
1376
+ cursor: isGenerating ? 'not-allowed' : 'pointer',
1377
+ opacity: isGenerating ? 0.5 : 1,
1378
+ transition: 'all 0.2s',
1379
+ }}
1380
+ >
1381
+ <Icons.Plus />
1382
+ <span>Attach</span>
1383
+ <input
1384
+ type="file"
1385
+ accept="image/*"
1386
+ multiple
1387
+ style={{ display: 'none' }}
1388
+ disabled={isGenerating}
1389
+ onChange={(e) => {
1390
+ if (e.target.files) {
1391
+ Array.from(e.target.files).forEach(file => {
1392
+ if (file.type.startsWith('image/')) {
1393
+ const reader = new FileReader();
1394
+ reader.onload = (ev) => {
1395
+ const newImage: ImageAttachment = {
1396
+ id: generateId(),
1397
+ data: ev.target?.result as string,
1398
+ type: file.type,
1399
+ name: file.name,
1400
+ };
1401
+ setImages(prev => [...prev, newImage]);
1402
+ };
1403
+ reader.readAsDataURL(file);
1404
+ }
1405
+ });
1406
+ }
1407
+ }}
1408
+ />
1409
+ </label>
1410
+
1411
+ {/* Send button */}
1412
+ <button
1413
+ onClick={sendMessage}
1414
+ disabled={isGenerating || !inputValue.trim()}
1415
+ style={{
1416
+ padding: '8px 12px',
1417
+ background: (isGenerating || !inputValue.trim()) ? THEME.bgHover : THEME.accent,
1418
+ border: 'none',
1419
+ borderRadius: '8px',
1420
+ color: (isGenerating || !inputValue.trim()) ? THEME.textSubtle : '#fff',
1421
+ cursor: (isGenerating || !inputValue.trim()) ? 'not-allowed' : 'pointer',
1422
+ display: 'flex',
1423
+ alignItems: 'center',
1424
+ justifyContent: 'center',
1425
+ }}
1426
+ >
1427
+ <Icons.Send />
1428
+ </button>
1429
+ </div>
1025
1430
  </div>
1026
1431
  </div>
1027
1432