@terreno/ui 0.3.1 → 0.4.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.
package/src/GPTChat.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import React, {useCallback, useEffect, useRef, useState} from "react";
2
- import {Image as RNImage, type ScrollView as RNScrollView} from "react-native";
2
+ import {Platform, Image as RNImage, type ScrollView as RNScrollView} from "react-native";
3
3
 
4
4
  import {AttachmentPreview} from "./AttachmentPreview";
5
5
  import {Box} from "./Box";
@@ -107,6 +107,23 @@ export interface GPTChatProps {
107
107
  testID?: string;
108
108
  }
109
109
 
110
+ // ============================================================
111
+ // Small helper components to replace ternaries
112
+ // ============================================================
113
+
114
+ const ExpandableContent = ({
115
+ children,
116
+ isExpanded,
117
+ }: {
118
+ children: React.ReactNode;
119
+ isExpanded: boolean;
120
+ }): React.ReactElement | null => {
121
+ if (!isExpanded) {
122
+ return null;
123
+ }
124
+ return <>{children}</>;
125
+ };
126
+
110
127
  const ToolCallCard = ({toolCall}: {toolCall: ToolCallInfo}): React.ReactElement => {
111
128
  const [isExpanded, setIsExpanded] = useState(false);
112
129
 
@@ -126,17 +143,32 @@ const ToolCallCard = ({toolCall}: {toolCall: ToolCallInfo}): React.ReactElement
126
143
  </Text>
127
144
  <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
128
145
  </Box>
129
- {isExpanded ? (
146
+ <ExpandableContent isExpanded={isExpanded}>
130
147
  <Box marginTop={1} padding={1}>
131
148
  <Text color="secondaryDark" size="sm">
132
149
  {JSON.stringify(toolCall.args, null, 2)}
133
150
  </Text>
134
151
  </Box>
135
- ) : null}
152
+ </ExpandableContent>
136
153
  </Box>
137
154
  );
138
155
  };
139
156
 
157
+ const ToolResultText = ({result}: {result: unknown}): React.ReactElement => {
158
+ if (typeof result === "string") {
159
+ return (
160
+ <Text color="secondaryDark" size="sm">
161
+ {result}
162
+ </Text>
163
+ );
164
+ }
165
+ return (
166
+ <Text color="secondaryDark" size="sm">
167
+ {JSON.stringify(result, null, 2)}
168
+ </Text>
169
+ );
170
+ };
171
+
140
172
  const ToolResultCard = ({toolResult}: {toolResult: ToolResultInfo}): React.ReactElement => {
141
173
  const [isExpanded, setIsExpanded] = useState(false);
142
174
 
@@ -156,15 +188,11 @@ const ToolResultCard = ({toolResult}: {toolResult: ToolResultInfo}): React.React
156
188
  </Text>
157
189
  <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
158
190
  </Box>
159
- {isExpanded ? (
191
+ <ExpandableContent isExpanded={isExpanded}>
160
192
  <Box marginTop={1} padding={1}>
161
- <Text color="secondaryDark" size="sm">
162
- {typeof toolResult.result === "string"
163
- ? toolResult.result
164
- : JSON.stringify(toolResult.result, null, 2)}
165
- </Text>
193
+ <ToolResultText result={toolResult.result} />
166
194
  </Box>
167
- ) : null}
195
+ </ExpandableContent>
168
196
  </Box>
169
197
  );
170
198
  };
@@ -243,6 +271,24 @@ const MessageContentParts = ({parts}: {parts: MessageContentPart[]}): React.Reac
243
271
  );
244
272
  };
245
273
 
