@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/dist/GPTChat.d.ts +9 -1
- package/dist/GPTChat.js +216 -24
- package/dist/GPTChat.js.map +1 -1
- package/dist/MarkdownEditorField.d.ts +12 -0
- package/dist/MarkdownEditorField.js +66 -0
- package/dist/MarkdownEditorField.js.map +1 -0
- package/dist/MarkdownView.js +4 -0
- package/dist/MarkdownView.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +9 -10
- package/src/GPTChat.tsx +621 -165
- package/src/MarkdownEditorField.tsx +154 -0
- package/src/MarkdownView.tsx +33 -0
- package/src/__snapshots__/MarkdownView.test.tsx.snap +14 -8
- package/src/index.tsx +1 -0
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
{history
|
|
399
|
-
|
|
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"
|
|
415
|
-
{
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
<
|
|
455
|
-
{message.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
964
|
+
</Box>
|
|
492
965
|
|
|
493
|
-
{
|
|
494
|
-
{attachments
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
};
|