@terreno/ui 0.2.0 → 0.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.
@@ -0,0 +1,648 @@
1
+ import React, {useCallback, useEffect, useRef, useState} from "react";
2
+ import {Image as RNImage, type ScrollView as RNScrollView} from "react-native";
3
+
4
+ import {AttachmentPreview} from "./AttachmentPreview";
5
+ import {Box} from "./Box";
6
+ import {Button} from "./Button";
7
+ import type {SelectedFile} from "./FilePickerButton";
8
+ import {FilePickerButton} from "./FilePickerButton";
9
+ import {Heading} from "./Heading";
10
+ import {Icon} from "./Icon";
11
+ import {IconButton} from "./IconButton";
12
+ import {MarkdownView} from "./MarkdownView";
13
+ import {Modal} from "./Modal";
14
+ import {SelectField} from "./SelectField";
15
+ import {Spinner} from "./Spinner";
16
+ import {Text} from "./Text";
17
+ import {TextArea} from "./TextArea";
18
+ import {TextField} from "./TextField";
19
+
20
+ // ============================================================
21
+ // Content Part Types (mirroring backend types for rendering)
22
+ // ============================================================
23
+
24
+ export interface TextContentPart {
25
+ type: "text";
26
+ text: string;
27
+ }
28
+
29
+ export interface ImageContentPart {
30
+ type: "image";
31
+ url: string;
32
+ mimeType?: string;
33
+ }
34
+
35
+ export interface FileContentPart {
36
+ type: "file";
37
+ url: string;
38
+ filename?: string;
39
+ mimeType: string;
40
+ }
41
+
42
+ export type MessageContentPart = TextContentPart | ImageContentPart | FileContentPart;
43
+
44
+ // ============================================================
45
+ // Tool Call Types
46
+ // ============================================================
47
+
48
+ export interface ToolCallInfo {
49
+ args: Record<string, unknown>;
50
+ toolCallId: string;
51
+ toolName: string;
52
+ }
53
+
54
+ export interface ToolResultInfo {
55
+ result: unknown;
56
+ toolCallId: string;
57
+ toolName: string;
58
+ }
59
+
60
+ // ============================================================
61
+ // Message Types
62
+ // ============================================================
63
+
64
+ export interface GPTChatMessage {
65
+ content: string;
66
+ contentParts?: MessageContentPart[];
67
+ rating?: "up" | "down";
68
+ role: "user" | "assistant" | "system" | "tool-call" | "tool-result";
69
+ toolCall?: ToolCallInfo;
70
+ toolResult?: ToolResultInfo;
71
+ }
72
+
73
+ export interface GPTChatHistory {
74
+ id: string;
75
+ prompts: GPTChatMessage[];
76
+ title?: string;
77
+ updated?: string;
78
+ }
79
+
80
+ export interface MCPServerStatus {
81
+ connected: boolean;
82
+ name: string;
83
+ }
84
+
85
+ export interface GPTChatProps {
86
+ attachments?: SelectedFile[];
87
+ availableModels?: Array<{label: string; value: string}>;
88
+ currentHistoryId?: string;
89
+ currentMessages: GPTChatMessage[];
90
+ geminiApiKey?: string;
91
+ histories: GPTChatHistory[];
92
+ isStreaming?: boolean;
93
+ mcpServers?: MCPServerStatus[];
94
+ onAttachFiles?: (files: SelectedFile[]) => void;
95
+ onCreateHistory: () => void;
96
+ onDeleteHistory: (id: string) => void;
97
+ onGeminiApiKeyChange?: (key: string) => void;
98
+ onMemoryEdit?: (memory: string) => void;
99
+ onModelChange?: (modelId: string) => void;
100
+ onRateFeedback?: (promptIndex: number, rating: "up" | "down" | null) => void;
101
+ onRemoveAttachment?: (index: number) => void;
102
+ onSelectHistory: (id: string) => void;
103
+ onSubmit: (prompt: string) => void;
104
+ onUpdateTitle?: (id: string, title: string) => void;
105
+ selectedModel?: string;
106
+ systemMemory?: string;
107
+ testID?: string;
108
+ }
109
+
110
+ const ToolCallCard = ({toolCall}: {toolCall: ToolCallInfo}): React.ReactElement => {
111
+ const [isExpanded, setIsExpanded] = useState(false);
112
+
113
+ return (
114
+ <Box border="default" padding={2} rounding="md">
115
+ <Box
116
+ accessibilityHint="Toggle tool call details"
117
+ accessibilityLabel={`Tool: ${toolCall.toolName}`}
118
+ alignItems="center"
119
+ direction="row"
120
+ gap={1}
121
+ onClick={() => setIsExpanded(!isExpanded)}
122
+ >
123
+ <Icon iconName="wrench" size="xs" />
124
+ <Text bold size="sm">
125
+ Tool: {toolCall.toolName}
126
+ </Text>
127
+ <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
128
+ </Box>
129
+ {isExpanded ? (
130
+ <Box marginTop={1} padding={1}>
131
+ <Text color="secondaryDark" size="sm">
132
+ {JSON.stringify(toolCall.args, null, 2)}
133
+ </Text>
134
+ </Box>
135
+ ) : null}
136
+ </Box>
137
+ );
138
+ };
139
+
140
+ const ToolResultCard = ({toolResult}: {toolResult: ToolResultInfo}): React.ReactElement => {
141
+ const [isExpanded, setIsExpanded] = useState(false);
142
+
143
+ return (
144
+ <Box border="default" padding={2} rounding="md">
145
+ <Box
146
+ accessibilityHint="Toggle tool result details"
147
+ accessibilityLabel={`Result: ${toolResult.toolName}`}
148
+ alignItems="center"
149
+ direction="row"
150
+ gap={1}
151
+ onClick={() => setIsExpanded(!isExpanded)}
152
+ >
153
+ <Icon iconName="check" size="xs" />
154
+ <Text bold size="sm">
155
+ Result: {toolResult.toolName}
156
+ </Text>
157
+ <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
158
+ </Box>
159
+ {isExpanded ? (
160
+ <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>
166
+ </Box>
167
+ ) : null}
168
+ </Box>
169
+ );
170
+ };
171
+
172
+ const handleDownloadFile = (url: string, filename: string): void => {
173
+ if (typeof window === "undefined") {
174
+ return;
175
+ }
176
+ const link = document.createElement("a");
177
+ link.href = url;
178
+ link.download = filename;
179
+ document.body.appendChild(link);
180
+ link.click();
181
+ document.body.removeChild(link);
182
+ };
183
+
184
+ const MessageContentParts = ({parts}: {parts: MessageContentPart[]}): React.ReactElement => {
185
+ return (
186
+ <Box gap={2}>
187
+ {parts.map((part, index) => {
188
+ if (part.type === "image") {
189
+ return (
190
+ <RNImage
191
+ key={`content-${index}`}
192
+ resizeMode="contain"
193
+ source={{uri: part.url}}
194
+ style={{borderRadius: 8, height: 400, maxWidth: 800, minWidth: 400, width: "100%"}}
195
+ />
196
+ );
197
+ }
198
+ if (part.type === "file") {
199
+ const hasDownloadableUrl = part.url?.startsWith("data:") || part.url?.startsWith("http");
200
+ const filename = part.filename ?? "File";
201
+ const isPdf = part.mimeType === "application/pdf";
202
+ const iconName = isPdf ? "file-pdf" : "file";
203
+
204
+ if (hasDownloadableUrl) {
205
+ return (
206
+ <Box
207
+ accessibilityHint="Download this file"
208
+ accessibilityLabel={`File: ${filename}`}
209
+ alignItems="center"
210
+ border="default"
211
+ direction="row"
212
+ gap={1}
213
+ key={`content-${index}`}
214
+ onClick={() => handleDownloadFile(part.url, filename)}
215
+ padding={2}
216
+ rounding="md"
217
+ >
218
+ <Icon iconName={iconName} size="sm" />
219
+ <Text size="sm">{filename}</Text>
220
+ <Icon iconName="download" size="xs" />
221
+ </Box>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <Box
227
+ alignItems="center"
228
+ border="default"
229
+ direction="row"
230
+ gap={1}
231
+ key={`content-${index}`}
232
+ padding={2}
233
+ rounding="md"
234
+ >
235
+ <Icon iconName={iconName} size="sm" />
236
+ <Text size="sm">{filename}</Text>
237
+ </Box>
238
+ );
239
+ }
240
+ return null;
241
+ })}
242
+ </Box>
243
+ );
244
+ };
245
+
246
+ const MCPStatusIndicator = ({servers}: {servers: MCPServerStatus[]}): React.ReactElement => {
247
+ const [showList, setShowList] = useState(false);
248
+ const connectedCount = servers.filter((s) => s.connected).length;
249
+
250
+ return (
251
+ <Box>
252
+ <Box
253
+ accessibilityHint="Show MCP server list"
254
+ accessibilityLabel="MCP server status"
255
+ alignItems="center"
256
+ direction="row"
257
+ gap={1}
258
+ onClick={() => setShowList(!showList)}
259
+ >
260
+ <Box
261
+ color={connectedCount > 0 ? "success" : "error"}
262
+ height={8}
263
+ rounding="circle"
264
+ width={8}
265
+ />
266
+ <Text color="secondaryDark" size="sm">
267
+ {connectedCount}/{servers.length} MCP
268
+ </Text>
269
+ </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}
285
+ </Box>
286
+ );
287
+ };
288
+
289
+ export const GPTChat = ({
290
+ attachments = [],
291
+ availableModels,
292
+ currentHistoryId,
293
+ currentMessages,
294
+ geminiApiKey,
295
+ histories,
296
+ isStreaming = false,
297
+ mcpServers,
298
+ onAttachFiles,
299
+ onCreateHistory,
300
+ onDeleteHistory,
301
+ onGeminiApiKeyChange,
302
+ onMemoryEdit,
303
+ onModelChange,
304
+ onRateFeedback,
305
+ onRemoveAttachment,
306
+ onSelectHistory,
307
+ onSubmit,
308
+ selectedModel,
309
+ systemMemory,
310
+ testID,
311
+ }: GPTChatProps): React.ReactElement => {
312
+ const [inputValue, setInputValue] = useState("");
313
+ const scrollViewRef = useRef<RNScrollView>(null);
314
+ const [isScrolledUp, setIsScrolledUp] = useState(false);
315
+ const contentHeightRef = useRef(0);
316
+ const scrollOffsetRef = useRef(0);
317
+ const viewportHeightRef = useRef(0);
318
+ const [isApiKeyModalVisible, setIsApiKeyModalVisible] = useState(false);
319
+ const [apiKeyDraft, setApiKeyDraft] = useState(geminiApiKey ?? "");
320
+
321
+ const handleSubmit = useCallback(() => {
322
+ const trimmed = inputValue.trim();
323
+ if (!trimmed || isStreaming) {
324
+ return;
325
+ }
326
+ setIsScrolledUp(false);
327
+ onSubmit(trimmed);
328
+ setInputValue("");
329
+ }, [inputValue, isStreaming, onSubmit]);
330
+
331
+ const handleCopyMessage = useCallback(async (text: string) => {
332
+ const Clipboard = await import("expo-clipboard");
333
+ await Clipboard.setStringAsync(text);
334
+ }, []);
335
+
336
+ const scrollToBottom = useCallback(() => {
337
+ scrollViewRef.current?.scrollToEnd({animated: true});
338
+ setIsScrolledUp(false);
339
+ }, []);
340
+
341
+ const handleFilesSelected = useCallback(
342
+ (files: SelectedFile[]) => {
343
+ onAttachFiles?.(files);
344
+ },
345
+ [onAttachFiles]
346
+ );
347
+
348
+ const handleScroll = useCallback((offsetY: number) => {
349
+ scrollOffsetRef.current = offsetY;
350
+ const distanceFromBottom = contentHeightRef.current - offsetY - viewportHeightRef.current;
351
+ setIsScrolledUp(distanceFromBottom > 100);
352
+ }, []);
353
+
354
+ const handleContentLayout = useCallback(
355
+ (_event: {nativeEvent: {layout: {height: number; width: number; x: number; y: number}}}) => {
356
+ contentHeightRef.current = _event.nativeEvent.layout.height;
357
+ },
358
+ []
359
+ );
360
+
361
+ const handleViewportLayout = useCallback(
362
+ (event: {nativeEvent: {layout: {height: number; width: number; x: number; y: number}}}) => {
363
+ viewportHeightRef.current = event.nativeEvent.layout.height;
364
+ },
365
+ []
366
+ );
367
+
368
+ const [scrollTrigger, setScrollTrigger] = useState(0);
369
+ const prevMessagesRef = useRef(currentMessages);
370
+
371
+ if (
372
+ currentMessages !== prevMessagesRef.current &&
373
+ (currentMessages.length !== prevMessagesRef.current.length ||
374
+ currentMessages[currentMessages.length - 1]?.content !==
375
+ prevMessagesRef.current[prevMessagesRef.current.length - 1]?.content)
376
+ ) {
377
+ prevMessagesRef.current = currentMessages;
378
+ setScrollTrigger((prev) => prev + 1);
379
+ }
380
+
381
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scrollTrigger is intentionally used to trigger scroll on message changes
382
+ useEffect(() => {
383
+ if (!isScrolledUp) {
384
+ scrollToBottom();
385
+ }
386
+ }, [scrollTrigger, isScrolledUp, scrollToBottom]);
387
+
388
+ const handleOpenApiKeyModal = useCallback(() => {
389
+ setApiKeyDraft(geminiApiKey ?? "");
390
+ setIsApiKeyModalVisible(true);
391
+ }, [geminiApiKey]);
392
+
393
+ const handleSaveApiKey = useCallback(() => {
394
+ onGeminiApiKeyChange?.(apiKeyDraft);
395
+ setIsApiKeyModalVisible(false);
396
+ }, [apiKeyDraft, onGeminiApiKeyChange]);
397
+
398
+ return (
399
+ <Box direction="row" flex="grow" testID={testID}>
400
+ {/* Sidebar */}
401
+ <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}
412
+
413
+ <Box alignItems="center" direction="row" justifyContent="between" marginBottom={3}>
414
+ <Heading size="sm">Chats</Heading>
415
+ <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}
435
+ <IconButton
436
+ accessibilityLabel="New chat"
437
+ iconName="plus"
438
+ onClick={onCreateHistory}
439
+ testID="gpt-new-chat-button"
440
+ />
441
+ </Box>
442
+ </Box>
443
+
444
+ {histories.map((history) => (
445
+ <Box
446
+ accessibilityHint="Opens this chat history"
447
+ accessibilityLabel={`Select chat: ${history.title ?? "New Chat"}`}
448
+ alignItems="center"
449
+ color={history.id === currentHistoryId ? "primary" : undefined}
450
+ direction="row"
451
+ justifyContent="between"
452
+ key={history.id}
453
+ marginBottom={1}
454
+ onClick={() => onSelectHistory(history.id)}
455
+ padding={2}
456
+ rounding="md"
457
+ >
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"
471
+ />
472
+ </Box>
473
+ ))}
474
+ </Box>
475
+
476
+ {/* Chat Panel */}
477
+ <Box direction="column" flex="grow" padding={4}>
478
+ {/* Messages */}
479
+ <Box flex="grow" marginBottom={3} onLayout={handleViewportLayout}>
480
+ <Box flex="grow" gap={3} onScroll={handleScroll} scroll={true} scrollRef={scrollViewRef}>
481
+ <Box gap={3} onLayout={handleContentLayout}>
482
+ {currentMessages.map((message, index) => {
483
+ // Tool call/result messages
484
+ if (message.role === "tool-call" && message.toolCall) {
485
+ return (
486
+ <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
487
+ <ToolCallCard toolCall={message.toolCall} />
488
+ </Box>
489
+ );
490
+ }
491
+ if (message.role === "tool-result" && message.toolResult) {
492
+ return (
493
+ <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
494
+ <ToolResultCard toolResult={message.toolResult} />
495
+ </Box>
496
+ );
497
+ }
498
+
499
+ const hasImages = message.contentParts?.some((p) => p.type === "image");
500
+ return (
501
+ <Box alignItems={message.role === "user" ? "end" : "start"} key={`msg-${index}`}>
502
+ <Box
503
+ color={message.role === "user" ? "primary" : "neutralLight"}
504
+ maxWidth={hasImages ? "90%" : "80%"}
505
+ padding={3}
506
+ rounding="lg"
507
+ >
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}
565
+ </Box>
566
+ </Box>
567
+ );
568
+ })}
569
+ {isStreaming ? (
570
+ <Box alignItems="start" padding={2}>
571
+ <Spinner size="sm" />
572
+ </Box>
573
+ ) : null}
574
+ </Box>
575
+ </Box>
576
+ </Box>
577
+
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}
594
+
595
+ {/* Input */}
596
+ <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}
604
+ <Box flex="grow">
605
+ <TextArea
606
+ disabled={isStreaming}
607
+ onChange={setInputValue}
608
+ placeholder="Type a message..."
609
+ testID="gpt-input"
610
+ value={inputValue}
611
+ />
612
+ </Box>
613
+ <Button
614
+ disabled={!inputValue.trim() || isStreaming}
615
+ iconName="paper-plane"
616
+ onClick={handleSubmit}
617
+ testID="gpt-submit"
618
+ text="Send"
619
+ />
620
+ </Box>
621
+ </Box>
622
+
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}
646
+ </Box>
647
+ );
648
+ };
@@ -0,0 +1,50 @@
1
+ import React, {useCallback, useState} from "react";
2
+
3
+ import {Box} from "./Box";
4
+ import {Modal} from "./Modal";
5
+ import {TextArea} from "./TextArea";
6
+
7
+ export interface GPTMemoryModalProps {
8
+ memory: string;
9
+ onDismiss: () => void;
10
+ onSave: (memory: string) => void;
11
+ visible: boolean;
12
+ }
13
+
14
+ export const GPTMemoryModal = ({
15
+ memory,
16
+ onDismiss,
17
+ onSave,
18
+ visible,
19
+ }: GPTMemoryModalProps): React.ReactElement => {
20
+ const [value, setValue] = useState(memory);
21
+
22
+ const handleSave = useCallback(() => {
23
+ onSave(value);
24
+ onDismiss();
25
+ }, [onDismiss, onSave, value]);
26
+
27
+ return (
28
+ <Modal
29
+ onDismiss={onDismiss}
30
+ primaryButtonOnClick={handleSave}
31
+ primaryButtonText="Save"
32
+ secondaryButtonOnClick={onDismiss}
33
+ secondaryButtonText="Cancel"
34
+ size="md"
35
+ subtitle="Customize the system prompt for your AI assistant."
36
+ title="System Memory"
37
+ visible={visible}
38
+ >
39
+ <Box padding={2}>
40
+ <TextArea
41
+ onChange={setValue}
42
+ placeholder="Enter system instructions..."
43
+ rows={10}
44
+ testID="gpt-memory-textarea"
45
+ value={value}
46
+ />
47
+ </Box>
48
+ </Modal>
49
+ );
50
+ };