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.
@@ -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
- if (!trimmed) return;
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: trimmed,
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
- ? lastAssistantMessage.content
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
- allThreads: threads,
975
- activeThread: resolvedThread,
976
- messageMap: map,
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
- .filter((message) => message.role !== "system")
991
- .map((message) => ({ role: message.role, content: message.content })),
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
- threadMessages.length === 0 ? " empty" : ""
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
- {showJumpToBottom ? (
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
- 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"
1512
- onClick={() => {
1513
- shouldStickToBottomRef.current = true;
1514
- setShowJumpToBottom(false);
1515
- smoothFollowUntilRef.current = performance.now() + 900;
1516
- bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
1517
- }}
1518
- aria-label="Jump to latest message"
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
- <div className="composer-bar px-6 py-4">
1527
- <div className="mx-auto w-full max-w-3xl">
1528
- {error ? (
1529
- <div className="mb-3 rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-xs text-[var(--danger)]">
1530
- {error}
1531
- </div>
1532
- ) : null}
1533
- <Composer
1534
- value={input}
1535
- onChange={setInput}
1536
- onSend={() => {
1537
- void handleSend();
1538
- }}
1539
- onStop={handleStop}
1540
- isStreaming={Boolean(activeStreamingMessageId)}
1541
- onMicToggle={handleMicToggle}
1542
- micState={micState}
1543
- micDisabled={Boolean(activeStreamingMessageId)}
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 flex items-end gap-3">
114
- <textarea
115
- ref={textareaRef}
116
- rows={1}
117
- className="composer-textarea flex-1 text-base text-[var(--text-primary)]"
118
- value={value}
119
- onChange={(event) => onChange(event.target.value)}
120
- onPaste={(event) => {
121
- const clipboardText = event.clipboardData.getData("text/plain");
122
- if (!clipboardText) {
123
- return;
124
- }
125
- event.preventDefault();
126
- insertAtCursor(normalizePastedText(clipboardText));
127
- }}
128
- placeholder="Ask anything"
129
- onKeyDown={(event) => {
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
+ &ldquo;{quotedContext.length > 280 ? `${quotedContext.slice(0, 280)}…` : quotedContext}&rdquo;
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
- {isStreaming ? (
152
- <button
153
- onClick={onStop}
154
- 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)]"
155
- >
156
- <Square className="h-3 w-3" />
157
- Stop
158
- </button>
159
- ) : (
160
- <button
161
- onClick={onSend}
162
- className={`send-button shrink-0 ${hasText ? "active" : ""}`}
163
- >
164
- <ArrowUp className="h-4 w-4" />
165
- </button>
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 preview = splitContentAndSources(message.content).content.trim().slice(0, 60) || "(empty)";
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)}