274
+ const MCPServerList = ({servers}: {servers: MCPServerStatus[]}): React.ReactElement => {
275
+ return (
276
+ <Box border="default" marginTop={1} padding={2} position="absolute" rounding="md">
277
+ {servers.map((server) => (
278
+ <Box alignItems="center" direction="row" gap={1} key={server.name} padding={1}>
279
+ <Box
280
+ color={server.connected ? "success" : "error"}
281
+ height={6}
282
+ rounding="circle"
283
+ width={6}
284
+ />
285
+ <Text size="sm">{server.name}</Text>
286
+ </Box>
287
+ ))}
288
+ </Box>
289
+ );
290
+ };
291
+
246
292
  const MCPStatusIndicator = ({servers}: {servers: MCPServerStatus[]}): React.ReactElement => {
247
293
  const [showList, setShowList] = useState(false);
248
294
  const connectedCount = servers.filter((s) => s.connected).length;
@@ -267,25 +313,378 @@ const MCPStatusIndicator = ({servers}: {servers: MCPServerStatus[]}): React.Reac
267
313
  {connectedCount}/{servers.length} MCP
268
314
  </Text>
269
315
  </Box>
270
- {showList ? (
271
- <Box border="default" marginTop={1} padding={2} position="absolute" rounding="md">
272
- {servers.map((server) => (
273
- <Box alignItems="center" direction="row" gap={1} key={server.name} padding={1}>
274
- <Box
275
- color={server.connected ? "success" : "error"}
276
- height={6}
277
- rounding="circle"
278
- width={6}
279
- />
280
- <Text size="sm">{server.name}</Text>
281
- </Box>
282
- ))}
283
- </Box>
284
- ) : null}
316
+ <ExpandableContent isExpanded={showList}>
317
+ <MCPServerList servers={servers} />
318
+ </ExpandableContent>
319
+ </Box>
320
+ );
321
+ };
322
+
323
+ const SidebarModelSelector = ({
324
+ availableModels,
325
+ onModelChange,
326
+ selectedModel,
327
+ }: {
328
+ availableModels?: Array<{label: string; value: string}>;
329
+ onModelChange?: (modelId: string) => void;
330
+ selectedModel?: string;
331
+ }): React.ReactElement | null => {
332
+ if (!availableModels || availableModels.length === 0 || !onModelChange) {
333
+ return null;
334
+ }
335
+ return (
336
+ <Box marginBottom={2}>
337
+ <SelectField
338
+ onChange={onModelChange}
339
+ options={availableModels}
340
+ requireValue
341
+ value={selectedModel ?? availableModels[0]?.value ?? ""}
342
+ />
343
+ </Box>
344
+ );
345
+ };
346
+
347
+ const SidebarToolbarButtons = ({
348
+ mcpServers,
349
+ onGeminiApiKeyChange,
350
+ onMemoryEdit,
351
+ handleOpenApiKeyModal,
352
+ systemMemory,
353
+ }: {
354
+ handleOpenApiKeyModal: () => void;
355
+ mcpServers?: MCPServerStatus[];
356
+ onGeminiApiKeyChange?: (key: string) => void;
357
+ onMemoryEdit?: (memory: string) => void;
358
+ systemMemory?: string;
359
+ }): React.ReactElement => {
360
+ return (
361
+ <>
362
+ <MCPServersButton servers={mcpServers} />
363
+ <ApiKeyButton
364
+ handleOpenApiKeyModal={handleOpenApiKeyModal}
365
+ onGeminiApiKeyChange={onGeminiApiKeyChange}
366
+ />
367
+ <MemoryButton onMemoryEdit={onMemoryEdit} systemMemory={systemMemory} />
368
+ </>
369
+ );
370
+ };
371
+
372
+ const MCPServersButton = ({servers}: {servers?: MCPServerStatus[]}): React.ReactElement | null => {
373
+ if (!servers || servers.length === 0) {
374
+ return null;
375
+ }
376
+ return <MCPStatusIndicator servers={servers} />;
377
+ };
378
+
379
+ const ApiKeyButton = ({
380
+ handleOpenApiKeyModal,
381
+ onGeminiApiKeyChange,
382
+ }: {
383
+ handleOpenApiKeyModal: () => void;
384
+ onGeminiApiKeyChange?: (key: string) => void;
385
+ }): React.ReactElement | null => {
386
+ if (!onGeminiApiKeyChange) {
387
+ return null;
388
+ }
389
+ return (
390
+ <IconButton
391
+ accessibilityLabel="Set Gemini API key"
392
+ iconName="key"
393
+ onClick={handleOpenApiKeyModal}
394
+ testID="gpt-api-key-button"
395
+ />
396
+ );
397
+ };
398
+
399
+ const MemoryButton = ({
400
+ onMemoryEdit,
401
+ systemMemory,
402
+ }: {
403
+ onMemoryEdit?: (memory: string) => void;
404
+ systemMemory?: string;
405
+ }): React.ReactElement | null => {
406
+ if (!onMemoryEdit) {
407
+ return null;
408
+ }
409
+ return (
410
+ <IconButton
411
+ accessibilityLabel="Edit system memory"
412
+ iconName="gear"
413
+ onClick={() => onMemoryEdit(systemMemory ?? "")}
414
+ testID="gpt-memory-button"
415
+ />
416
+ );
417
+ };
418
+
419
+ const HistoryItemTitle = ({
420
+ currentHistoryId,
421
+ editingHistoryId,
422
+ editingTitle,
423
+ handleFinishRename,
424
+ history,
425
+ setEditingTitle,
426
+ }: {
427
+ currentHistoryId?: string;
428
+ editingHistoryId: string | null;
429
+ editingTitle: string;
430
+ handleFinishRename: () => void;
431
+ history: GPTChatHistory;
432
+ setEditingTitle: (title: string) => void;
433
+ }): React.ReactElement => {
434
+ if (editingHistoryId === history.id) {
435
+ return (
436
+ <Box flex="grow" marginRight={1}>
437
+ <TextField
438
+ onBlur={handleFinishRename}
439
+ onChange={setEditingTitle}
440
+ onEnter={handleFinishRename}
441
+ testID={`gpt-rename-input-${history.id}`}
442
+ value={editingTitle}
443
+ />
444
+ </Box>
445
+ );
446
+ }
447
+ return (
448
+ <Text color={history.id === currentHistoryId ? "inverted" : "primary"} size="sm" truncate>
449
+ {history.title ?? "New Chat"}
450
+ </Text>
451
+ );
452
+ };
453
+
454
+ const HistoryItemActionButton = ({
455
+ editingHistoryId,
456
+ handleFinishRename,
457
+ handleStartRename,
458
+ history,
459
+ onUpdateTitle,
460
+ }: {
461
+ editingHistoryId: string | null;
462
+ handleFinishRename: () => void;
463
+ handleStartRename: (id: string, title: string) => void;
464
+ history: GPTChatHistory;
465
+ onUpdateTitle?: (id: string, title: string) => void;
466
+ }): React.ReactElement | null => {
467
+ if (editingHistoryId === history.id) {
468
+ return (
469
+ <IconButton
470
+ accessibilityLabel="Save title"
471
+ iconName="check"
472
+ onClick={handleFinishRename}
473
+ testID={`gpt-rename-save-${history.id}`}
474
+ />
475
+ );
476
+ }
477
+ if (!onUpdateTitle) {
478
+ return null;
479
+ }
480
+ return (
481
+ <IconButton
482
+ accessibilityLabel={`Rename chat: ${history.title ?? "New Chat"}`}
483
+ iconName="pencil"
484
+ onClick={() => handleStartRename(history.id, history.title ?? "")}
485
+ testID={`gpt-rename-history-${history.id}`}
486
+ />
487
+ );
488
+ };
489
+
490
+ const ContentPartsPreview = ({
491
+ hasContent,
492
+ parts,
493
+ }: {
494
+ hasContent: boolean;
495
+ parts?: MessageContentPart[];
496
+ }): React.ReactElement | null => {
497
+ const nonTextParts = parts?.filter((p) => p.type !== "text");
498
+ if (!nonTextParts || nonTextParts.length === 0) {
499
+ return null;
500
+ }
501
+ return (
502
+ <Box marginBottom={hasContent ? 2 : 0}>
503
+ <MessageContentParts parts={nonTextParts} />
285
504
  </Box>
286
505
  );
287
506
  };
