@tpitre/story-ui 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1",
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
  };
@@ -548,6 +787,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
548
787
  messages: conversationHistory,
549
788
  systemPrompt,
550
789
  prefillAssistant,
790
+ model: serverConfig.currentModel,
551
791
  maxTokens: 4096,
552
792
  images: imageAttachments.map(img => ({
553
793
  type: img.type,
@@ -588,7 +828,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
588
828
  };
589
829
 
590
830
  const sendMessage = async () => {
591
- if (!inputValue.trim() || !activeConversationId || isGenerating) return;
831
+ if (!inputValue.trim() || isGenerating) return;
592
832
 
593
833
  const userMessage: Message = {
594
834
  id: generateId(),
@@ -598,17 +838,26 @@ OUTPUT: Start immediately with < and output only JSX.`;
598
838
  images: images.length > 0 ? [...images] : undefined,
599
839
  };
600
840
 
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
- }));
841
+ // Determine if we need to create a new conversation or add to existing
842
+ let conversationId = activeConversationId;
843
+
844
+ if (!conversationId) {
845
+ // Create new conversation with the first message
846
+ conversationId = createConversationWithMessage(userMessage);
847
+ setActiveConversationId(conversationId);
848
+ } else {
849
+ // Add message to existing conversation
850
+ setConversations(prev => prev.map(conv => {
851
+ if (conv.id === conversationId) {
852
+ return {
853
+ ...conv,
854
+ messages: [...conv.messages, userMessage],
855
+ updatedAt: Date.now(),
856
+ };
857
+ }
858
+ return conv;
859
+ }));
860
+ }
612
861
 
613
862
  const currentImages = [...images];
614
863
  setInputValue('');
@@ -627,7 +876,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
627
876
  };
628
877
 
629
878
  setConversations(prev => prev.map(conv => {
630
- if (conv.id === activeConversationId) {
879
+ if (conv.id === conversationId) {
631
880
  return {
632
881
  ...conv,
633
882
  messages: [...conv.messages, assistantMessage],
@@ -650,7 +899,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
650
899
  };
651
900
 
652
901
  setConversations(prev => prev.map(conv => {
653
- if (conv.id === activeConversationId) {
902
+ if (conv.id === conversationId) {
654
903
  return {
655
904
  ...conv,
656
905
  messages: [...conv.messages, errorMessage],
@@ -740,7 +989,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
740
989
  {/* New Chat Button */}
741
990
  <div style={{ padding: sidebarCollapsed ? '8px' : '12px' }}>
742
991
  <button
743
- onClick={createNewConversation}
992
+ onClick={startNewChat}
744
993
  style={{
745
994
  width: '100%',
746
995
  padding: sidebarCollapsed ? '10px' : '10px 14px',
@@ -765,7 +1014,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
765
1014
  {/* Conversation List */}
766
1015
  {!sidebarCollapsed && (
767
1016
  <div style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
768
- {conversations.map(conv => (
1017
+ {displayConversations.map(conv => (
769
1018
  <div
770
1019
  key={conv.id}
771
1020
  onClick={() => {
@@ -815,15 +1064,62 @@ OUTPUT: Start immediately with < and output only JSX.`;
815
1064
  </div>
816
1065
  )}
817
1066
 
818
- {/* Components Badge */}
1067
+ {/* Sidebar Footer - Provider/Model Info */}
819
1068
  {!sidebarCollapsed && (
820
1069
  <div style={{
821
1070
  padding: '12px',
822
1071
  borderTop: `1px solid ${THEME.border}`,
823
- fontSize: '11px',
824
- color: THEME.textSubtle,
1072
+ display: 'flex',
1073
+ flexDirection: 'column',
1074
+ gap: '6px',
825
1075
  }}>
826
- <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
1076
+ {/* Provider Label */}
1077
+ <div style={{ fontSize: '12px', color: THEME.textMuted }}>
1078
+ <span style={{ color: THEME.textSubtle, textTransform: 'uppercase', fontSize: '10px', letterSpacing: '0.5px' }}>Provider: </span>
1079
+ <span style={{ color: THEME.text, fontWeight: 500 }}>
1080
+ {serverConfig.providers.find(p => p.id === serverConfig.currentProvider)?.name || 'Claude (Anthropic)'}
1081
+ </span>
1082
+ </div>
1083
+
1084
+ {/* Model Dropdown */}
1085
+ <div style={{ fontSize: '12px', color: THEME.textMuted }}>
1086
+ <span style={{ color: THEME.textSubtle, textTransform: 'uppercase', fontSize: '10px', letterSpacing: '0.5px' }}>Model</span>
1087
+ </div>
1088
+ <select
1089
+ value={serverConfig.currentModel}
1090
+ onChange={(e) => serverConfig.changeModel(e.target.value)}
1091
+ disabled={serverConfig.loading}
1092
+ style={{
1093
+ width: '100%',
1094
+ padding: '8px 10px',
1095
+ background: THEME.bgElevated,
1096
+ border: `1px solid ${THEME.border}`,
1097
+ borderRadius: '6px',
1098
+ color: THEME.text,
1099
+ fontSize: '12px',
1100
+ cursor: 'pointer',
1101
+ outline: 'none',
1102
+ appearance: 'none',
1103
+ 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")`,
1104
+ backgroundRepeat: 'no-repeat',
1105
+ backgroundPosition: 'right 8px center',
1106
+ paddingRight: '28px',
1107
+ }}
1108
+ >
1109
+ {serverConfig.models.map(model => (
1110
+ <option key={model.id} value={model.id}>{model.id}</option>
1111
+ ))}
1112
+ </select>
1113
+
1114
+ {/* Components Count */}
1115
+ <div style={{
1116
+ display: 'flex',
1117
+ alignItems: 'center',
1118
+ gap: '6px',
1119
+ fontSize: '11px',
1120
+ color: THEME.textSubtle,
1121
+ paddingTop: '4px',
1122
+ }}>
827
1123
  <div style={{
828
1124
  width: '6px',
829
1125
  height: '6px',
@@ -847,7 +1143,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
847
1143
  }}>
848
1144
  {/* Messages */}
849
1145
  <div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
850
- {activeConversation?.messages.length === 0 && (
1146
+ {(!activeConversation || activeConversation.messages.length === 0) && (
851
1147
  <div style={{
852
1148
  padding: '32px 16px',
853
1149
  textAlign: 'center',
@@ -970,19 +1266,68 @@ OUTPUT: Start immediately with < and output only JSX.`;
970
1266
  <div ref={messagesEndRef} />
971
1267
  </div>
972
1268
 
973
- {/* Input Area */}
1269
+ {/* Input Area - ChatGPT/Lovable Style */}
974
1270
  <div style={{
975
1271
  padding: '16px',
976
1272
  borderTop: `1px solid ${THEME.border}`,
977
1273
  background: THEME.bgSurface,
978
1274
  }}>
979
- <ImageUploadArea
980
- images={images}
981
- onImagesChange={setImages}
982
- disabled={isGenerating}
983
- />
1275
+ {/* Image thumbnails if any */}
1276
+ {images.length > 0 && (
1277
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' }}>
1278
+ {images.map(img => (
1279
+ <div
1280
+ key={img.id}
1281
+ style={{
1282
+ position: 'relative',
1283
+ width: '64px',
1284
+ height: '64px',
1285
+ borderRadius: '8px',
1286
+ overflow: 'hidden',
1287
+ border: `1px solid ${THEME.border}`,
1288
+ }}
1289
+ >
1290
+ <img
1291
+ src={img.data}
1292
+ alt={img.name}
1293
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
1294
+ />
1295
+ <button
1296
+ onClick={() => setImages(images.filter(i => i.id !== img.id))}
1297
+ style={{
1298
+ position: 'absolute',
1299
+ top: '2px',
1300
+ right: '2px',
1301
+ width: '18px',
1302
+ height: '18px',
1303
+ borderRadius: '50%',
1304
+ background: 'rgba(0,0,0,0.7)',
1305
+ border: 'none',
1306
+ color: '#fff',
1307
+ cursor: 'pointer',
1308
+ display: 'flex',
1309
+ alignItems: 'center',
1310
+ justifyContent: 'center',
1311
+ fontSize: '10px',
1312
+ }}
1313
+ >
1314
+ <Icons.X />
1315
+ </button>
1316
+ </div>
1317
+ ))}
1318
+ </div>
1319
+ )}
984
1320
 
985
- <div style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
1321
+ {/* Combined input container matching reference */}
1322
+ <div style={{
1323
+ display: 'flex',
1324
+ flexDirection: 'column',
1325
+ background: THEME.bgElevated,
1326
+ border: `1px solid ${THEME.border}`,
1327
+ borderRadius: '12px',
1328
+ overflow: 'hidden',
1329
+ }}>
1330
+ {/* Text input area */}
986
1331
  <textarea
987
1332
  ref={inputRef}
988
1333
  value={inputValue}
@@ -991,37 +1336,95 @@ OUTPUT: Start immediately with < and output only JSX.`;
991
1336
  placeholder="Describe the component you want to create..."
992
1337
  disabled={isGenerating}
993
1338
  style={{
994
- flex: 1,
995
- padding: '12px 14px',
996
- background: THEME.bgElevated,
997
- border: `1px solid ${THEME.border}`,
998
- borderRadius: '10px',
1339
+ width: '100%',
1340
+ padding: '14px 16px',
1341
+ background: 'transparent',
1342
+ border: 'none',
999
1343
  color: THEME.text,
1000
1344
  fontSize: '14px',
1001
1345
  resize: 'none',
1002
1346
  outline: 'none',
1003
1347
  fontFamily: 'inherit',
1004
- minHeight: '44px',
1348
+ minHeight: '24px',
1005
1349
  maxHeight: '120px',
1006
1350
  }}
1007
1351
  rows={1}
1008
1352
  />
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>
1353
+
1354
+ {/* Bottom row with attach button and send */}
1355
+ <div style={{
1356
+ display: 'flex',
1357
+ alignItems: 'center',
1358
+ justifyContent: 'space-between',
1359
+ padding: '8px 12px',
1360
+ borderTop: `1px solid ${THEME.border}`,
1361
+ }}>
1362
+ {/* + Attach button */}
1363
+ <label
1364
+ style={{
1365
+ display: 'flex',
1366
+ alignItems: 'center',
1367
+ gap: '6px',
1368
+ padding: '6px 12px',
1369
+ background: THEME.bgHover,
1370
+ border: `1px solid ${THEME.borderSubtle}`,
1371
+ borderRadius: '6px',
1372
+ color: THEME.textMuted,
1373
+ fontSize: '13px',
1374
+ cursor: isGenerating ? 'not-allowed' : 'pointer',
1375
+ opacity: isGenerating ? 0.5 : 1,
1376
+ transition: 'all 0.2s',
1377
+ }}
1378
+ >
1379
+ <Icons.Plus />
1380
+ <span>Attach</span>
1381
+ <input
1382
+ type="file"
1383
+ accept="image/*"
1384
+ multiple
1385
+ style={{ display: 'none' }}
1386
+ disabled={isGenerating}
1387
+ onChange={(e) => {
1388
+ if (e.target.files) {
1389
+ Array.from(e.target.files).forEach(file => {
1390
+ if (file.type.startsWith('image/')) {
1391
+ const reader = new FileReader();
1392
+ reader.onload = (ev) => {
1393
+ const newImage: ImageAttachment = {
1394
+ id: generateId(),
1395
+ data: ev.target?.result as string,
1396
+ type: file.type,
1397
+ name: file.name,
1398
+ };
1399
+ setImages(prev => [...prev, newImage]);
1400
+ };
1401
+ reader.readAsDataURL(file);
1402
+ }
1403
+ });
1404
+ }
1405
+ }}
1406
+ />
1407
+ </label>
1408
+
1409
+ {/* Send button */}
1410
+ <button
1411
+ onClick={sendMessage}
1412
+ disabled={isGenerating || !inputValue.trim()}
1413
+ style={{
1414
+ padding: '8px 12px',
1415
+ background: (isGenerating || !inputValue.trim()) ? THEME.bgHover : THEME.accent,
1416
+ border: 'none',
1417
+ borderRadius: '8px',
1418
+ color: (isGenerating || !inputValue.trim()) ? THEME.textSubtle : '#fff',
1419
+ cursor: (isGenerating || !inputValue.trim()) ? 'not-allowed' : 'pointer',
1420
+ display: 'flex',
1421
+ alignItems: 'center',
1422
+ justifyContent: 'center',
1423
+ }}
1424
+ >
1425
+ <Icons.Send />
1426
+ </button>
1427
+ </div>
1025
1428
  </div>
1026
1429
  </div>
1027
1430