iris-chatbot 5.0.0 → 5.0.2

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.2",
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.2",
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.2",
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.2",
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,54 @@ 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) => {
1105
+ let content = message.content;
1106
+
1107
+ // Enrich assistant messages with tool call summaries so the model
1108
+ // retains awareness of tools it successfully used earlier in the
1109
+ // conversation. Without this, the model "forgets" its own tool
1110
+ // capabilities and may deny being able to do things it already did.
1111
+ if (message.role === "assistant") {
1112
+ const events = toolEventsByMessage.get(message.id);
1113
+ if (events && events.length > 0) {
1114
+ const resultEvents = events.filter((e) => e.stage === "result");
1115
+ if (resultEvents.length > 0) {
1116
+ const summaries = resultEvents.map((e) => {
1117
+ const name = e.toolName || "Unknown Tool";
1118
+ // Try to extract a meaningful result summary from payloadJson
1119
+ let resultSummary = "";
1120
+ if (e.payloadJson) {
1121
+ try {
1122
+ const payload = JSON.parse(e.payloadJson);
1123
+ if (typeof payload === "object" && payload !== null) {
1124
+ // Look for common result fields
1125
+ resultSummary =
1126
+ payload.message || payload.summary || payload.result ||
1127
+ payload.status || payload.output || "";
1128
+ if (typeof resultSummary !== "string") {
1129
+ resultSummary = JSON.stringify(payload).slice(0, 120);
1130
+ }
1131
+ } else if (typeof payload === "string") {
1132
+ resultSummary = payload;
1133
+ }
1134
+ } catch {
1135
+ // payloadJson may not be valid JSON; ignore
1136
+ }
1137
+ }
1138
+ if (!resultSummary && e.message && e.message !== "success") {
1139
+ resultSummary = e.message;
1140
+ }
1141
+ return resultSummary ? `${name} → ${resultSummary}` : name;
1142
+ });
1143
+ const toolPrefix = `[Tools used: ${summaries.join("; ")}]`;
1144
+ content = content ? `${toolPrefix}\n\n${content}` : toolPrefix;
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ return { role: message.role, content };
1150
+ }),
992
1151
  );
993
1152
 
994
1153
  const controller = new AbortController();
@@ -1363,7 +1522,9 @@ export default function ChatView({
1363
1522
  stopMicRecognitionSession,
1364
1523
  startMicRecognitionSession,
1365
1524
  stopMicMode,
1525
+ quotedContext,
1366
1526
  messageMap,
1527
+ toolEventsByMessage,
1367
1528
  ]);
1368
1529
 
1369
1530
  useEffect(() => {
@@ -1472,9 +1633,8 @@ export default function ChatView({
1472
1633
  <div
1473
1634
  ref={chatScrollRef}
1474
1635
  onScroll={updateBottomVisibility}
1475
- className={`chat-scroll flex-1 overflow-y-auto px-6 py-8${
1476
- threadMessages.length === 0 ? " empty" : ""
1477
- }`}
1636
+ className={`chat-scroll flex-1 overflow-y-auto px-6 py-8${threadMessages.length === 0 ? " empty" : ""
1637
+ }`}
1478
1638
  >
1479
1639
  {threadMessages.length === 0 ? (
1480
1640
  <div className="empty-hero">
@@ -1493,6 +1653,8 @@ export default function ChatView({
1493
1653
  onMicToggle={handleMicToggle}
1494
1654
  micState={micState}
1495
1655
  micDisabled={Boolean(activeStreamingMessageId)}
1656
+ quotedContext={quotedContext}
1657
+ onClearQuotedContext={handleClearQuotedContext}
1496
1658
  />
1497
1659
  </div>
1498
1660
  </div>
@@ -1501,21 +1663,36 @@ export default function ChatView({
1501
1663
  {renderedThreadRows}
1502
1664
  </div>
1503
1665
  )}
1666
+ {askButtonPos ? (
1667
+ <button
1668
+ ref={askButtonRef}
1669
+ className="ask-selection-button"
1670
+ style={{
1671
+ top: askButtonPos.top,
1672
+ left: askButtonPos.left,
1673
+ }}
1674
+ onMouseDown={handleAskClick}
1675
+ aria-label="Ask about selection"
1676
+ >
1677
+ <TextQuote className="h-3.5 w-3.5" />
1678
+ Ask
1679
+ </button>
1680
+ ) : null}
1504
1681
  <div ref={bottomRef} className="h-2" />
1505
1682
  </div>
1506
1683
  {threadMessages.length > 0 ? (
1507
- <>
1508
- {showJumpToBottom ? (
1684
+ <>
1685
+ {showJumpToBottom ? (
1509
1686
  <div className="pointer-events-none absolute bottom-28 left-1/2 z-30 -translate-x-1/2 opacity-80 transition-opacity duration-200">
1510
1687
  <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"
1688
+ 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"
1689
+ onClick={() => {
1690
+ shouldStickToBottomRef.current = true;
1691
+ setShowJumpToBottom(false);
1692
+ smoothFollowUntilRef.current = performance.now() + 900;
1693
+ bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
1694
+ }}
1695
+ aria-label="Jump to latest message"
1519
1696
  title="Jump to latest"
1520
1697
  >
1521
1698
  <ArrowDown className="h-4 w-4" />
@@ -1523,27 +1700,29 @@ export default function ChatView({
1523
1700
  </div>
1524
1701
  ) : null}
1525
1702
 
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
- />
1703
+ <div className="composer-bar px-6 py-4">
1704
+ <div className="mx-auto w-full max-w-3xl">
1705
+ {error ? (
1706
+ <div className="mb-3 rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-xs text-[var(--danger)]">
1707
+ {error}
1708
+ </div>
1709
+ ) : null}
1710
+ <Composer
1711
+ value={input}
1712
+ onChange={setInput}
1713
+ onSend={() => {
1714
+ void handleSend();
1715
+ }}
1716
+ onStop={handleStop}
1717
+ isStreaming={Boolean(activeStreamingMessageId)}
1718
+ onMicToggle={handleMicToggle}
1719
+ micState={micState}
1720
+ micDisabled={Boolean(activeStreamingMessageId)}
1721
+ quotedContext={quotedContext}
1722
+ onClearQuotedContext={handleClearQuotedContext}
1723
+ />
1724
+ </div>
1545
1725
  </div>
1546
- </div>
1547
1726
  </>
1548
1727
  ) : null}
1549
1728
  </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
  }
@@ -900,11 +900,24 @@ async function runMusicSetVolume(input: unknown, context: ToolExecutionContext)
900
900
  return { dryRun: true, action: "music_set_volume", target, level };
901
901
  }
902
902
 
903
- const script =
904
- target === "music"
905
- ? `tell application "Music" to set sound volume to ${level}`
906
- : `set volume output volume ${level}`;
907
- await runAppleScript(script, [], context.signal);
903
+ // Always set the system output volume so the user hears an audible change.
904
+ // The Music app's internal sound volume is often already at 100%, so
905
+ // only setting that has no effect on actual output loudness.
906
+ await runAppleScript(`set volume output volume ${level}`, [], context.signal);
907
+
908
+ // If targeted at Music, also set the Music app's internal volume.
909
+ if (target === "music") {
910
+ try {
911
+ await runAppleScript(
912
+ `tell application "Music" to set sound volume to ${level}`,
913
+ [],
914
+ context.signal,
915
+ );
916
+ } catch {
917
+ // Music app may not be running; system volume is already set.
918
+ }
919
+ }
920
+
908
921
  return { target, level };
909
922
  }
910
923