iris-chatbot 5.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris-chatbot",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "private": false,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "bin": {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "iris",
9
- "version": "5.0.0",
9
+ "version": "5.0.1",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.72.1",
12
12
  "clsx": "^2.1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "private": true,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "engines": {
@@ -39,7 +39,7 @@
39
39
  body {
40
40
  background: var(--bg);
41
41
  color: var(--text-primary);
42
- font-family: var(--app-font, var(--font-sans));
42
+ font-family: var(--app-font, var(--font-poppins));
43
43
  min-height: 100vh;
44
44
  -webkit-font-smoothing: antialiased;
45
45
  text-rendering: optimizeLegibility;
@@ -107,7 +107,7 @@ button:focus-visible {
107
107
  pointer-events: none;
108
108
  }
109
109
 
110
- .topbar > *,
110
+ .topbar>*,
111
111
  .topbar button,
112
112
  .topbar a,
113
113
  .topbar [role="listbox"],
@@ -890,8 +890,173 @@ button:focus-visible {
890
890
 
891
891
  .chat-scroll {
892
892
  padding-top: calc(var(--topbar-height) + 24px);
893
+ position: relative;
893
894
  }
894
895
 
895
896
  .chat-scroll.empty {
896
897
  padding-top: calc(var(--topbar-height) + 24px);
898
+ }
899
+
900
+ /* ─── Ask Selection Button ─── */
901
+
902
+ .ask-selection-button {
903
+ position: absolute;
904
+ z-index: 50;
905
+ transform: translateX(-50%);
906
+ display: inline-flex;
907
+ align-items: center;
908
+ gap: 6px;
909
+ padding: 7px 14px;
910
+ border-radius: 10px;
911
+ border: 1px solid rgba(255, 255, 255, 0.15);
912
+ background: rgba(50, 50, 50, 0.92);
913
+ backdrop-filter: blur(12px);
914
+ -webkit-backdrop-filter: blur(12px);
915
+ color: #f0f0f0;
916
+ font-size: 13px;
917
+ font-weight: 600;
918
+ letter-spacing: 0.01em;
919
+ cursor: pointer;
920
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35), 0 0 0 0.5px rgba(255, 255, 255, 0.06);
921
+ animation: ask-button-enter 0.18s ease-out;
922
+ transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
923
+ white-space: nowrap;
924
+ user-select: none;
925
+ -webkit-user-select: none;
926
+ }
927
+
928
+ .ask-selection-button:hover {
929
+ background: rgba(60, 60, 60, 0.95);
930
+ border-color: rgba(255, 255, 255, 0.22);
931
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4), 0 0 0 0.5px rgba(255, 255, 255, 0.1);
932
+ transform: translateX(-50%) translateY(-1px);
933
+ }
934
+
935
+ .ask-selection-button:active {
936
+ transform: translateX(-50%) translateY(0);
937
+ background: rgba(70, 70, 70, 0.95);
938
+ }
939
+
940
+ @keyframes ask-button-enter {
941
+ from {
942
+ opacity: 0;
943
+ transform: translateX(-50%) translateY(6px) scale(0.92);
944
+ }
945
+
946
+ to {
947
+ opacity: 1;
948
+ transform: translateX(-50%) translateY(0) scale(1);
949
+ }
950
+ }
951
+
952
+ /* ─── Quoted Context Container ─── */
953
+
954
+ .composer-wrapper {
955
+ display: flex;
956
+ flex-direction: column;
957
+ gap: 0;
958
+ }
959
+
960
+ .quoted-context-container {
961
+ display: flex;
962
+ align-items: flex-start;
963
+ gap: 10px;
964
+ padding: 12px 14px;
965
+ margin-bottom: 0;
966
+ background: #2b2b2b;
967
+ border: 1px solid var(--border);
968
+ border-bottom: none;
969
+ border-radius: 18px 18px 0 0;
970
+ animation: quoted-context-enter 0.22s ease-out;
971
+ }
972
+
973
+ .quoted-context-container+.composer {
974
+ border-top-left-radius: 0;
975
+ border-top-right-radius: 0;
976
+ border-top-color: var(--border);
977
+ }
978
+
979
+ .quoted-context-content {
980
+ flex: 1;
981
+ display: flex;
982
+ align-items: flex-start;
983
+ gap: 10px;
984
+ min-width: 0;
985
+ }
986
+
987
+ .quoted-context-icon {
988
+ flex-shrink: 0;
989
+ width: 18px;
990
+ height: 18px;
991
+ color: var(--text-muted);
992
+ margin-top: 1px;
993
+ }
994
+
995
+ .quoted-context-text {
996
+ font-size: 13px;
997
+ line-height: 1.5;
998
+ color: var(--text-secondary);
999
+ overflow: hidden;
1000
+ display: -webkit-box;
1001
+ -webkit-line-clamp: 3;
1002
+ line-clamp: 3;
1003
+ -webkit-box-orient: vertical;
1004
+ word-break: break-word;
1005
+ }
1006
+
1007
+ .quoted-context-dismiss {
1008
+ flex-shrink: 0;
1009
+ display: inline-flex;
1010
+ align-items: center;
1011
+ justify-content: center;
1012
+ width: 24px;
1013
+ height: 24px;
1014
+ border-radius: 999px;
1015
+ border: none;
1016
+ background: transparent;
1017
+ color: var(--text-muted);
1018
+ cursor: pointer;
1019
+ transition: color 0.15s ease, background 0.15s ease;
1020
+ }
1021
+
1022
+ .quoted-context-dismiss:hover {
1023
+ color: var(--text-primary);
1024
+ background: rgba(255, 255, 255, 0.08);
1025
+ }
1026
+
1027
+ @keyframes quoted-context-enter {
1028
+ from {
1029
+ opacity: 0;
1030
+ transform: translateY(6px);
1031
+ max-height: 0;
1032
+ }
1033
+
1034
+ to {
1035
+ opacity: 1;
1036
+ transform: translateY(0);
1037
+ max-height: 200px;
1038
+ }
1039
+ }
1040
+
1041
+ /* ─── Light theme overrides ─── */
1042
+
1043
+ [data-theme="light"] .ask-selection-button {
1044
+ background: rgba(255, 255, 255, 0.94);
1045
+ border-color: rgba(0, 0, 0, 0.12);
1046
+ color: #111111;
1047
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.05);
1048
+ }
1049
+
1050
+ [data-theme="light"] .ask-selection-button:hover {
1051
+ background: #ffffff;
1052
+ border-color: rgba(0, 0, 0, 0.18);
1053
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15), 0 0 0 0.5px rgba(0, 0, 0, 0.08);
1054
+ }
1055
+
1056
+ [data-theme="light"] .quoted-context-container {
1057
+ background: transparent;
1058
+ }
1059
+
1060
+ [data-theme="light"] .quoted-context-dismiss:hover {
1061
+ background: rgba(0, 0, 0, 0.06);
897
1062
  }
