@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/dist/GPTChat.d.ts +1 -1
- package/dist/GPTChat.js +174 -14
- 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 +534 -175
- 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
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
<
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
{history
|
|
464
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
{
|
|
579
|
-
{
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
};
|