iris-chatbot 4.1.0 → 5.0.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.
- package/package.json +1 -1
- package/template/docs/plan-projects-sidebar-ui.md +11 -0
- package/template/next-env.d.ts +1 -1
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/src/app/globals.css +264 -10
- package/template/src/app/page.tsx +21 -6
- package/template/src/components/ChatView.tsx +178 -46
- package/template/src/components/Composer.tsx +84 -54
- package/template/src/components/MapView.tsx +21 -2
- package/template/src/components/MessageCard.tsx +1 -1
- package/template/src/components/SearchModal.tsx +1 -1
- package/template/src/components/SettingsModal.tsx +174 -104
- package/template/src/components/Sidebar.tsx +2 -4
- package/template/src/components/TopBar.tsx +4 -10
- package/template/src/lib/data.ts +5 -0
- package/template/src/lib/model-presets.ts +29 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
|
4
4
|
import type {
|
|
5
5
|
ChatCitationSource,
|
|
6
6
|
ModelConnection,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "../lib/memory";
|
|
27
27
|
import { useUIStore } from "../lib/store";
|
|
28
28
|
import { toChatConnectionPayload } from "../lib/connections";
|
|
29
|
-
import { ArrowDown } from "lucide-react";
|
|
29
|
+
import { ArrowDown, TextQuote } from "lucide-react";
|
|
30
30
|
import MessageCard from "./MessageCard";
|
|
31
31
|
import Composer from "./Composer";
|
|
32
32
|
|
|
@@ -326,6 +326,10 @@ export default function ChatView({
|
|
|
326
326
|
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
|
327
327
|
const [micMode, setMicMode] = useState<MicMode>("off");
|
|
328
328
|
const [micState, setMicState] = useState<MicState>("idle");
|
|
329
|
+
const [quotedContext, setQuotedContext] = useState<string | null>(null);
|
|
330
|
+
const [askButtonPos, setAskButtonPos] = useState<{ top: number; left: number } | null>(null);
|
|
331
|
+
const [pendingSelection, setPendingSelection] = useState<string | null>(null);
|
|
332
|
+
const askButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
329
333
|
const focusedMessageId = useUIStore((state) => state.focusedMessageId);
|
|
330
334
|
const setFocusedMessageId = useUIStore((state) => state.setFocusedMessageId);
|
|
331
335
|
const chatScrollRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -346,7 +350,7 @@ export default function ChatView({
|
|
|
346
350
|
const recognitionFinalTranscriptRef = useRef("");
|
|
347
351
|
const recognitionInterimTranscriptRef = useRef("");
|
|
348
352
|
const micSilenceTimerRef = useRef<number | null>(null);
|
|
349
|
-
const handleSendRef = useRef<(overrideInput?: string) => Promise<void>>(async () => {});
|
|
353
|
+
const handleSendRef = useRef<(overrideInput?: string) => Promise<void>>(async () => { });
|
|
350
354
|
|
|
351
355
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
352
356
|
const container = chatScrollRef.current;
|
|
@@ -819,12 +823,113 @@ export default function ChatView({
|
|
|
819
823
|
startMicRecognitionSession("dictation");
|
|
820
824
|
}, [micSupported, startMicRecognitionSession, stopMicMode]);
|
|
821
825
|
|
|
826
|
+
const handleTextSelection = useCallback(() => {
|
|
827
|
+
const selection = window.getSelection();
|
|
828
|
+
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
|
829
|
+
setAskButtonPos(null);
|
|
830
|
+
setPendingSelection(null);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const selectedText = selection.toString().trim();
|
|
835
|
+
if (!selectedText) {
|
|
836
|
+
setAskButtonPos(null);
|
|
837
|
+
setPendingSelection(null);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Check if the selection is inside an assistant message
|
|
842
|
+
let node: Node | null = selection.anchorNode;
|
|
843
|
+
let isInAssistantMessage = false;
|
|
844
|
+
while (node) {
|
|
845
|
+
if (node instanceof HTMLElement) {
|
|
846
|
+
if (node.classList.contains("assistant-card") || node.closest?.(".assistant-card")) {
|
|
847
|
+
isInAssistantMessage = true;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
if (node.classList.contains("message-row") && node.classList.contains("assistant")) {
|
|
851
|
+
isInAssistantMessage = true;
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
node = node.parentNode;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (!isInAssistantMessage) {
|
|
859
|
+
setAskButtonPos(null);
|
|
860
|
+
setPendingSelection(null);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const range = selection.getRangeAt(0);
|
|
865
|
+
const rect = range.getBoundingClientRect();
|
|
866
|
+
const scrollContainer = chatScrollRef.current;
|
|
867
|
+
|
|
868
|
+
if (scrollContainer) {
|
|
869
|
+
const containerRect = scrollContainer.getBoundingClientRect();
|
|
870
|
+
setAskButtonPos({
|
|
871
|
+
top: rect.top - containerRect.top + scrollContainer.scrollTop - 44,
|
|
872
|
+
left: rect.left - containerRect.left + rect.width / 2,
|
|
873
|
+
});
|
|
874
|
+
} else {
|
|
875
|
+
setAskButtonPos({
|
|
876
|
+
top: rect.top + window.scrollY - 44,
|
|
877
|
+
left: rect.left + rect.width / 2,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
setPendingSelection(selectedText);
|
|
881
|
+
}, []);
|
|
882
|
+
|
|
883
|
+
const handleAskClick = useCallback((event: ReactMouseEvent) => {
|
|
884
|
+
event.preventDefault();
|
|
885
|
+
event.stopPropagation();
|
|
886
|
+
if (pendingSelection) {
|
|
887
|
+
setQuotedContext(pendingSelection);
|
|
888
|
+
setAskButtonPos(null);
|
|
889
|
+
setPendingSelection(null);
|
|
890
|
+
window.getSelection()?.removeAllRanges();
|
|
891
|
+
}
|
|
892
|
+
}, [pendingSelection]);
|
|
893
|
+
|
|
894
|
+
const handleClearQuotedContext = useCallback(() => {
|
|
895
|
+
setQuotedContext(null);
|
|
896
|
+
}, []);
|
|
897
|
+
|
|
898
|
+
useEffect(() => {
|
|
899
|
+
const onMouseUp = () => {
|
|
900
|
+
// Small delay so the selection is finalized
|
|
901
|
+
setTimeout(handleTextSelection, 10);
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const onSelectionChange = () => {
|
|
905
|
+
const selection = window.getSelection();
|
|
906
|
+
if (!selection || selection.isCollapsed) {
|
|
907
|
+
// Don't dismiss if the ask button itself was clicked
|
|
908
|
+
setTimeout(() => {
|
|
909
|
+
const currentSelection = window.getSelection();
|
|
910
|
+
if (!currentSelection || currentSelection.isCollapsed) {
|
|
911
|
+
setAskButtonPos(null);
|
|
912
|
+
setPendingSelection(null);
|
|
913
|
+
}
|
|
914
|
+
}, 150);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
919
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
920
|
+
return () => {
|
|
921
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
922
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
923
|
+
};
|
|
924
|
+
}, [handleTextSelection]);
|
|
925
|
+
|
|
822
926
|
const handleSend = useCallback(async (overrideInput?: string) => {
|
|
823
927
|
const resolvedThread =
|
|
824
928
|
thread || (activeThreadId ? await db.threads.get(activeThreadId) : null);
|
|
825
929
|
if (!resolvedThread) return;
|
|
826
930
|
const trimmed = (overrideInput ?? input).trim();
|
|
827
|
-
|
|
931
|
+
const hasQuoted = Boolean(quotedContext);
|
|
932
|
+
if (!trimmed && !hasQuoted) return;
|
|
828
933
|
const sendTriggeredByMic =
|
|
829
934
|
typeof overrideInput === "string" || micModeRef.current !== "off";
|
|
830
935
|
const stopMicAfterValidationFailure = () => {
|
|
@@ -886,6 +991,8 @@ export default function ChatView({
|
|
|
886
991
|
inflightThreadIdsRef.current.add(resolvedThread.id);
|
|
887
992
|
setError(null);
|
|
888
993
|
setInput("");
|
|
994
|
+
const capturedQuotedContext = quotedContext;
|
|
995
|
+
setQuotedContext(null);
|
|
889
996
|
setFocusedMessageId(null);
|
|
890
997
|
pendingSendScrollThreadIdRef.current = resolvedThread.id;
|
|
891
998
|
isUserNearBottomRef.current = true;
|
|
@@ -894,9 +1001,15 @@ export default function ChatView({
|
|
|
894
1001
|
smoothFollowUntilRef.current = performance.now() + 1200;
|
|
895
1002
|
scrollToBottom("smooth");
|
|
896
1003
|
|
|
1004
|
+
const contentToSend = capturedQuotedContext
|
|
1005
|
+
? trimmed
|
|
1006
|
+
? `Regarding this text: "${capturedQuotedContext}"\n\n${trimmed}`
|
|
1007
|
+
: `Explain this: "${capturedQuotedContext}"`
|
|
1008
|
+
: trimmed;
|
|
1009
|
+
|
|
897
1010
|
const { userMessage, assistantMessage } = await appendUserAndAssistant({
|
|
898
1011
|
thread: resolvedThread,
|
|
899
|
-
content:
|
|
1012
|
+
content: contentToSend,
|
|
900
1013
|
provider: connection.kind === "builtin" ? connection.provider ?? connection.id : connection.id,
|
|
901
1014
|
model,
|
|
902
1015
|
});
|
|
@@ -906,8 +1019,8 @@ export default function ChatView({
|
|
|
906
1019
|
const lastAssistantMessageContent =
|
|
907
1020
|
lastAssistantMessage?.role === "assistant"
|
|
908
1021
|
? (typeof lastAssistantMessage.content === "string"
|
|
909
|
-
|
|
910
|
-
|
|
1022
|
+
? lastAssistantMessage.content
|
|
1023
|
+
: "")
|
|
911
1024
|
: undefined;
|
|
912
1025
|
const memoryEnabled = settings.memory?.enabled !== false;
|
|
913
1026
|
const memoryAutoCaptureEnabled =
|
|
@@ -971,10 +1084,10 @@ export default function ChatView({
|
|
|
971
1084
|
|
|
972
1085
|
const siblingThreadsContext = shouldIncludeSiblingThreadContext(trimmed)
|
|
973
1086
|
? buildSiblingThreadsContext({
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1087
|
+
allThreads: threads,
|
|
1088
|
+
activeThread: resolvedThread,
|
|
1089
|
+
messageMap: map,
|
|
1090
|
+
})
|
|
978
1091
|
: null;
|
|
979
1092
|
if (siblingThreadsContext) {
|
|
980
1093
|
payloadMessages.push({
|
|
@@ -987,8 +1100,8 @@ export default function ChatView({
|
|
|
987
1100
|
|
|
988
1101
|
payloadMessages.push(
|
|
989
1102
|
...history
|
|
990
|
-
|
|
991
|
-
|
|
1103
|
+
.filter((message) => message.role !== "system")
|
|
1104
|
+
.map((message) => ({ role: message.role, content: message.content })),
|
|
992
1105
|
);
|
|
993
1106
|
|
|
994
1107
|
const controller = new AbortController();
|
|
@@ -1363,6 +1476,7 @@ export default function ChatView({
|
|
|
1363
1476
|
stopMicRecognitionSession,
|
|
1364
1477
|
startMicRecognitionSession,
|
|
1365
1478
|
stopMicMode,
|
|
1479
|
+
quotedContext,
|
|
1366
1480
|
messageMap,
|
|
1367
1481
|
]);
|
|
1368
1482
|
|
|
@@ -1472,9 +1586,8 @@ export default function ChatView({
|
|
|
1472
1586
|
<div
|
|
1473
1587
|
ref={chatScrollRef}
|
|
1474
1588
|
onScroll={updateBottomVisibility}
|
|
1475
|
-
className={`chat-scroll flex-1 overflow-y-auto px-6 py-8${
|
|
1476
|
-
|
|
1477
|
-
}`}
|
|
1589
|
+
className={`chat-scroll flex-1 overflow-y-auto px-6 py-8${threadMessages.length === 0 ? " empty" : ""
|
|
1590
|
+
}`}
|
|
1478
1591
|
>
|
|
1479
1592
|
{threadMessages.length === 0 ? (
|
|
1480
1593
|
<div className="empty-hero">
|
|
@@ -1493,6 +1606,8 @@ export default function ChatView({
|
|
|
1493
1606
|
onMicToggle={handleMicToggle}
|
|
1494
1607
|
micState={micState}
|
|
1495
1608
|
micDisabled={Boolean(activeStreamingMessageId)}
|
|
1609
|
+
quotedContext={quotedContext}
|
|
1610
|
+
onClearQuotedContext={handleClearQuotedContext}
|
|
1496
1611
|
/>
|
|
1497
1612
|
</div>
|
|
1498
1613
|
</div>
|
|
@@ -1501,21 +1616,36 @@ export default function ChatView({
|
|
|
1501
1616
|
{renderedThreadRows}
|
|
1502
1617
|
</div>
|
|
1503
1618
|
)}
|
|
1619
|
+
{askButtonPos ? (
|
|
1620
|
+
<button
|
|
1621
|
+
ref={askButtonRef}
|
|
1622
|
+
className="ask-selection-button"
|
|
1623
|
+
style={{
|
|
1624
|
+
top: askButtonPos.top,
|
|
1625
|
+
left: askButtonPos.left,
|
|
1626
|
+
}}
|
|
1627
|
+
onMouseDown={handleAskClick}
|
|
1628
|
+
aria-label="Ask about selection"
|
|
1629
|
+
>
|
|
1630
|
+
<TextQuote className="h-3.5 w-3.5" />
|
|
1631
|
+
Ask
|
|
1632
|
+
</button>
|
|
1633
|
+
) : null}
|
|
1504
1634
|
<div ref={bottomRef} className="h-2" />
|
|
1505
1635
|
</div>
|
|
1506
1636
|
{threadMessages.length > 0 ? (
|
|
1507
|
-
|
|
1508
|
-
|
|
1637
|
+
<>
|
|
1638
|
+
{showJumpToBottom ? (
|
|
1509
1639
|
<div className="pointer-events-none absolute bottom-28 left-1/2 z-30 -translate-x-1/2 opacity-80 transition-opacity duration-200">
|
|
1510
1640
|
<button
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1641
|
+
className="pointer-events-auto inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--panel)] text-[var(--text-primary)] shadow-[var(--shadow)] transition hover:translate-y-[1px] hover:border-[var(--accent)] hover:opacity-100"
|
|
1642
|
+
onClick={() => {
|
|
1643
|
+
shouldStickToBottomRef.current = true;
|
|
1644
|
+
setShowJumpToBottom(false);
|
|
1645
|
+
smoothFollowUntilRef.current = performance.now() + 900;
|
|
1646
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
1647
|
+
}}
|
|
1648
|
+
aria-label="Jump to latest message"
|
|
1519
1649
|
title="Jump to latest"
|
|
1520
1650
|
>
|
|
1521
1651
|
<ArrowDown className="h-4 w-4" />
|
|
@@ -1523,27 +1653,29 @@ export default function ChatView({
|
|
|
1523
1653
|
</div>
|
|
1524
1654
|
) : null}
|
|
1525
1655
|
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1656
|
+
<div className="composer-bar px-6 py-4">
|
|
1657
|
+
<div className="mx-auto w-full max-w-3xl">
|
|
1658
|
+
{error ? (
|
|
1659
|
+
<div className="mb-3 rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-xs text-[var(--danger)]">
|
|
1660
|
+
{error}
|
|
1661
|
+
</div>
|
|
1662
|
+
) : null}
|
|
1663
|
+
<Composer
|
|
1664
|
+
value={input}
|
|
1665
|
+
onChange={setInput}
|
|
1666
|
+
onSend={() => {
|
|
1667
|
+
void handleSend();
|
|
1668
|
+
}}
|
|
1669
|
+
onStop={handleStop}
|
|
1670
|
+
isStreaming={Boolean(activeStreamingMessageId)}
|
|
1671
|
+
onMicToggle={handleMicToggle}
|
|
1672
|
+
micState={micState}
|
|
1673
|
+
micDisabled={Boolean(activeStreamingMessageId)}
|
|
1674
|
+
quotedContext={quotedContext}
|
|
1675
|
+
onClearQuotedContext={handleClearQuotedContext}
|
|
1676
|
+
/>
|
|
1677
|
+
</div>
|
|
1545
1678
|
</div>
|
|
1546
|
-
</div>
|
|
1547
1679
|
</>
|
|
1548
1680
|
) : null}
|
|
1549
1681
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
4
|
-
import { ArrowUp, Loader2, Mic, Square } from "lucide-react";
|
|
4
|
+
import { ArrowUp, CornerDownRight, Loader2, Mic, Square, X } from "lucide-react";
|
|
5
5
|
|
|
6
6
|
function normalizePastedText(input: string): string {
|
|
7
7
|
return input
|
|
@@ -21,6 +21,8 @@ export default function Composer({
|
|
|
21
21
|
onMicToggle,
|
|
22
22
|
micState = "idle",
|
|
23
23
|
micDisabled = false,
|
|
24
|
+
quotedContext = null,
|
|
25
|
+
onClearQuotedContext,
|
|
24
26
|
}: {
|
|
25
27
|
value: string;
|
|
26
28
|
onChange: (value: string) => void;
|
|
@@ -30,6 +32,8 @@ export default function Composer({
|
|
|
30
32
|
onMicToggle?: () => void;
|
|
31
33
|
micState?: "idle" | "listening" | "processing";
|
|
32
34
|
micDisabled?: boolean;
|
|
35
|
+
quotedContext?: string | null;
|
|
36
|
+
onClearQuotedContext?: () => void;
|
|
33
37
|
}) {
|
|
34
38
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
35
39
|
const minRowsRef = useRef(1);
|
|
@@ -109,61 +113,87 @@ export default function Composer({
|
|
|
109
113
|
};
|
|
110
114
|
}, [resizeTextarea, value]);
|
|
111
115
|
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (quotedContext && textareaRef.current) {
|
|
118
|
+
textareaRef.current.focus();
|
|
119
|
+
}
|
|
120
|
+
}, [quotedContext]);
|
|
121
|
+
|
|
112
122
|
return (
|
|
113
|
-
<div className="composer
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (event.key === "Enter" && !event.shiftKey) {
|
|
131
|
-
event.preventDefault();
|
|
132
|
-
if (!isStreaming) onSend();
|
|
133
|
-
}
|
|
134
|
-
}}
|
|
135
|
-
/>
|
|
136
|
-
{onMicToggle ? (
|
|
137
|
-
<button
|
|
138
|
-
onClick={onMicToggle}
|
|
139
|
-
className={`send-button mic-button shrink-0 ${micState !== "idle" ? "active" : ""}`}
|
|
140
|
-
disabled={micDisabled || micState === "processing"}
|
|
141
|
-
title={micButtonTitle}
|
|
142
|
-
aria-label={micButtonTitle}
|
|
143
|
-
>
|
|
144
|
-
{micState === "processing" ? (
|
|
145
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
146
|
-
) : (
|
|
147
|
-
<Mic className="h-4 w-4" />
|
|
148
|
-
)}
|
|
149
|
-
</button>
|
|
123
|
+
<div className="composer-wrapper">
|
|
124
|
+
{quotedContext ? (
|
|
125
|
+
<div className="quoted-context-container">
|
|
126
|
+
<div className="quoted-context-content">
|
|
127
|
+
<CornerDownRight className="quoted-context-icon" />
|
|
128
|
+
<span className="quoted-context-text">
|
|
129
|
+
“{quotedContext.length > 280 ? `${quotedContext.slice(0, 280)}…` : quotedContext}”
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
<button
|
|
133
|
+
className="quoted-context-dismiss"
|
|
134
|
+
onClick={onClearQuotedContext}
|
|
135
|
+
aria-label="Remove quoted context"
|
|
136
|
+
>
|
|
137
|
+
<X className="h-3.5 w-3.5" />
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
150
140
|
) : null}
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
141
|
+
<div className="composer flex items-end gap-3">
|
|
142
|
+
<textarea
|
|
143
|
+
ref={textareaRef}
|
|
144
|
+
rows={1}
|
|
145
|
+
className="composer-textarea flex-1 text-base text-[var(--text-primary)]"
|
|
146
|
+
value={value}
|
|
147
|
+
onChange={(event) => onChange(event.target.value)}
|
|
148
|
+
onPaste={(event) => {
|
|
149
|
+
const clipboardText = event.clipboardData.getData("text/plain");
|
|
150
|
+
if (!clipboardText) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
insertAtCursor(normalizePastedText(clipboardText));
|
|
155
|
+
}}
|
|
156
|
+
placeholder={quotedContext ? "Ask about this selection" : "Ask anything"}
|
|
157
|
+
onKeyDown={(event) => {
|
|
158
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
if (!isStreaming) onSend();
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
{onMicToggle ? (
|
|
165
|
+
<button
|
|
166
|
+
onClick={onMicToggle}
|
|
167
|
+
className={`send-button mic-button shrink-0 ${micState !== "idle" ? "active" : ""}`}
|
|
168
|
+
disabled={micDisabled || micState === "processing"}
|
|
169
|
+
title={micButtonTitle}
|
|
170
|
+
aria-label={micButtonTitle}
|
|
171
|
+
>
|
|
172
|
+
{micState === "processing" ? (
|
|
173
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
174
|
+
) : (
|
|
175
|
+
<Mic className="h-4 w-4" />
|
|
176
|
+
)}
|
|
177
|
+
</button>
|
|
178
|
+
) : null}
|
|
179
|
+
{isStreaming ? (
|
|
180
|
+
<button
|
|
181
|
+
onClick={onStop}
|
|
182
|
+
className="flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-xs text-[var(--text-secondary)]"
|
|
183
|
+
>
|
|
184
|
+
<Square className="h-3 w-3" />
|
|
185
|
+
Stop
|
|
186
|
+
</button>
|
|
187
|
+
) : (
|
|
188
|
+
<button
|
|
189
|
+
onClick={onSend}
|
|
190
|
+
className={`send-button shrink-0 ${hasText || quotedContext ? "active" : ""}`}
|
|
191
|
+
>
|
|
192
|
+
<ArrowUp className="h-4 w-4" />
|
|
193
|
+
</button>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
167
196
|
</div>
|
|
168
197
|
);
|
|
169
198
|
}
|
|
199
|
+
|
|
@@ -16,6 +16,21 @@ import { useUIStore } from "../lib/store";
|
|
|
16
16
|
const NODE_WIDTH = 220;
|
|
17
17
|
const NODE_HEIGHT = 80;
|
|
18
18
|
|
|
19
|
+
/** Strip markdown syntax for a short plain-text preview (e.g. "## Hi there" → "Hi there"). */
|
|
20
|
+
function stripMarkdownForPreview(text: string): string {
|
|
21
|
+
return text
|
|
22
|
+
.replace(/^#+\s*/m, "") // headings: ## Title -> Title
|
|
23
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1") // **bold**
|
|
24
|
+
.replace(/\*([^*]+)\*/g, "$1") // *italic*
|
|
25
|
+
.replace(/_([^_]+)_/g, "$1") // _italic_
|
|
26
|
+
.replace(/`([^`]+)`/g, "$1") // `code`
|
|
27
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [link](url) -> link
|
|
28
|
+
.replace(/^[-*]\s+/gm, "") // list bullets
|
|
29
|
+
.replace(/^>\s*/gm, "") // blockquote
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
const graph = new dagre.graphlib.Graph();
|
|
20
35
|
graph.setDefaultEdgeLabel(() => ({}));
|
|
21
36
|
|
|
@@ -120,7 +135,10 @@ export default function MapView({
|
|
|
120
135
|
|
|
121
136
|
const { nodes, edges } = useMemo(() => {
|
|
122
137
|
const nodes: Node[] = visibleMessages.map((message) => {
|
|
123
|
-
const
|
|
138
|
+
const raw = splitContentAndSources(message.content).content.trim();
|
|
139
|
+
const preview = raw
|
|
140
|
+
? stripMarkdownForPreview(raw).slice(0, 60)
|
|
141
|
+
: "(empty)";
|
|
124
142
|
const isActive = activePathIds.has(message.id);
|
|
125
143
|
const roleClass =
|
|
126
144
|
message.role === "user"
|
|
@@ -169,6 +187,7 @@ export default function MapView({
|
|
|
169
187
|
padding: 0.2,
|
|
170
188
|
duration,
|
|
171
189
|
includeHiddenNodes: false,
|
|
190
|
+
minZoom: 0.85,
|
|
172
191
|
});
|
|
173
192
|
}, []);
|
|
174
193
|
|
|
@@ -198,7 +217,7 @@ export default function MapView({
|
|
|
198
217
|
nodes={nodes}
|
|
199
218
|
edges={edges}
|
|
200
219
|
fitView
|
|
201
|
-
fitViewOptions={{ padding: 0.2, includeHiddenNodes: false }}
|
|
220
|
+
fitViewOptions={{ padding: 0.2, includeHiddenNodes: false, minZoom: 0.85 }}
|
|
202
221
|
onInit={(instance) => {
|
|
203
222
|
flowRef.current = instance;
|
|
204
223
|
refitView(0);
|
|
@@ -1009,7 +1009,7 @@ function MessageCard({
|
|
|
1009
1009
|
{canEditThreads ? (
|
|
1010
1010
|
<button
|
|
1011
1011
|
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${threadEditMode
|
|
1012
|
-
? "border-[var(--accent)] text-[var(--text-primary)]"
|
|
1012
|
+
? "border-[var(--accent-ring)] text-[var(--text-primary)]"
|
|
1013
1013
|
: "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
|
|
1014
1014
|
}`}
|
|
1015
1015
|
onClick={() => setThreadEditMode((prev) => !prev)}
|
|
@@ -33,7 +33,7 @@ export default function SearchModal({
|
|
|
33
33
|
<div className="flex items-center gap-3 border-b border-[var(--border)] px-4 py-3">
|
|
34
34
|
<Search className="h-4 w-4 text-[var(--text-muted)]" />
|
|
35
35
|
<input
|
|
36
|
-
className="flex-1 bg-transparent text-sm text-[var(--text-primary)] outline-none"
|
|
36
|
+
className="search-modal-input flex-1 bg-transparent text-sm text-[var(--text-primary)] outline-none"
|
|
37
37
|
placeholder="Search chats..."
|
|
38
38
|
value={query}
|
|
39
39
|
onChange={(event) => setQuery(event.target.value)}
|