@@ -124,7 +124,7 @@ export default function Home() {
124
124
  ? "var(--font-sora)"
125
125
  : settings.font === "space"
126
126
  ? "var(--font-space)"
127
- : "var(--font-sans)";
127
+ : "var(--font-poppins)";
128
128
  document.documentElement.style.setProperty("--app-font", fontVar);
129
129
  document.body.style.fontFamily = fontVar;
130
130
  }, [settings, connectionOverrideId, setConnectionOverrideId]);
@@ -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
+
@@ -115,7 +115,7 @@ export default function SettingsModal({
115
115
  const [accentColor, setAccentColor] = useState(settings?.accentColor || DEFAULT_ACCENT_DARK);
116
116
  const [theme, setTheme] = useState<"dark" | "light">(settings?.theme || "dark");
117
117
  const [font, setFont] = useState<"ibm" | "manrope" | "sora" | "space" | "poppins">(
118
- settings?.font || "manrope",
118
+ settings?.font || "poppins",
119
119
  );
120
120
 
121
121
  const localTools = settings?.localTools ?? DEFAULT_LOCAL_TOOLS_SETTINGS;
@@ -195,8 +195,8 @@ export default function SettingsModal({
195
195
 
196
196
  const selectedDefaultModel = selectedDefaultConnection
197
197
  ? defaultModelByConnection[selectedDefaultConnection.id] ||
198
- getConnectionModelPresets(selectedDefaultConnection)[0] ||
199
- ""
198
+ getConnectionModelPresets(selectedDefaultConnection)[0] ||
199
+ ""
200
200
  : "";
201
201
  const selectedPresets = selectedDefaultConnection ? getConnectionModelPresets(selectedDefaultConnection) : [];
202
202
  const selectedFetchedModels = selectedDefaultConnection ? fetchedModels[selectedDefaultConnection.id] ?? [] : [];
@@ -586,11 +586,10 @@ export default function SettingsModal({
586
586
  <button
587
587
  key={tab.id}
588
588
  onClick={() => setActiveTab(tab.id)}
589
- className={`rounded-full px-3 py-1.5 text-xs settings-tab ${
590
- activeTab === tab.id
589
+ className={`rounded-full px-3 py-1.5 text-xs settings-tab ${activeTab === tab.id
591
590
  ? "settings-tab-active"
592
591
  : "border border-[var(--border)] bg-[var(--panel-2)] text-[var(--text-secondary)]"
593
- }`}
592
+ }`}
594
593
  >
595
594
  {tab.label}
596
595
  </button>
@@ -673,7 +672,7 @@ export default function SettingsModal({
673
672
  ) : null}
674
673
  </div>
675
674
  {selectedDefaultConnection?.kind === "builtin" &&
676
- selectedDefaultConnection?.provider === "openai" ? (
675
+ selectedDefaultConnection?.provider === "openai" ? (
677
676
  <label className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
678
677
  <input
679
678
  type="checkbox"
@@ -822,37 +821,37 @@ export default function SettingsModal({
822
821
  />
823
822
  Enabled
824
823
  </label>
825
- <span>
826
- Tools: {(connection.supportsTools ?? true) ? "supported" : "disabled"}
827
- </span>
824
+ <span>
825
+ Tools: {(connection.supportsTools ?? true) ? "supported" : "disabled"}
826
+ </span>
828
827
  {connection.kind === "builtin" ? (
829
828
  <span>Keys configured in API Keys tab</span>
830
829
  ) : null}
831
- {connectionStatus[connection.id] ? <span>{connectionStatus[connection.id]}</span> : null}
832
- </div>
833
- </div>
834
- ))}
835
- </div>
830
+ {connectionStatus[connection.id] ? <span>{connectionStatus[connection.id]}</span> : null}
831
+ </div>
832
+ </div>
833
+ ))}
834
+ </div>
836
835
 
837
836
  <div className="rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4">
838
837
  <div className="mb-3 text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
839
838
  {editingConnectionId ? "Edit Connection" : "Add Connection"}
840
839
  </div>
841
- <div className="grid gap-3 md:grid-cols-2">
840
+ <div className="grid gap-3 md:grid-cols-2">
842
841
  <input
843
842
  className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
844
843
  placeholder="Connection name"
845
844
  value={connectionFormName}
846
845
  onChange={(event) => setConnectionFormName(event.target.value)}
847
846
  />
848
- <select
847
+ <select
849
848
  className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
850
849
  value={connectionFormKind}
851
- onChange={(event) => setConnectionFormKind(event.target.value as ConnectionKind)}
852
- >
853
- <option value="openai_compatible">OpenAI-compatible</option>
854
- <option value="ollama">Ollama</option>
855
- </select>
850
+ onChange={(event) => setConnectionFormKind(event.target.value as ConnectionKind)}
851
+ >
852
+ <option value="openai_compatible">OpenAI-compatible</option>
853
+ <option value="ollama">Ollama</option>
854
+ </select>
856
855
  <input
857
856
  className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm md:col-span-2"
858
857
  placeholder="Base URL (e.g. http://localhost:11434)"
@@ -871,11 +870,11 @@ export default function SettingsModal({
871
870
  value={connectionFormHeadersText}
872
871
  onChange={(event) => setConnectionFormHeadersText(event.target.value)}
873
872
  />
874
- </div>
873
+ </div>
875
874
  {connectionStatus.form ? (
876
875
  <div className="mt-2 text-xs text-[var(--danger)]">{connectionStatus.form}</div>
877
876
  ) : null}
878
- <div className="mt-3 flex flex-wrap items-center gap-3">
877
+ <div className="mt-3 flex flex-wrap items-center gap-3">
879
878
  <label className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
880
879
  <input
881
880
  type="checkbox"
@@ -975,28 +974,28 @@ export default function SettingsModal({
975
974
  placeholder="~/iris-project&#10;~/Desktop"
976
975
  />
977
976
  </div>
978
- <div className="grid gap-2 md:grid-cols-2">
979
- {[
980
- { label: "Enable Apple Notes tools", checked: enableNotes, setter: setEnableNotes },
981
- { label: "Enable app open/focus tools", checked: enableApps, setter: setEnableApps },
982
- { label: "Enable Numbers tools", checked: enableNumbers, setter: setEnableNumbers },
983
- { label: "Enable web search/open tools", checked: enableWeb, setter: setEnableWeb },
984
- { label: "Enable music controls", checked: enableMusic, setter: setEnableMusic },
985
- { label: "Enable calendar/reminders tools", checked: enableCalendar, setter: setEnableCalendar },
986
- { label: "Enable mail/messages tools", checked: enableMail, setter: setEnableMail },
987
- { label: "Enable workflow tool", checked: enableWorkflow, setter: setEnableWorkflow },
988
- { label: "Enable system controls", checked: enableSystem, setter: setEnableSystem },
989
- ].map((item) => (
990
- <label key={item.label} className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
991
- <input
992
- type="checkbox"
993
- checked={item.checked}
994
- onChange={(event) => item.setter(event.target.checked)}
995
- />
996
- {item.label}
997
- </label>
998
- ))}
999
- </div>
977
+ <div className="grid gap-2 md:grid-cols-2">
978
+ {[
979
+ { label: "Enable Apple Notes tools", checked: enableNotes, setter: setEnableNotes },
980
+ { label: "Enable app open/focus tools", checked: enableApps, setter: setEnableApps },
981
+ { label: "Enable Numbers tools", checked: enableNumbers, setter: setEnableNumbers },
982
+ { label: "Enable web search/open tools", checked: enableWeb, setter: setEnableWeb },
983
+ { label: "Enable music controls", checked: enableMusic, setter: setEnableMusic },
984
+ { label: "Enable calendar/reminders tools", checked: enableCalendar, setter: setEnableCalendar },
985
+ { label: "Enable mail/messages tools", checked: enableMail, setter: setEnableMail },
986
+ { label: "Enable workflow tool", checked: enableWorkflow, setter: setEnableWorkflow },
987
+ { label: "Enable system controls", checked: enableSystem, setter: setEnableSystem },
988
+ ].map((item) => (
989
+ <label key={item.label} className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
990
+ <input
991
+ type="checkbox"
992
+ checked={item.checked}
993
+ onChange={(event) => item.setter(event.target.checked)}
994
+ />
995
+ {item.label}
996
+ </label>
997
+ ))}
998
+ </div>
1000
999
  </div>
1001
1000
  ) : null}
1002
1001
 
@@ -1226,14 +1225,14 @@ export default function SettingsModal({
1226
1225
  ? "var(--font-sora)"
1227
1226
  : value === "space"
1228
1227
  ? "var(--font-space)"
1229
- : "var(--font-sans)";
1228
+ : "var(--font-poppins)";
1230
1229
  document.documentElement.style.setProperty("--app-font", fontVar);
1231
1230
  document.body.style.fontFamily = fontVar;
1232
1231
  }}
1233
1232
  >
1234
- <option value="ibm">IBM Plex Sans (Default)</option>
1233
+ <option value="ibm">IBM Plex Sans</option>
1235
1234
  <option value="manrope">Manrope</option>
1236
- <option value="poppins">Poppins</option>
1235
+ <option value="poppins">Poppins (Default)</option>
1237
1236
  <option value="sora">Sora</option>
1238
1237
  <option value="space">Space Grotesk</option>
1239
1238
  </select>
@@ -1255,11 +1254,10 @@ export default function SettingsModal({
1255
1254
  return (
1256
1255
  <button
1257
1256
  key={preset.id}
1258
- className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs ${
1259
- isSelected
1257
+ className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs ${isSelected
1260
1258
  ? "border-[var(--accent-ring)] text-[var(--text-primary)]"
1261
1259
  : "border-[var(--border)] text-[var(--text-secondary)]"
1262
- }`}
1260
+ }`}
1263
1261
  onClick={() =>
1264
1262
  setAccentColor(isDefault ? DEFAULT_ACCENT_DARK : preset.color)
1265
1263
  }