iris-chatbot 5.2.0 → 5.3.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.
@@ -280,16 +280,79 @@ function resolveMusicTargetFromResult(result: unknown): string | null {
280
280
  return artist ? `${title} by ${artist}` : title;
281
281
  }
282
282
 
283
- function toApprovalStatus(decision: "approve" | "deny" | "timeout") {
283
+ function toApprovalStatus(decision: "approve" | "deny" | "timeout" | "supersede") {
284
284
  if (decision === "approve") {
285
285
  return "approved" as const;
286
286
  }
287
287
  if (decision === "deny") {
288
288
  return "denied" as const;
289
289
  }
290
+ if (decision === "supersede") {
291
+ return "superseded" as const;
292
+ }
290
293
  return "timeout" as const;
291
294
  }
292
295
 
296
+ function isDraftSendApproval(
297
+ approval: ToolApproval,
298
+ ): approval is ToolApproval & { toolName: "mail_send" | "messages_send" } {
299
+ return approval.toolName === "mail_send" || approval.toolName === "messages_send";
300
+ }
301
+
302
+ type PendingDraftContext = {
303
+ toolName: "mail_send" | "messages_send";
304
+ to: string[];
305
+ cc: string[];
306
+ subject: string;
307
+ body: string;
308
+ };
309
+
310
+ function parsePendingDraftContext(approval: ToolApproval): PendingDraftContext | null {
311
+ if (!isDraftSendApproval(approval)) {
312
+ return null;
313
+ }
314
+ if (!approval.argsJson) {
315
+ return null;
316
+ }
317
+
318
+ let payload: unknown;
319
+ try {
320
+ payload = JSON.parse(approval.argsJson);
321
+ } catch {
322
+ return null;
323
+ }
324
+ if (!payload || typeof payload !== "object") {
325
+ return null;
326
+ }
327
+ const record = payload as Record<string, unknown>;
328
+
329
+ const to = Array.isArray(record.to) ? record.to.filter((value): value is string => typeof value === "string") : [];
330
+ const cc = Array.isArray(record.cc) ? record.cc.filter((value): value is string => typeof value === "string") : [];
331
+ const subject = typeof record.subject === "string" ? record.subject : "";
332
+ const body = typeof record.body === "string" ? record.body : "";
333
+
334
+ return {
335
+ toolName: approval.toolName,
336
+ to,
337
+ cc,
338
+ subject,
339
+ body,
340
+ };
341
+ }
342
+
343
+ function isLikelyDraftEditFollowup(input: string): boolean {
344
+ const text = input.replace(/\s+/g, " ").trim();
345
+ if (!text) {
346
+ return false;
347
+ }
348
+ const editVerb = /\b(edit|rewrite|change|update|improve|polish|adjust|expand|shorten|lengthen|make)\b/i.test(text);
349
+ const draftNoun = /\b(email|mail|message|text|draft|subject|body)\b/i.test(text);
350
+ const toneOrLength =
351
+ /\b(longer|shorter|formal|casual|professional|friendly|polite|concise|detailed|brief)\b/i.test(text);
352
+ const pronounEdit = /\b(make|rewrite|edit|change|update)\s+(?:it|this)\b/i.test(text);
353
+ return (editVerb && draftNoun) || toneOrLength || pronounEdit;
354
+ }
355
+
293
356
  type MicMode = "off" | "dictation";
294
357
  type MicState = "idle" | "listening" | "processing";
295
358
 
@@ -341,7 +404,8 @@ export default function ChatView({
341
404
  const lastFollowScrollAtRef = useRef(0);
342
405
  const smoothFollowUntilRef = useRef(0);
343
406
  const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
344
- const inflightThreadIdsRef = useRef<Set<string>>(new Set());
407
+ const inflightThreadIdsRef = useRef<Map<string, number>>(new Map());
408
+ const streamingByThreadRef = useRef<Record<string, string>>({});
345
409
  const micModeRef = useRef<MicMode>("off");
346
410
  const micStateRef = useRef<MicState>("idle");
347
411
  const currentInputRef = useRef("");
@@ -500,6 +564,18 @@ export default function ChatView({
500
564
  }
501
565
  return streamingByThread[thread.id] ?? null;
502
566
  }, [streamingByThread, thread?.id]);
567
+ const pendingDraftApprovalMessageIds = useMemo(() => {
568
+ const set = new Set<string>();
569
+ for (const approval of toolApprovals) {
570
+ if (approval.status === "requested" && isDraftSendApproval(approval)) {
571
+ set.add(approval.assistantMessageId);
572
+ }
573
+ }
574
+ return set;
575
+ }, [toolApprovals]);
576
+ const isAwaitingDraftApproval = activeStreamingMessageId
577
+ ? pendingDraftApprovalMessageIds.has(activeStreamingMessageId)
578
+ : false;
503
579
  const micSupported = getSpeechRecognitionCtor() !== null;
504
580
 
505
581
  const toolEventsByMessage = useMemo(() => {
@@ -563,6 +639,10 @@ export default function ChatView({
563
639
  setInput(parts.join(" ").trim());
564
640
  }, []);
565
641
 
642
+ useEffect(() => {
643
+ streamingByThreadRef.current = streamingByThread;
644
+ }, [streamingByThread]);
645
+
566
646
  useEffect(() => {
567
647
  const controllers = abortControllersRef.current;
568
648
  const inflightThreadIds = inflightThreadIdsRef.current;
@@ -642,34 +722,81 @@ export default function ChatView({
642
722
  });
643
723
  }, [thread?.id, activeThreadId]);
644
724
 
645
- const handleApprovalDecision = useCallback(
646
- async (approvalId: string, decision: "approve" | "deny") => {
647
- setApprovalBusyIds((current) => ({
648
- ...current,
649
- [approvalId]: true,
650
- }));
725
+ const resolveApprovalDecision = useCallback(
726
+ async (params: {
727
+ approvalId: string;
728
+ decision: "approve" | "deny" | "supersede";
729
+ argsOverride?: Record<string, unknown>;
730
+ source?: "user" | "system";
731
+ reasonCode?: "user_cancel" | "internal_replace" | "timeout" | "other";
732
+ busy?: boolean;
733
+ suppressErrors?: boolean;
734
+ }) => {
735
+ const {
736
+ approvalId,
737
+ decision,
738
+ argsOverride,
739
+ source = "user",
740
+ reasonCode,
741
+ busy = true,
742
+ suppressErrors = false,
743
+ } = params;
744
+
745
+ if (busy) {
746
+ setApprovalBusyIds((current) => ({
747
+ ...current,
748
+ [approvalId]: true,
749
+ }));
750
+ }
651
751
 
652
752
  try {
753
+ const payload: {
754
+ approvalId: string;
755
+ decision: "approve" | "deny" | "supersede";
756
+ source: "user" | "system";
757
+ reasonCode?: "user_cancel" | "internal_replace" | "timeout" | "other";
758
+ args?: Record<string, unknown>;
759
+ } = { approvalId, decision, source };
760
+ if (reasonCode) {
761
+ payload.reasonCode = reasonCode;
762
+ }
763
+ if (decision === "approve" && argsOverride) {
764
+ payload.args = argsOverride;
765
+ }
766
+
653
767
  const response = await fetch("/api/tool-approval", {
654
768
  method: "POST",
655
769
  headers: { "Content-Type": "application/json" },
656
- body: JSON.stringify({ approvalId, decision }),
770
+ body: JSON.stringify(payload),
657
771
  });
658
772
 
659
773
  if (!response.ok) {
660
- const payload = await response.json().catch(() => ({}));
661
- throw new Error(payload.error || "Failed to resolve approval.");
774
+ const responsePayload = await response.json().catch(() => ({}));
775
+ const message = responsePayload.error || "Failed to resolve approval.";
776
+ if (!suppressErrors) {
777
+ throw new Error(message);
778
+ }
779
+ return;
662
780
  }
663
781
 
664
782
  await db.toolApprovals.update(approvalId, {
665
783
  status: toApprovalStatus(decision),
666
784
  resolvedAt: Date.now(),
785
+ ...(decision === "approve" && argsOverride
786
+ ? { argsJson: JSON.stringify(argsOverride) }
787
+ : {}),
667
788
  });
668
789
  } catch (err) {
790
+ if (suppressErrors) {
791
+ return;
792
+ }
669
793
  const message =
670
794
  err instanceof Error ? err.message : "Unable to resolve approval.";
671
795
  setError(message);
672
796
  } finally {
797
+ if (!busy) {
798
+ return;
799
+ }
673
800
  setApprovalBusyIds((current) => {
674
801
  if (!current[approvalId]) {
675
802
  return current;
@@ -680,7 +807,38 @@ export default function ChatView({
680
807
  });
681
808
  }
682
809
  },
683
- []
810
+ [],
811
+ );
812
+
813
+ const handleApprovalDecision = useCallback(
814
+ async (
815
+ approvalId: string,
816
+ decision: "approve" | "deny",
817
+ argsOverride?: Record<string, unknown>,
818
+ ) => {
819
+ await resolveApprovalDecision({
820
+ approvalId,
821
+ decision,
822
+ argsOverride,
823
+ source: "user",
824
+ reasonCode: decision === "deny" ? "user_cancel" : undefined,
825
+ });
826
+ },
827
+ [resolveApprovalDecision],
828
+ );
829
+
830
+ const resolveApprovalSystemSupersede = useCallback(
831
+ async (approvalId: string) => {
832
+ await resolveApprovalDecision({
833
+ approvalId,
834
+ decision: "supersede",
835
+ source: "system",
836
+ reasonCode: "internal_replace",
837
+ busy: false,
838
+ suppressErrors: true,
839
+ });
840
+ },
841
+ [resolveApprovalDecision],
684
842
  );
685
843
 
686
844
  const startMicRecognitionSession = useCallback(
@@ -937,6 +1095,7 @@ export default function ChatView({
937
1095
  const trimmed = (overrideInput ?? input).trim();
938
1096
  const hasQuoted = Boolean(quotedContext);
939
1097
  if (!trimmed && !hasQuoted) return;
1098
+ let draftEditContextForRequest: PendingDraftContext | null = null;
940
1099
  const sendTriggeredByMic =
941
1100
  typeof overrideInput === "string" || micModeRef.current !== "off";
942
1101
  const stopMicAfterValidationFailure = () => {
@@ -987,15 +1146,51 @@ export default function ChatView({
987
1146
  setMicState("processing");
988
1147
  }
989
1148
 
990
- if (
991
- streamingByThread[resolvedThread.id] ||
992
- inflightThreadIdsRef.current.has(resolvedThread.id)
993
- ) {
1149
+ const isThreadLocked = () =>
1150
+ Boolean(streamingByThreadRef.current[resolvedThread.id]) ||
1151
+ (inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) > 0;
1152
+ if (isThreadLocked()) {
1153
+ const pendingDraftApprovals = toolApprovals.filter(
1154
+ (approval) =>
1155
+ approval.threadId === resolvedThread.id &&
1156
+ approval.status === "requested" &&
1157
+ isDraftSendApproval(approval),
1158
+ );
1159
+
1160
+ if (pendingDraftApprovals.length > 0) {
1161
+ if (isLikelyDraftEditFollowup(trimmed)) {
1162
+ const latestPendingApproval = [...pendingDraftApprovals].sort(
1163
+ (left, right) => right.requestedAt - left.requestedAt,
1164
+ )[0];
1165
+ const parsedDraft = parsePendingDraftContext(latestPendingApproval);
1166
+ if (parsedDraft) {
1167
+ draftEditContextForRequest = parsedDraft;
1168
+ }
1169
+ }
1170
+
1171
+ for (const approval of pendingDraftApprovals) {
1172
+ await resolveApprovalSystemSupersede(approval.id);
1173
+ }
1174
+
1175
+ const controller = abortControllersRef.current.get(resolvedThread.id);
1176
+ controller?.abort();
1177
+
1178
+ const unlockDeadline = Date.now() + 1500;
1179
+ while (isThreadLocked() && Date.now() < unlockDeadline) {
1180
+ await new Promise((resolve) => window.setTimeout(resolve, 25));
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ if (isThreadLocked()) {
994
1186
  setError("This thread is already responding.");
995
1187
  return;
996
1188
  }
997
1189
 
998
- inflightThreadIdsRef.current.add(resolvedThread.id);
1190
+ inflightThreadIdsRef.current.set(
1191
+ resolvedThread.id,
1192
+ (inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) + 1,
1193
+ );
999
1194
  setError(null);
1000
1195
  setInput("");
1001
1196
  const capturedQuotedContext = quotedContext;
@@ -1136,7 +1331,6 @@ export default function ChatView({
1136
1331
  siblingThreadsContext,
1137
1332
  });
1138
1333
  }
1139
-
1140
1334
  payloadMessages.push(
1141
1335
  ...history
1142
1336
  .filter((message) => message.role !== "system")
@@ -1209,6 +1403,18 @@ export default function ChatView({
1209
1403
  threadId: resolvedThread.id,
1210
1404
  conversationId: resolvedThread.conversationId,
1211
1405
  assistantMessageId: assistantMessage.id,
1406
+ ...(draftEditContextForRequest
1407
+ ? {
1408
+ forceToolRouting: true,
1409
+ draftEditContext: {
1410
+ toolName: draftEditContextForRequest.toolName,
1411
+ to: draftEditContextForRequest.to,
1412
+ cc: draftEditContextForRequest.cc,
1413
+ subject: draftEditContextForRequest.subject,
1414
+ body: draftEditContextForRequest.body,
1415
+ },
1416
+ }
1417
+ : {}),
1212
1418
  },
1213
1419
  }),
1214
1420
  signal: controller.signal,
@@ -1512,10 +1718,17 @@ export default function ChatView({
1512
1718
  });
1513
1719
  setError(message);
1514
1720
  } finally {
1515
- inflightThreadIdsRef.current.delete(resolvedThread.id);
1516
- abortControllersRef.current.delete(resolvedThread.id);
1721
+ const currentInflight = inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0;
1722
+ if (currentInflight <= 1) {
1723
+ inflightThreadIdsRef.current.delete(resolvedThread.id);
1724
+ } else {
1725
+ inflightThreadIdsRef.current.set(resolvedThread.id, currentInflight - 1);
1726
+ }
1727
+ if (abortControllersRef.current.get(resolvedThread.id) === controller) {
1728
+ abortControllersRef.current.delete(resolvedThread.id);
1729
+ }
1517
1730
  setStreamingByThread((current) => {
1518
- if (!current[resolvedThread.id]) {
1731
+ if (current[resolvedThread.id] !== assistantMessage.id) {
1519
1732
  return current;
1520
1733
  }
1521
1734
  const next = { ...current };
@@ -1553,7 +1766,8 @@ export default function ChatView({
1553
1766
  threads,
1554
1767
  activeThreadId,
1555
1768
  onOpenSettings,
1556
- streamingByThread,
1769
+ toolApprovals,
1770
+ resolveApprovalSystemSupersede,
1557
1771
  scrollToBottom,
1558
1772
  setFocusedMessageId,
1559
1773
  maybeAutoScrollToBottom,
@@ -1641,7 +1855,10 @@ export default function ChatView({
1641
1855
  activeThreadId={activeThreadId}
1642
1856
  onSelectThread={onSelectThread}
1643
1857
  onDeleteThread={handleDeleteBranch}
1644
- isStreaming={activeStreamingMessageId === message.id}
1858
+ isStreaming={
1859
+ activeStreamingMessageId === message.id &&
1860
+ !pendingDraftApprovalMessageIds.has(message.id)
1861
+ }
1645
1862
  toolEvents={messageToolEvents}
1646
1863
  approvals={messageApprovals}
1647
1864
  onResolveApproval={handleApprovalDecision}
@@ -1659,6 +1876,7 @@ export default function ChatView({
1659
1876
  onSelectThread,
1660
1877
  handleDeleteBranch,
1661
1878
  activeStreamingMessageId,
1879
+ pendingDraftApprovalMessageIds,
1662
1880
  toolEventsByMessage,
1663
1881
  toolApprovalsByMessage,
1664
1882
  handleApprovalDecision,
@@ -1688,10 +1906,11 @@ export default function ChatView({
1688
1906
  void handleSend();
1689
1907
  }}
1690
1908
  onStop={handleStop}
1691
- isStreaming={Boolean(activeStreamingMessageId)}
1909
+ isStreaming={Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval}
1910
+ showStopWhenStreaming={!isAwaitingDraftApproval}
1692
1911
  onMicToggle={handleMicToggle}
1693
1912
  micState={micState}
1694
- micDisabled={Boolean(activeStreamingMessageId)}
1913
+ micDisabled={Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval}
1695
1914
  quotedContext={quotedContext}
1696
1915
  onClearQuotedContext={handleClearQuotedContext}
1697
1916
  />
@@ -1753,10 +1972,11 @@ export default function ChatView({
1753
1972
  void handleSend();
1754
1973
  }}
1755
1974
  onStop={handleStop}
1756
- isStreaming={Boolean(activeStreamingMessageId)}
1975
+ isStreaming={Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval}
1976
+ showStopWhenStreaming={!isAwaitingDraftApproval}
1757
1977
  onMicToggle={handleMicToggle}
1758
1978
  micState={micState}
1759
- micDisabled={Boolean(activeStreamingMessageId)}
1979
+ micDisabled={Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval}
1760
1980
  quotedContext={quotedContext}
1761
1981
  onClearQuotedContext={handleClearQuotedContext}
1762
1982
  />
@@ -18,6 +18,7 @@ export default function Composer({
18
18
  onSend,
19
19
  onStop,
20
20
  isStreaming,
21
+ showStopWhenStreaming = true,
21
22
  onMicToggle,
22
23
  micState = "idle",
23
24
  micDisabled = false,
@@ -29,6 +30,7 @@ export default function Composer({
29
30
  onSend: () => void;
30
31
  onStop: () => void;
31
32
  isStreaming: boolean;
33
+ showStopWhenStreaming?: boolean;
32
34
  onMicToggle?: () => void;
33
35
  micState?: "idle" | "listening" | "processing";
34
36
  micDisabled?: boolean;
@@ -177,13 +179,15 @@ export default function Composer({
177
179
  </button>
178
180
  ) : null}
179
181
  {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>
182
+ showStopWhenStreaming ? (
183
+ <button
184
+ onClick={onStop}
185
+ 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)]"
186
+ >
187
+ <Square className="h-3 w-3" />
188
+ Stop
189
+ </button>
190
+ ) : null
187
191
  ) : (
188
192
  <button
189
193
  onClick={onSend}
@@ -196,4 +200,3 @@ export default function Composer({
196
200
  </div>
197
201
  );
198
202
  }
199
-