@terreno/ui 0.3.0 → 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
- import React, {useCallback, useRef, useState} from "react";
2
- import {Image as RNImage, type ScrollView as RNScrollView} from "react-native";
1
+ import React, {useCallback, useEffect, useRef, useState} from "react";
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";
@@ -11,6 +11,7 @@ import {Icon} from "./Icon";
11
11
  import {IconButton} from "./IconButton";
12
12
  import {MarkdownView} from "./MarkdownView";
13
13
  import {Modal} from "./Modal";
14
+ import {SelectField} from "./SelectField";
14
15
  import {Spinner} from "./Spinner";
15
16
  import {Text} from "./Text";
16
17
  import {TextArea} from "./TextArea";
@@ -63,6 +64,7 @@ export interface ToolResultInfo {
63
64
  export interface GPTChatMessage {
64
65
  content: string;
65
66
  contentParts?: MessageContentPart[];
67
+ rating?: "up" | "down";
66
68
  role: "user" | "assistant" | "system" | "tool-call" | "tool-result";
67
69
  toolCall?: ToolCallInfo;
68
70
  toolResult?: ToolResultInfo;
@@ -82,6 +84,7 @@ export interface MCPServerStatus {
82
84
 
83
85
  export interface GPTChatProps {
84
86
  attachments?: SelectedFile[];
87
+ availableModels?: Array<{label: string; value: string}>;
85
88
  currentHistoryId?: string;
86
89
  currentMessages: GPTChatMessage[];
87
90
  geminiApiKey?: string;
@@ -93,14 +96,34 @@ export interface GPTChatProps {
93
96
  onDeleteHistory: (id: string) => void;
94
97
  onGeminiApiKeyChange?: (key: string) => void;
95
98
  onMemoryEdit?: (memory: string) => void;
99
+ onModelChange?: (modelId: string) => void;
100
+ onRateFeedback?: (promptIndex: number, rating: "up" | "down" | null) => void;
96
101
  onRemoveAttachment?: (index: number) => void;
97
102
  onSelectHistory: (id: string) => void;
98
103
  onSubmit: (prompt: string) => void;
99
104
  onUpdateTitle?: (id: string, title: string) => void;
105
+ selectedModel?: string;
100
106
  systemMemory?: string;
101
107
  testID?: string;
102
108
  }
103
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
+
104
127
  const ToolCallCard = ({toolCall}: {toolCall: ToolCallInfo}): React.ReactElement => {
105
128
  const [isExpanded, setIsExpanded] = useState(false);
106
129
 
@@ -120,17 +143,32 @@ const ToolCallCard = ({toolCall}: {toolCall: ToolCallInfo}): React.ReactElement
120
143
  </Text>
121
144
  <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
122
145
  </Box>
123
- {isExpanded ? (
146
+ <ExpandableContent isExpanded={isExpanded}>
124
147
  <Box marginTop={1} padding={1}>
125
148
  <Text color="secondaryDark" size="sm">
126
149
  {JSON.stringify(toolCall.args, null, 2)}
127
150
  </Text>
128
151
  </Box>
129
- ) : null}
152
+ </ExpandableContent>
130
153
  </Box>
131
154
  );
132
155
  };
133
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
+
134
172
  const ToolResultCard = ({toolResult}: {toolResult: ToolResultInfo}): React.ReactElement => {
135
173
  const [isExpanded, setIsExpanded] = useState(false);
136
174
 
@@ -150,15 +188,11 @@ const ToolResultCard = ({toolResult}: {toolResult: ToolResultInfo}): React.React
150
188
  </Text>
151
189
  <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
152
190
  </Box>
153
- {isExpanded ? (
191
+ <ExpandableContent isExpanded={isExpanded}>
154
192
  <Box marginTop={1} padding={1}>
155
- <Text color="secondaryDark" size="sm">
156
- {typeof toolResult.result === "string"
157
- ? toolResult.result
158
- : JSON.stringify(toolResult.result, null, 2)}
159
- </Text>
193
+ <ToolResultText result={toolResult.result} />
160
194
  </Box>
161
- ) : null}
195
+ </ExpandableContent>
162
196
  </Box>
163
197
  );
164
198
  };
@@ -237,6 +271,24 @@ const MessageContentParts = ({parts}: {parts: MessageContentPart[]}): React.Reac
237
271
  );
238
272
  };