288
507
 
508
+ const MessageText = ({content, role}: {content: string; role: string}): React.ReactElement => {
509
+ if (role === "assistant") {
510
+ return <MarkdownView>{content}</MarkdownView>;
511
+ }
512
+ return <Text color={role === "user" ? "inverted" : "primary"}>{content}</Text>;
513
+ };
514
+
515
+ const RatingButtons = ({
516
+ index,
517
+ onRateFeedback,
518
+ rating,
519
+ }: {
520
+ index: number;
521
+ onRateFeedback?: (promptIndex: number, rating: "up" | "down" | null) => void;
522
+ rating?: "up" | "down";
523
+ }): React.ReactElement | null => {
524
+ if (!onRateFeedback) {
525
+ return null;
526
+ }
527
+ return (
528
+ <>
529
+ <IconButton
530
+ accessibilityLabel="Thumbs up"
531
+ iconName="thumbs-up"
532
+ onClick={() => onRateFeedback(index, rating === "up" ? null : "up")}
533
+ testID={`gpt-rate-up-${index}`}
534
+ variant={rating === "up" ? "primary" : "muted"}
535
+ />
536
+ <IconButton
537
+ accessibilityLabel="Thumbs down"
538
+ iconName="thumbs-down"
539
+ onClick={() => onRateFeedback(index, rating === "down" ? null : "down")}
540
+ testID={`gpt-rate-down-${index}`}
541
+ variant={rating === "down" ? "primary" : "muted"}
542
+ />
543
+ </>
544
+ );
545
+ };
546
+
547
+ const AssistantActions = ({
548
+ handleCopyMessage,
549
+ index,
550
+ message,
551
+ onRateFeedback,
552
+ }: {
553
+ handleCopyMessage: (text: string) => void;
554
+ index: number;
555
+ message: GPTChatMessage;
556
+ onRateFeedback?: (promptIndex: number, rating: "up" | "down" | null) => void;
557
+ }): React.ReactElement | null => {
558
+ if (message.role !== "assistant") {
559
+ return null;
560
+ }
561
+ return (
562
+ <Box alignItems="end" direction="row" gap={1} justifyContent="end" marginTop={1}>
563
+ <RatingButtons index={index} onRateFeedback={onRateFeedback} rating={message.rating} />
564
+ <IconButton
565
+ accessibilityLabel="Copy message"
566
+ iconName="copy"
567
+ onClick={() => handleCopyMessage(message.content)}
568
+ testID={`gpt-copy-msg-${index}`}
569
+ />
570
+ </Box>
571
+ );
572
+ };
573
+
574
+ const StreamingIndicator = ({isStreaming}: {isStreaming: boolean}): React.ReactElement | null => {
575
+ if (!isStreaming) {
576
+ return null;
577
+ }
578
+ return (
579
+ <Box alignItems="start" padding={2}>
580
+ <Spinner size="sm" />
581
+ </Box>
582
+ );
583
+ };
584
+
585
+ const ScrollToBottomButton = ({
586
+ isScrolledUp,
587
+ scrollToBottom,
588
+ }: {
589
+ isScrolledUp: boolean;
590
+ scrollToBottom: () => void;
591
+ }): React.ReactElement | null => {
592
+ if (!isScrolledUp) {
593
+ return null;
594
+ }
595
+ return (
596
+ <Box alignItems="center" marginBottom={2}>
597
+ <Button
598
+ iconName="arrow-down"
599
+ onClick={scrollToBottom}
600
+ text="Scroll to bottom"
601
+ variant="outline"
602
+ />
603
+ </Box>
604
+ );
605
+ };
606
+
607
+ const AttachmentSection = ({
608
+ attachments,
609
+ onRemoveAttachment,
610
+ }: {
611
+ attachments: SelectedFile[];
612
+ onRemoveAttachment?: (index: number) => void;
613
+ }): React.ReactElement | null => {
614
+ if (attachments.length === 0 || !onRemoveAttachment) {
615
+ return null;
616
+ }
617
+ return <AttachmentPreview attachments={attachments} onRemove={onRemoveAttachment} />;
618
+ };
619
+
620
+ const AttachButton = ({
621
+ handleFilesSelected,
622
+ isStreaming,
623
+ onAttachFiles,
624
+ }: {
625
+ handleFilesSelected: (files: SelectedFile[]) => void;
626
+ isStreaming: boolean;
627
+ onAttachFiles?: (files: SelectedFile[]) => void;
628
+ }): React.ReactElement | null => {
629
+ if (!onAttachFiles) {
630
+ return null;
631
+ }
632
+ return (
633
+ <FilePickerButton
634
+ disabled={isStreaming}
635
+ onFilesSelected={handleFilesSelected}
636
+ testID="gpt-attach-button"
637
+ />
638
+ );
639
+ };
640
+
641
+ const ApiKeyModal = ({
642
+ apiKeyDraft,
643
+ handleSaveApiKey,
644
+ isVisible,
645
+ onDismiss,
646
+ onGeminiApiKeyChange,
647
+ setApiKeyDraft,
648
+ }: {
649
+ apiKeyDraft: string;
650
+ handleSaveApiKey: () => void;
651
+ isVisible: boolean;
652
+ onDismiss: () => void;
653
+ onGeminiApiKeyChange?: (key: string) => void;
654
+ setApiKeyDraft: (key: string) => void;
655
+ }): React.ReactElement | null => {
656
+ if (!onGeminiApiKeyChange) {
657
+ return null;
658
+ }
659
+ return (
660
+ <Modal
661
+ onDismiss={onDismiss}
662
+ primaryButtonOnClick={handleSaveApiKey}
663
+ primaryButtonText="Save"
664
+ secondaryButtonOnClick={onDismiss}
665
+ secondaryButtonText="Cancel"
666
+ size="sm"
667
+ subtitle="Provide your own Gemini API key for AI requests."
668
+ title="Gemini API Key"
669
+ visible={isVisible}
670
+ >
671
+ <Box padding={2}>
672
+ <TextField
673
+ onChange={setApiKeyDraft}
674
+ placeholder="Enter Gemini API key..."
675
+ testID="gpt-api-key-input"
676
+ type="password"
677
+ value={apiKeyDraft}
678
+ />
679
+ </Box>
680
+ </Modal>
681
+ );
682
+ };
683
+
684
+ // ============================================================
685
+ // Main Component
686
+ // ============================================================
687
+
289
688
  export const GPTChat = ({
290
689
  attachments = [],
291
690
  availableModels,
@@ -305,11 +704,14 @@ export const GPTChat = ({
305
704
  onRemoveAttachment,
306
705
  onSelectHistory,
307
706
  onSubmit,
707
+ onUpdateTitle,
308
708
  selectedModel,
309
709
  systemMemory,
310
710
  testID,
311
711
  }: GPTChatProps): React.ReactElement => {
312
712
  const [inputValue, setInputValue] = useState("");
713
+ const [editingHistoryId, setEditingHistoryId] = useState<string | null>(null);
714
+ const [editingTitle, setEditingTitle] = useState("");
313
715
  const scrollViewRef = useRef<RNScrollView>(null);
314
716
  const [isScrolledUp, setIsScrolledUp] = useState(false);
315
717
  const contentHeightRef = useRef(0);
@@ -328,6 +730,29 @@ export const GPTChat = ({
328
730
  setInputValue("");
329
731
  }, [inputValue, isStreaming, onSubmit]);
330
732
 
733
+ // On web, intercept Enter key in the chat input to submit (Shift+Enter for newline)
734
+ const handleSubmitRef = useRef(handleSubmit);
735
+ handleSubmitRef.current = handleSubmit;
736
+ useEffect(() => {
737
+ if (Platform.OS !== "web" || typeof document === "undefined") {
738
+ return;
739
+ }
740
+ const handler = (e: KeyboardEvent) => {
741
+ if (e.key !== "Enter" || e.shiftKey) {
742
+ return;
743
+ }
744
+ const target = e.target as HTMLElement | null;
745
+ const testId = target?.getAttribute("data-testid");
746
+ if (testId !== "gpt-input") {
747
+ return;
748
+ }
749
+ e.preventDefault();
750
+ handleSubmitRef.current();
751
+ };
752
+ document.addEventListener("keydown", handler);
753
+ return () => document.removeEventListener("keydown", handler);
754
+ }, []);
755
+
331
756
  const handleCopyMessage = useCallback(async (text: string) => {
332
757
  const Clipboard = await import("expo-clipboard");
333
758
  await Clipboard.setStringAsync(text);
@@ -385,6 +810,26 @@ export const GPTChat = ({
385
810
  }
386
811
  }, [scrollTrigger, isScrolledUp, scrollToBottom]);
387
812
 
813
+ const handleStartRename = useCallback((id: string, currentTitle: string) => {
814
+ renameSavedRef.current = false;
815
+ setEditingHistoryId(id);
816
+ setEditingTitle(currentTitle || "");
817
+ }, []);
818
+
819
+ const renameSavedRef = useRef(false);
820
+
821
+ const handleFinishRename = useCallback(() => {
822
+ if (renameSavedRef.current) {
823
+ return;
824
+ }
825
+ renameSavedRef.current = true;
826
+ if (editingHistoryId && editingTitle.trim()) {
827
+ onUpdateTitle?.(editingHistoryId, editingTitle.trim());
828
+ }
829
+ setEditingHistoryId(null);
830
+ setEditingTitle("");
831
+ }, [editingHistoryId, editingTitle, onUpdateTitle]);
832
+
388
833
  const handleOpenApiKeyModal = useCallback(() => {
389
834
  setApiKeyDraft(geminiApiKey ?? "");
390
835
  setIsApiKeyModalVisible(true);
@@ -399,39 +844,22 @@ export const GPTChat = ({
399
844
  <Box direction="row" flex="grow" testID={testID}>
400
845
  {/* Sidebar */}
401
846
  <Box border="default" color="base" minWidth={250} overflow="scrollY" padding={3} width="30%">
402
- {availableModels && availableModels.length > 0 && onModelChange ? (
403
- <Box marginBottom={2}>
404
- <SelectField
405
- onChange={onModelChange}
406
- options={availableModels}
407
- requireValue
408
- value={selectedModel ?? availableModels[0]?.value ?? ""}
409
- />
410
- </Box>
411
- ) : null}
847
+ <SidebarModelSelector
848
+ availableModels={availableModels}
849
+ onModelChange={onModelChange}
850
+ selectedModel={selectedModel}
851
+ />
412
852
 
413
853
  <Box alignItems="center" direction="row" justifyContent="between" marginBottom={3}>
414
854
  <Heading size="sm">Chats</Heading>
415
855
  <Box direction="row" gap={1}>
416
- {mcpServers && mcpServers.length > 0 ? (
417
- <MCPStatusIndicator servers={mcpServers} />
418
- ) : null}
419
- {onGeminiApiKeyChange ? (
420
- <IconButton
421
- accessibilityLabel="Set Gemini API key"
422
- iconName="key"
423
- onClick={handleOpenApiKeyModal}
424
- testID="gpt-api-key-button"
425
- />
426
- ) : null}
427
- {onMemoryEdit ? (
428
- <IconButton
429
- accessibilityLabel="Edit system memory"
430
- iconName="gear"
431
- onClick={() => onMemoryEdit(systemMemory ?? "")}
432
- testID="gpt-memory-button"
433
- />
434
- ) : null}
856
+ <SidebarToolbarButtons
857
+ handleOpenApiKeyModal={handleOpenApiKeyModal}
858
+ mcpServers={mcpServers}
859
+ onGeminiApiKeyChange={onGeminiApiKeyChange}
860
+ onMemoryEdit={onMemoryEdit}
861
+ systemMemory={systemMemory}
862
+ />
435
863
  <IconButton
436
864
  accessibilityLabel="New chat"
437
865
  iconName="plus"
@@ -455,20 +883,30 @@ export const GPTChat = ({
455
883
  padding={2}
456
884
  rounding="md"
457
885
  >
458
- <Text
459
- color={history.id === currentHistoryId ? "inverted" : "primary"}
460
- size="sm"
461
- truncate
462
- >
463
- {history.title ?? "New Chat"}
464
- </Text>
465
- <IconButton
466
- accessibilityLabel={`Delete chat: ${history.title ?? "New Chat"}`}
467
- iconName="trash"
468
- onClick={() => onDeleteHistory(history.id)}
469
- testID={`gpt-delete-history-${history.id}`}
470
- variant="destructive"
886
+ <HistoryItemTitle
887
+ currentHistoryId={currentHistoryId}
888
+ editingHistoryId={editingHistoryId}
889
+ editingTitle={editingTitle}
890
+ handleFinishRename={handleFinishRename}
891
+ history={history}
892
+ setEditingTitle={setEditingTitle}
471
893
  />
894
+ <Box direction="row" gap={1}>
895
+ <HistoryItemActionButton
896
+ editingHistoryId={editingHistoryId}
897
+ handleFinishRename={handleFinishRename}
898
+ handleStartRename={handleStartRename}
899
+ history={history}
900
+ onUpdateTitle={onUpdateTitle}
901
+ />
902
+ <IconButton
903
+ accessibilityLabel={`Delete chat: ${history.title ?? "New Chat"}`}
904
+ iconName="trash"
905
+ onClick={() => onDeleteHistory(history.id)}
906
+ testID={`gpt-delete-history-${history.id}`}
907
+ variant="destructive"
908
+ />
909
+ </Box>
472
910
  </Box>
473
911
  ))}
474
912
  </Box>
@@ -505,106 +943,42 @@ export const GPTChat = ({
505
943
  padding={3}
506
944
  rounding="lg"
507
945
  >
508
- {/* Render content parts (images, files) */}
509
- {message.contentParts && message.contentParts.length > 0 ? (
510
- <Box marginBottom={message.content ? 2 : 0}>
511
- <MessageContentParts
512
- parts={message.contentParts.filter((p) => p.type !== "text")}
513
- />
514
- </Box>
515
- ) : null}
516
-
517
- {/* Render text content */}
518
- {message.role === "assistant" ? (
519
- <MarkdownView>{message.content}</MarkdownView>
520
- ) : (
521
- <Text color={message.role === "user" ? "inverted" : "primary"}>
522
- {message.content}
523
- </Text>
524
- )}
525
-
526
- {/* Action buttons */}
527
- {message.role === "assistant" ? (
528
- <Box
529
- alignItems="end"
530
- direction="row"
531
- gap={1}
532
- justifyContent="end"
533
- marginTop={1}
534
- >
535
- {onRateFeedback ? (
536
- <>
537
- <IconButton
538
- accessibilityLabel="Thumbs up"
539
- iconName="thumbs-up"
540
- onClick={() =>
541
- onRateFeedback(index, message.rating === "up" ? null : "up")
542
- }
543
- testID={`gpt-rate-up-${index}`}
544
- variant={message.rating === "up" ? "primary" : "muted"}
545
- />
546
- <IconButton
547
- accessibilityLabel="Thumbs down"
548
- iconName="thumbs-down"
549
- onClick={() =>
550
- onRateFeedback(index, message.rating === "down" ? null : "down")
551
- }
552
- testID={`gpt-rate-down-${index}`}
553
- variant={message.rating === "down" ? "primary" : "muted"}
554
- />
555
- </>
556
- ) : null}
557
- <IconButton
558
- accessibilityLabel="Copy message"
559
- iconName="copy"
560
- onClick={() => handleCopyMessage(message.content)}
561
- testID={`gpt-copy-msg-${index}`}
562
- />
563
- </Box>
564
- ) : null}
946
+ <ContentPartsPreview
947
+ hasContent={Boolean(message.content)}
948
+ parts={message.contentParts}
949
+ />
950
+ <MessageText content={message.content} role={message.role} />
951
+ <AssistantActions
952
+ handleCopyMessage={handleCopyMessage}
953
+ index={index}
954
+ message={message}
955
+ onRateFeedback={onRateFeedback}
956
+ />
565
957
  </Box>
566
958
  </Box>
567
959
  );
568
960
  })}
569
- {isStreaming ? (
570
- <Box alignItems="start" padding={2}>
571
- <Spinner size="sm" />
572
- </Box>
573
- ) : null}
961
+ <StreamingIndicator isStreaming={isStreaming} />
574
962
  </Box>
575
963
  </Box>
576
964
  </Box>
577
965
 
578
- {/* Scroll to bottom button */}
579
- {isScrolledUp ? (
580
- <Box alignItems="center" marginBottom={2}>
581
- <Button
582
- iconName="arrow-down"
583
- onClick={scrollToBottom}
584
- text="Scroll to bottom"
585
- variant="outline"
586
- />
587
- </Box>
588
- ) : null}
589
-
590
- {/* Attachment preview */}
591
- {attachments.length > 0 && onRemoveAttachment ? (
592
- <AttachmentPreview attachments={attachments} onRemove={onRemoveAttachment} />
593
- ) : null}
966
+ <ScrollToBottomButton isScrolledUp={isScrolledUp} scrollToBottom={scrollToBottom} />
967
+ <AttachmentSection attachments={attachments} onRemoveAttachment={onRemoveAttachment} />
594
968
 
595
969
  {/* Input */}
596
970
  <Box alignItems="end" direction="row" gap={2}>
597
- {onAttachFiles ? (
598
- <FilePickerButton
599
- disabled={isStreaming}
600
- onFilesSelected={handleFilesSelected}
601
- testID="gpt-attach-button"
602
- />
603
- ) : null}
971
+ <AttachButton
972
+ handleFilesSelected={handleFilesSelected}
973
+ isStreaming={isStreaming}
974
+ onAttachFiles={onAttachFiles}
975
+ />
604
976
  <Box flex="grow">
605
977
  <TextArea
978
+ blurOnSubmit={false}
606
979
  disabled={isStreaming}
607
980
  onChange={setInputValue}
981
+ onEnter={handleSubmit}
608
982
  placeholder="Type a message..."
609
983
  testID="gpt-input"
610
984
  value={inputValue}
@@ -620,29 +994,14 @@ export const GPTChat = ({
620
994
  </Box>
621
995
  </Box>
622
996
 
623
- {onGeminiApiKeyChange ? (
624
- <Modal
625
- onDismiss={() => setIsApiKeyModalVisible(false)}
626
- primaryButtonOnClick={handleSaveApiKey}
627
- primaryButtonText="Save"
628
- secondaryButtonOnClick={() => setIsApiKeyModalVisible(false)}
629
- secondaryButtonText="Cancel"
630
- size="sm"
631
- subtitle="Provide your own Gemini API key for AI requests."
632
- title="Gemini API Key"
633
- visible={isApiKeyModalVisible}
634
- >
635
- <Box padding={2}>
636
- <TextField
637
- onChange={setApiKeyDraft}
638
- placeholder="Enter Gemini API key..."
639
- testID="gpt-api-key-input"
640
- type="password"
641
- value={apiKeyDraft}
642
- />
643
- </Box>
644
- </Modal>
645
- ) : null}
997
+ <ApiKeyModal
998
+ apiKeyDraft={apiKeyDraft}
999
+ handleSaveApiKey={handleSaveApiKey}
1000
+ isVisible={isApiKeyModalVisible}
1001
+ onDismiss={() => setIsApiKeyModalVisible(false)}
1002
+ onGeminiApiKeyChange={onGeminiApiKeyChange}
1003
+ setApiKeyDraft={setApiKeyDraft}
1004
+ />
646
1005
  </Box>
647
1006
  );
648
1007
  };