239
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
+
240
292
  const MCPStatusIndicator = ({servers}: {servers: MCPServerStatus[]}): React.ReactElement => {
241
293
  const [showList, setShowList] = useState(false);
242
294
  const connectedCount = servers.filter((s) => s.connected).length;
@@ -261,27 +313,381 @@ const MCPStatusIndicator = ({servers}: {servers: MCPServerStatus[]}): React.Reac
261
313
  {connectedCount}/{servers.length} MCP
262
314
  </Text>
263
315
  </Box>
264
- {showList ? (
265
- <Box border="default" marginTop={1} padding={2} position="absolute" rounding="md">
266
- {servers.map((server) => (
267
- <Box alignItems="center" direction="row" gap={1} key={server.name} padding={1}>
268
- <Box
269
- color={server.connected ? "success" : "error"}
270
- height={6}
271
- rounding="circle"
272
- width={6}
273
- />
274
- <Text size="sm">{server.name}</Text>
275
- </Box>
276
- ))}
277
- </Box>
278
- ) : 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} />
504
+ </Box>
505
+ );
506
+ };
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
+ />
279
603
  </Box>
280
604
  );
281
605
  };
282
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
+
283
688
  export const GPTChat = ({
284
689
  attachments = [],
690
+ availableModels,
285
691
  currentHistoryId,
286
692
  currentMessages,
287
693
  geminiApiKey,
@@ -293,15 +699,24 @@ export const GPTChat = ({
293
699
  onDeleteHistory,
294
700
  onGeminiApiKeyChange,
295
701
  onMemoryEdit,
702
+ onModelChange,
703
+ onRateFeedback,
296
704
  onRemoveAttachment,
297
705
  onSelectHistory,
298
706
  onSubmit,
707
+ onUpdateTitle,
708
+ selectedModel,
299
709
  systemMemory,
300
710
  testID,
301
711
  }: GPTChatProps): React.ReactElement => {
302
712
  const [inputValue, setInputValue] = useState("");
713
+ const [editingHistoryId, setEditingHistoryId] = useState<string | null>(null);
714
+ const [editingTitle, setEditingTitle] = useState("");
303
715
  const scrollViewRef = useRef<RNScrollView>(null);
304
716
  const [isScrolledUp, setIsScrolledUp] = useState(false);
717
+ const contentHeightRef = useRef(0);
718
+ const scrollOffsetRef = useRef(0);
719
+ const viewportHeightRef = useRef(0);
305
720
  const [isApiKeyModalVisible, setIsApiKeyModalVisible] = useState(false);
306
721
  const [apiKeyDraft, setApiKeyDraft] = useState(geminiApiKey ?? "");
307
722
 
@@ -310,10 +725,34 @@ export const GPTChat = ({
310
725
  if (!trimmed || isStreaming) {
311
726
  return;
312
727
  }
728
+ setIsScrolledUp(false);
313
729
  onSubmit(trimmed);
314
730
  setInputValue("");
315
731
  }, [inputValue, isStreaming, onSubmit]);
316
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
+
317
756
  const handleCopyMessage = useCallback(async (text: string) => {
318
757
  const Clipboard = await import("expo-clipboard");
319
758
  await Clipboard.setStringAsync(text);
@@ -331,6 +770,66 @@ export const GPTChat = ({
331
770
  [onAttachFiles]
332
771
  );
333
772
 
773
+ const handleScroll = useCallback((offsetY: number) => {
774
+ scrollOffsetRef.current = offsetY;
775
+ const distanceFromBottom = contentHeightRef.current - offsetY - viewportHeightRef.current;
776
+ setIsScrolledUp(distanceFromBottom > 100);
777
+ }, []);
778
+
779
+ const handleContentLayout = useCallback(
780
+ (_event: {nativeEvent: {layout: {height: number; width: number; x: number; y: number}}}) => {
781
+ contentHeightRef.current = _event.nativeEvent.layout.height;
782
+ },
783
+ []
784
+ );
785
+
786
+ const handleViewportLayout = useCallback(
787
+ (event: {nativeEvent: {layout: {height: number; width: number; x: number; y: number}}}) => {
788
+ viewportHeightRef.current = event.nativeEvent.layout.height;
789
+ },
790
+ []
791
+ );
792
+
793
+ const [scrollTrigger, setScrollTrigger] = useState(0);
794
+ const prevMessagesRef = useRef(currentMessages);
795
+
796
+ if (
797
+ currentMessages !== prevMessagesRef.current &&
798
+ (currentMessages.length !== prevMessagesRef.current.length ||
799
+ currentMessages[currentMessages.length - 1]?.content !==
800
+ prevMessagesRef.current[prevMessagesRef.current.length - 1]?.content)
801
+ ) {
802
+ prevMessagesRef.current = currentMessages;
803
+ setScrollTrigger((prev) => prev + 1);
804
+ }
805
+
806
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scrollTrigger is intentionally used to trigger scroll on message changes
807
+ useEffect(() => {
808
+ if (!isScrolledUp) {
809
+ scrollToBottom();
810
+ }
811
+ }, [scrollTrigger, isScrolledUp, scrollToBottom]);
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
+
334
833
  const handleOpenApiKeyModal = useCallback(() => {
335
834
  setApiKeyDraft(geminiApiKey ?? "");
336
835
  setIsApiKeyModalVisible(true);
@@ -345,28 +844,22 @@ export const GPTChat = ({
345
844
  <Box direction="row" flex="grow" testID={testID}>
346
845
  {/* Sidebar */}
347
846
  <Box border="default" color="base" minWidth={250} overflow="scrollY" padding={3} width="30%">
847
+ <SidebarModelSelector
848
+ availableModels={availableModels}
849
+ onModelChange={onModelChange}
850
+ selectedModel={selectedModel}
851
+ />
852
+
348
853
  <Box alignItems="center" direction="row" justifyContent="between" marginBottom={3}>
349
854
  <Heading size="sm">Chats</Heading>
350
855
  <Box direction="row" gap={1}>
351
- {mcpServers && mcpServers.length > 0 ? (
352
- <MCPStatusIndicator servers={mcpServers} />
353
- ) : null}
354
- {onGeminiApiKeyChange ? (
355
- <IconButton
356
- accessibilityLabel="Set Gemini API key"
357
- iconName="key"
358
- onClick={handleOpenApiKeyModal}
359
- testID="gpt-api-key-button"
360
- />
361
- ) : null}
362
- {onMemoryEdit ? (
363
- <IconButton
364
- accessibilityLabel="Edit system memory"
365
- iconName="gear"
366
- onClick={() => onMemoryEdit(systemMemory ?? "")}
367
- testID="gpt-memory-button"
368
- />
369
- ) : null}
856
+ <SidebarToolbarButtons
857
+ handleOpenApiKeyModal={handleOpenApiKeyModal}
858
+ mcpServers={mcpServers}
859
+ onGeminiApiKeyChange={onGeminiApiKeyChange}
860
+ onMemoryEdit={onMemoryEdit}
861
+ systemMemory={systemMemory}
862
+ />
370
863
  <IconButton
371
864
  accessibilityLabel="New chat"
372
865
  iconName="plus"
@@ -390,20 +883,30 @@ export const GPTChat = ({
390
883
  padding={2}
391
884
  rounding="md"
392
885
  >
393
- <Text
394
- color={history.id === currentHistoryId ? "inverted" : "primary"}
395
- size="sm"
396
- truncate
397
- >
398
- {history.title ?? "New Chat"}
399
- </Text>
400
- <IconButton
401
- accessibilityLabel={`Delete chat: ${history.title ?? "New Chat"}`}
402
- iconName="trash"
403
- onClick={() => onDeleteHistory(history.id)}
404
- testID={`gpt-delete-history-${history.id}`}
405
- variant="destructive"
886
+ <HistoryItemTitle
887
+ currentHistoryId={currentHistoryId}
888
+ editingHistoryId={editingHistoryId}
889
+ editingTitle={editingTitle}
890
+ handleFinishRename={handleFinishRename}
891
+ history={history}
892
+ setEditingTitle={setEditingTitle}
406
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>
407
910
  </Box>
408
911
  ))}
409
912
  </Box>
@@ -411,103 +914,71 @@ export const GPTChat = ({
411
914
  {/* Chat Panel */}
412
915
  <Box direction="column" flex="grow" padding={4}>
413
916
  {/* Messages */}
414
- <Box flex="grow" gap={3} marginBottom={3} overflow="scrollY">
415
- {currentMessages.map((message, index) => {
416
- // Tool call/result messages
417
- if (message.role === "tool-call" && message.toolCall) {
418
- return (
419
- <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
420
- <ToolCallCard toolCall={message.toolCall} />
421
- </Box>
422
- );
423
- }
424
- if (message.role === "tool-result" && message.toolResult) {
425
- return (
426
- <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
427
- <ToolResultCard toolResult={message.toolResult} />
428
- </Box>
429
- );
430
- }
431
-
432
- const hasImages = message.contentParts?.some((p) => p.type === "image");
433
- return (
434
- <Box alignItems={message.role === "user" ? "end" : "start"} key={`msg-${index}`}>
435
- <Box
436
- color={message.role === "user" ? "primary" : "neutralLight"}
437
- maxWidth={hasImages ? "90%" : "80%"}
438
- padding={3}
439
- rounding="lg"
440
- >
441
- {/* Render content parts (images, files) */}
442
- {message.contentParts && message.contentParts.length > 0 ? (
443
- <Box marginBottom={message.content ? 2 : 0}>
444
- <MessageContentParts
445
- parts={message.contentParts.filter((p) => p.type !== "text")}
446
- />
917
+ <Box flex="grow" marginBottom={3} onLayout={handleViewportLayout}>
918
+ <Box flex="grow" gap={3} onScroll={handleScroll} scroll={true} scrollRef={scrollViewRef}>
919
+ <Box gap={3} onLayout={handleContentLayout}>
920
+ {currentMessages.map((message, index) => {
921
+ // Tool call/result messages
922
+ if (message.role === "tool-call" && message.toolCall) {
923
+ return (
924
+ <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
925
+ <ToolCallCard toolCall={message.toolCall} />
926
+ </Box>
927
+ );
928
+ }
929
+ if (message.role === "tool-result" && message.toolResult) {
930
+ return (
931
+ <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
932
+ <ToolResultCard toolResult={message.toolResult} />
447
933
  </Box>
448
- ) : null}
449
-
450
- {/* Render text content */}
451
- {message.role === "assistant" ? (
452
- <MarkdownView>{message.content}</MarkdownView>
453
- ) : (
454
- <Text color={message.role === "user" ? "inverted" : "primary"}>
455
- {message.content}
456
- </Text>
457
- )}
458
-
459
- {/* Copy button */}
460
- {message.role === "assistant" ? (
461
- <Box alignItems="end" marginTop={1}>
462
- <IconButton
463
- accessibilityLabel="Copy message"
464
- iconName="copy"
465
- onClick={() => handleCopyMessage(message.content)}
466
- testID={`gpt-copy-msg-${index}`}
934
+ );
935
+ }
936
+
937
+ const hasImages = message.contentParts?.some((p) => p.type === "image");
938
+ return (
939
+ <Box alignItems={message.role === "user" ? "end" : "start"} key={`msg-${index}`}>
940
+ <Box
941
+ color={message.role === "user" ? "primary" : "neutralLight"}
942
+ maxWidth={hasImages ? "90%" : "80%"}
943
+ padding={3}
944
+ rounding="lg"
945
+ >
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}
467
956
  />
468
957
  </Box>
469
- ) : null}
470
- </Box>
471
- </Box>
472
- );
473
- })}
474
- {isStreaming ? (
475
- <Box alignItems="start" padding={2}>
476
- <Spinner size="sm" />
958
+ </Box>
959
+ );
960
+ })}
961
+ <StreamingIndicator isStreaming={isStreaming} />
477
962
  </Box>
478
- ) : null}
479
- </Box>
480
-
481
- {/* Scroll to bottom button */}
482
- {isScrolledUp && isStreaming ? (
483
- <Box alignItems="center" marginBottom={2}>
484
- <Button
485
- iconName="arrow-down"
486
- onClick={scrollToBottom}
487
- text="Scroll to bottom"
488
- variant="outline"
489
- />
490
963
  </Box>
491
- ) : null}
964
+ </Box>
492
965
 
493
- {/* Attachment preview */}
494
- {attachments.length > 0 && onRemoveAttachment ? (
495
- <AttachmentPreview attachments={attachments} onRemove={onRemoveAttachment} />
496
- ) : null}
966
+ <ScrollToBottomButton isScrolledUp={isScrolledUp} scrollToBottom={scrollToBottom} />
967
+ <AttachmentSection attachments={attachments} onRemoveAttachment={onRemoveAttachment} />
497
968
 
498
969
  {/* Input */}
499
970
  <Box alignItems="end" direction="row" gap={2}>
500
- {onAttachFiles ? (
501
- <FilePickerButton
502
- disabled={isStreaming}
503
- onFilesSelected={handleFilesSelected}
504
- testID="gpt-attach-button"
505
- />
506
- ) : null}
971
+ <AttachButton
972
+ handleFilesSelected={handleFilesSelected}
973
+ isStreaming={isStreaming}
974
+ onAttachFiles={onAttachFiles}
975
+ />
507
976
  <Box flex="grow">
508
977
  <TextArea
978
+ blurOnSubmit={false}
509
979
  disabled={isStreaming}
510
980
  onChange={setInputValue}
981
+ onEnter={handleSubmit}
511
982
  placeholder="Type a message..."
512
983
  testID="gpt-input"
513
984
  value={inputValue}
@@ -523,29 +994,14 @@ export const GPTChat = ({
523
994
  </Box>
524
995
  </Box>
525
996
 
526
- {onGeminiApiKeyChange ? (
527
- <Modal
528
- onDismiss={() => setIsApiKeyModalVisible(false)}
529
- primaryButtonOnClick={handleSaveApiKey}
530
- primaryButtonText="Save"
531
- secondaryButtonOnClick={() => setIsApiKeyModalVisible(false)}
532
- secondaryButtonText="Cancel"
533
- size="sm"
534
- subtitle="Provide your own Gemini API key for AI requests."
535
- title="Gemini API Key"
536
- visible={isApiKeyModalVisible}
537
- >
538
- <Box padding={2}>
539
- <TextField
540
- onChange={setApiKeyDraft}
541
- placeholder="Enter Gemini API key..."
542
- testID="gpt-api-key-input"
543
- type="password"
544
- value={apiKeyDraft}
545
- />
546
- </Box>
547
- </Modal>
548
- ) : null}
997
+ <ApiKeyModal
998
+ apiKeyDraft={apiKeyDraft}
999
+ handleSaveApiKey={handleSaveApiKey}
1000
+ isVisible={isApiKeyModalVisible}
1001
+ onDismiss={() => setIsApiKeyModalVisible(false)}
1002
+ onGeminiApiKeyChange={onGeminiApiKeyChange}
1003
+ setApiKeyDraft={setApiKeyDraft}
1004
+ />
549
1005
  </Box>
550
1006
  );
551
1007
  };