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.
@@ -9,11 +9,12 @@ import {
9
9
  ChevronUp,
10
10
  Check,
11
11
  Copy,
12
+ Send,
12
13
  Pencil,
13
14
  PlusCircle,
14
15
  X,
15
16
  } from "lucide-react";
16
- import { memo, useMemo, useState } from "react";
17
+ import { memo, useMemo, useRef, useState } from "react";
17
18
  import type {
18
19
  ChatCitationSource,
19
20
  MessageNode,
@@ -731,6 +732,502 @@ function getTimelineVisual(event: ToolEvent): {
731
732
  };
732
733
  }
733
734
 
735
+ type DraftTemplate = {
736
+ channel: "email" | "message";
737
+ to: string[];
738
+ cc: string[];
739
+ subject: string;
740
+ body: string;
741
+ attachments: string[];
742
+ };
743
+
744
+ type ContactSuggestion = {
745
+ name: string;
746
+ value: string;
747
+ detail: string;
748
+ };
749
+
750
+ function toStringArray(value: unknown): string[] {
751
+ if (!Array.isArray(value)) {
752
+ return [];
753
+ }
754
+ return value
755
+ .filter((item): item is string => typeof item === "string")
756
+ .map((item) => item.trim())
757
+ .filter(Boolean);
758
+ }
759
+
760
+ function dedupeStrings(values: string[]): string[] {
761
+ const seen = new Set<string>();
762
+ const out: string[] = [];
763
+ for (const value of values) {
764
+ if (!value || seen.has(value)) {
765
+ continue;
766
+ }
767
+ seen.add(value);
768
+ out.push(value);
769
+ }
770
+ return out;
771
+ }
772
+
773
+ function tokenizeRecipients(value: string): string[] {
774
+ return dedupeStrings(
775
+ value
776
+ .split(/[\n,]/g)
777
+ .map((item) => item.trim())
778
+ .filter(Boolean),
779
+ );
780
+ }
781
+
782
+ function parseDraftTemplate(approval: ToolApproval): DraftTemplate | null {
783
+ if (approval.toolName !== "mail_send" && approval.toolName !== "messages_send") {
784
+ return null;
785
+ }
786
+ const payload = parsePayload(approval.argsJson);
787
+ if (!payload) {
788
+ return null;
789
+ }
790
+ const to = toStringArray(payload.to);
791
+ const body = typeof payload.body === "string" ? payload.body : "";
792
+ if (to.length === 0 || !body) {
793
+ return null;
794
+ }
795
+ if (approval.toolName === "mail_send") {
796
+ const subject = typeof payload.subject === "string" ? payload.subject : "";
797
+ if (!subject) {
798
+ return null;
799
+ }
800
+ return {
801
+ channel: "email",
802
+ to,
803
+ cc: toStringArray(payload.cc),
804
+ subject,
805
+ body,
806
+ attachments: toStringArray(payload.attachments),
807
+ };
808
+ }
809
+ return {
810
+ channel: "message",
811
+ to,
812
+ cc: [],
813
+ subject: "",
814
+ body,
815
+ attachments: [],
816
+ };
817
+ }
818
+
819
+ function buildDraftCopyText(template: DraftTemplate): string {
820
+ if (template.channel === "email") {
821
+ return `Subject: ${template.subject}\n\n${template.body}`;
822
+ }
823
+ return template.body;
824
+ }
825
+
826
+ function DraftApprovalEditor({
827
+ approval,
828
+ isBusy,
829
+ onResolveApproval,
830
+ }: {
831
+ approval: ToolApproval;
832
+ isBusy: boolean;
833
+ onResolveApproval: (approvalId: string, decision: "approve" | "deny", argsOverride?: Record<string, unknown>) => void;
834
+ }) {
835
+ const parsed = parseDraftTemplate(approval);
836
+ const [toRecipients, setToRecipients] = useState<string[]>(() => parsed?.to ?? []);
837
+ const [ccRecipients, setCcRecipients] = useState<string[]>(() => parsed?.cc ?? []);
838
+ const [toInput, setToInput] = useState("");
839
+ const [ccInput, setCcInput] = useState("");
840
+ const [toSuggestions, setToSuggestions] = useState<ContactSuggestion[]>([]);
841
+ const [ccSuggestions, setCcSuggestions] = useState<ContactSuggestion[]>([]);
842
+ const [showToSuggestions, setShowToSuggestions] = useState(false);
843
+ const [showCcSuggestions, setShowCcSuggestions] = useState(false);
844
+ const [contactsHint, setContactsHint] = useState<string | null>(null);
845
+ const [subject, setSubject] = useState(() => parsed?.subject ?? "");
846
+ const [body, setBody] = useState(() => parsed?.body ?? "");
847
+ const [copiedDraft, setCopiedDraft] = useState(false);
848
+ const toSuggestTimerRef = useRef<number | null>(null);
849
+ const ccSuggestTimerRef = useRef<number | null>(null);
850
+ const toSuggestAbortRef = useRef<AbortController | null>(null);
851
+ const ccSuggestAbortRef = useRef<AbortController | null>(null);
852
+
853
+ if (!parsed) {
854
+ return null;
855
+ }
856
+
857
+ const mergedTo = dedupeStrings([...toRecipients, ...tokenizeRecipients(toInput)]);
858
+ const mergedCc = dedupeStrings([...ccRecipients, ...tokenizeRecipients(ccInput)]);
859
+ const canSend =
860
+ mergedTo.length > 0 &&
861
+ body.trim().length > 0 &&
862
+ (parsed.channel === "message" || subject.trim().length > 0);
863
+
864
+ const commitToInput = () => {
865
+ if (!toInput.trim()) {
866
+ setToSuggestions([]);
867
+ return;
868
+ }
869
+ setToRecipients((current) => dedupeStrings([...current, ...tokenizeRecipients(toInput)]));
870
+ setToInput("");
871
+ setToSuggestions([]);
872
+ setShowToSuggestions(false);
873
+ };
874
+
875
+ const commitCcInput = () => {
876
+ if (!ccInput.trim()) {
877
+ setCcSuggestions([]);
878
+ return;
879
+ }
880
+ setCcRecipients((current) => dedupeStrings([...current, ...tokenizeRecipients(ccInput)]));
881
+ setCcInput("");
882
+ setCcSuggestions([]);
883
+ setShowCcSuggestions(false);
884
+ };
885
+
886
+ const queueSuggestions = (params: { query: string; field: "to" | "cc" }) => {
887
+ const trimmed = params.query.trim();
888
+ const mode = parsed?.channel === "email" ? "email" : "message";
889
+ const timerRef = params.field === "to" ? toSuggestTimerRef : ccSuggestTimerRef;
890
+ const abortRef = params.field === "to" ? toSuggestAbortRef : ccSuggestAbortRef;
891
+ const setSuggestions = params.field === "to" ? setToSuggestions : setCcSuggestions;
892
+
893
+ if (timerRef.current !== null) {
894
+ window.clearTimeout(timerRef.current);
895
+ timerRef.current = null;
896
+ }
897
+ abortRef.current?.abort();
898
+ abortRef.current = null;
899
+
900
+ if (!trimmed || trimmed.length < 2) {
901
+ setSuggestions([]);
902
+ return;
903
+ }
904
+
905
+ timerRef.current = window.setTimeout(async () => {
906
+ const controller = new AbortController();
907
+ abortRef.current = controller;
908
+ try {
909
+ const response = await fetch("/api/contacts/search", {
910
+ method: "POST",
911
+ headers: { "Content-Type": "application/json" },
912
+ body: JSON.stringify({
913
+ query: trimmed,
914
+ mode,
915
+ }),
916
+ signal: controller.signal,
917
+ });
918
+ const payload = (await response.json().catch(() => ({}))) as {
919
+ ok?: boolean;
920
+ suggestions?: ContactSuggestion[];
921
+ error?: string;
922
+ permissionRequired?: boolean;
923
+ };
924
+ if (!response.ok || payload.ok === false) {
925
+ setSuggestions([]);
926
+ if (payload.permissionRequired) {
927
+ setContactsHint(
928
+ payload.error ||
929
+ "Contacts permission is required for suggestions. Approve when macOS prompts.",
930
+ );
931
+ }
932
+ return;
933
+ }
934
+ const suggestions = Array.isArray(payload.suggestions)
935
+ ? payload.suggestions
936
+ .map((item) => ({
937
+ name: typeof item?.name === "string" ? item.name.trim() : "",
938
+ value: typeof item?.value === "string" ? item.value.trim() : "",
939
+ detail: typeof item?.detail === "string" ? item.detail.trim() : "",
940
+ }))
941
+ .filter((item) => item.name && item.value)
942
+ : [];
943
+ setContactsHint(null);
944
+ setSuggestions(suggestions);
945
+ } catch (error) {
946
+ if (error instanceof Error && error.name === "AbortError") {
947
+ return;
948
+ }
949
+ setSuggestions([]);
950
+ }
951
+ }, 170);
952
+ };
953
+
954
+ const addRecipientFromSuggestion = (params: { field: "to" | "cc"; value: string }) => {
955
+ if (params.field === "to") {
956
+ setToRecipients((current) => dedupeStrings([...current, params.value]));
957
+ setToInput("");
958
+ setToSuggestions([]);
959
+ setShowToSuggestions(false);
960
+ return;
961
+ }
962
+ setCcRecipients((current) => dedupeStrings([...current, params.value]));
963
+ setCcInput("");
964
+ setCcSuggestions([]);
965
+ setShowCcSuggestions(false);
966
+ };
967
+
968
+ const buildOverrideArgs = (): Record<string, unknown> => {
969
+ const args: Record<string, unknown> = {
970
+ to: mergedTo,
971
+ body,
972
+ };
973
+ if (parsed.channel === "email") {
974
+ args.subject = subject;
975
+ if (mergedCc.length > 0) {
976
+ args.cc = mergedCc;
977
+ }
978
+ if (parsed.attachments.length > 0) {
979
+ args.attachments = parsed.attachments;
980
+ }
981
+ }
982
+ return args;
983
+ };
984
+
985
+ return (
986
+ <div className="draft-approval-card">
987
+ <div className="draft-approval-header">
988
+ <div className="draft-approval-title">{parsed.channel === "email" ? "Email" : "Message"} draft</div>
989
+ <div className="draft-approval-actions">
990
+ <button
991
+ className="draft-approval-icon-btn"
992
+ onClick={async () => {
993
+ await navigator.clipboard.writeText(
994
+ buildDraftCopyText({
995
+ ...parsed,
996
+ to: mergedTo,
997
+ cc: mergedCc,
998
+ subject,
999
+ body,
1000
+ }),
1001
+ );
1002
+ setCopiedDraft(true);
1003
+ window.setTimeout(() => setCopiedDraft(false), 1200);
1004
+ }}
1005
+ disabled={isBusy}
1006
+ title="Copy draft"
1007
+ aria-label="Copy draft"
1008
+ >
1009
+ {copiedDraft ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
1010
+ </button>
1011
+ <button
1012
+ className="draft-approval-icon-btn primary"
1013
+ onClick={() => {
1014
+ commitToInput();
1015
+ commitCcInput();
1016
+ onResolveApproval(approval.id, "approve", buildOverrideArgs());
1017
+ }}
1018
+ disabled={isBusy || !canSend}
1019
+ title="Send"
1020
+ aria-label="Send draft"
1021
+ >
1022
+ <Send className="h-4 w-4" />
1023
+ </button>
1024
+ </div>
1025
+ </div>
1026
+
1027
+ <div className="draft-approval-field">
1028
+ <span className="draft-approval-label">To</span>
1029
+ <div className="draft-recipient-field-stack">
1030
+ <div className="draft-recipient-input-wrap">
1031
+ {toRecipients.map((recipient, index) => (
1032
+ <span key={`${recipient}-${index}`} className="draft-recipient-chip">
1033
+ {recipient}
1034
+ <button
1035
+ className="draft-recipient-chip-remove"
1036
+ onClick={() => setToRecipients((current) => current.filter((_, itemIndex) => itemIndex !== index))}
1037
+ aria-label={`Remove ${recipient}`}
1038
+ disabled={isBusy}
1039
+ >
1040
+ <X className="h-3 w-3" />
1041
+ </button>
1042
+ </span>
1043
+ ))}
1044
+ <input
1045
+ value={toInput}
1046
+ onChange={(event) => {
1047
+ const next = event.target.value;
1048
+ setToInput(next);
1049
+ queueSuggestions({ query: next, field: "to" });
1050
+ }}
1051
+ onFocus={() => {
1052
+ setShowToSuggestions(true);
1053
+ queueSuggestions({ query: toInput, field: "to" });
1054
+ }}
1055
+ onBlur={() => {
1056
+ window.setTimeout(() => {
1057
+ setShowToSuggestions(false);
1058
+ commitToInput();
1059
+ }, 80);
1060
+ }}
1061
+ onKeyDown={(event) => {
1062
+ if (event.key === "Enter" || event.key === "Tab") {
1063
+ if (showToSuggestions && toSuggestions.length > 0) {
1064
+ event.preventDefault();
1065
+ addRecipientFromSuggestion({ field: "to", value: toSuggestions[0].value });
1066
+ return;
1067
+ }
1068
+ event.preventDefault();
1069
+ commitToInput();
1070
+ return;
1071
+ }
1072
+ if (event.key === ",") {
1073
+ event.preventDefault();
1074
+ commitToInput();
1075
+ return;
1076
+ }
1077
+ if (event.key === "Backspace" && !toInput && toRecipients.length > 0) {
1078
+ setToRecipients((current) => current.slice(0, -1));
1079
+ }
1080
+ }}
1081
+ placeholder="Add recipient"
1082
+ className="draft-recipient-input"
1083
+ disabled={isBusy}
1084
+ />
1085
+ </div>
1086
+ {showToSuggestions && (toSuggestions.length > 0 || contactsHint) ? (
1087
+ <div className="draft-contact-suggestions">
1088
+ {toSuggestions.map((suggestion) => (
1089
+ <button
1090
+ key={`${suggestion.value}-${suggestion.name}`}
1091
+ className="draft-contact-suggestion-item"
1092
+ onMouseDown={(event) => {
1093
+ event.preventDefault();
1094
+ addRecipientFromSuggestion({ field: "to", value: suggestion.value });
1095
+ }}
1096
+ type="button"
1097
+ >
1098
+ <span className="draft-contact-suggestion-name">{suggestion.name}</span>
1099
+ <span className="draft-contact-suggestion-detail">{suggestion.detail}</span>
1100
+ </button>
1101
+ ))}
1102
+ {toSuggestions.length === 0 && contactsHint ? (
1103
+ <div className="draft-contact-suggestion-hint">{contactsHint}</div>
1104
+ ) : null}
1105
+ </div>
1106
+ ) : null}
1107
+ </div>
1108
+ </div>
1109
+
1110
+ {parsed.channel === "email" ? (
1111
+ <div className="draft-approval-field">
1112
+ <span className="draft-approval-label">Cc</span>
1113
+ <div className="draft-recipient-field-stack">
1114
+ <div className="draft-recipient-input-wrap">
1115
+ {ccRecipients.map((recipient, index) => (
1116
+ <span key={`${recipient}-${index}`} className="draft-recipient-chip">
1117
+ {recipient}
1118
+ <button
1119
+ className="draft-recipient-chip-remove"
1120
+ onClick={() => setCcRecipients((current) => current.filter((_, itemIndex) => itemIndex !== index))}
1121
+ aria-label={`Remove ${recipient}`}
1122
+ disabled={isBusy}
1123
+ >
1124
+ <X className="h-3 w-3" />
1125
+ </button>
1126
+ </span>
1127
+ ))}
1128
+ <input
1129
+ value={ccInput}
1130
+ onChange={(event) => {
1131
+ const next = event.target.value;
1132
+ setCcInput(next);
1133
+ queueSuggestions({ query: next, field: "cc" });
1134
+ }}
1135
+ onFocus={() => {
1136
+ setShowCcSuggestions(true);
1137
+ queueSuggestions({ query: ccInput, field: "cc" });
1138
+ }}
1139
+ onBlur={() => {
1140
+ window.setTimeout(() => {
1141
+ setShowCcSuggestions(false);
1142
+ commitCcInput();
1143
+ }, 80);
1144
+ }}
1145
+ onKeyDown={(event) => {
1146
+ if (event.key === "Enter" || event.key === "Tab") {
1147
+ if (showCcSuggestions && ccSuggestions.length > 0) {
1148
+ event.preventDefault();
1149
+ addRecipientFromSuggestion({ field: "cc", value: ccSuggestions[0].value });
1150
+ return;
1151
+ }
1152
+ event.preventDefault();
1153
+ commitCcInput();
1154
+ return;
1155
+ }
1156
+ if (event.key === ",") {
1157
+ event.preventDefault();
1158
+ commitCcInput();
1159
+ return;
1160
+ }
1161
+ if (event.key === "Backspace" && !ccInput && ccRecipients.length > 0) {
1162
+ setCcRecipients((current) => current.slice(0, -1));
1163
+ }
1164
+ }}
1165
+ placeholder="Add cc"
1166
+ className="draft-recipient-input"
1167
+ disabled={isBusy}
1168
+ />
1169
+ </div>
1170
+ {showCcSuggestions && (ccSuggestions.length > 0 || contactsHint) ? (
1171
+ <div className="draft-contact-suggestions">
1172
+ {ccSuggestions.map((suggestion) => (
1173
+ <button
1174
+ key={`${suggestion.value}-${suggestion.name}`}
1175
+ className="draft-contact-suggestion-item"
1176
+ onMouseDown={(event) => {
1177
+ event.preventDefault();
1178
+ addRecipientFromSuggestion({ field: "cc", value: suggestion.value });
1179
+ }}
1180
+ type="button"
1181
+ >
1182
+ <span className="draft-contact-suggestion-name">{suggestion.name}</span>
1183
+ <span className="draft-contact-suggestion-detail">{suggestion.detail}</span>
1184
+ </button>
1185
+ ))}
1186
+ {ccSuggestions.length === 0 && contactsHint ? (
1187
+ <div className="draft-contact-suggestion-hint">{contactsHint}</div>
1188
+ ) : null}
1189
+ </div>
1190
+ ) : null}
1191
+ </div>
1192
+ </div>
1193
+ ) : null}
1194
+
1195
+ {parsed.channel === "email" ? (
1196
+ <div className="draft-approval-field">
1197
+ <span className="draft-approval-label">Subject</span>
1198
+ <input
1199
+ value={subject}
1200
+ onChange={(event) => setSubject(event.target.value)}
1201
+ className="draft-approval-text-input"
1202
+ disabled={isBusy}
1203
+ />
1204
+ </div>
1205
+ ) : null}
1206
+
1207
+ <div className="draft-approval-field">
1208
+ <span className="draft-approval-label">Message</span>
1209
+ <textarea
1210
+ value={body}
1211
+ onChange={(event) => setBody(event.target.value)}
1212
+ className="draft-approval-textarea"
1213
+ disabled={isBusy}
1214
+ rows={parsed.channel === "email" ? 8 : 5}
1215
+ />
1216
+ </div>
1217
+
1218
+ <div className="draft-approval-footer">
1219
+ <button
1220
+ className="draft-approval-cancel"
1221
+ onClick={() => onResolveApproval(approval.id, "deny")}
1222
+ disabled={isBusy}
1223
+ >
1224
+ Cancel
1225
+ </button>
1226
+ </div>
1227
+ </div>
1228
+ );
1229
+ }
1230
+
734
1231
  function MessageCard({
735
1232
  message,
736
1233
  onAddThread,
@@ -755,7 +1252,11 @@ function MessageCard({
755
1252
  isStreaming?: boolean;
756
1253
  toolEvents?: ToolEvent[];
757
1254
  approvals?: ToolApproval[];
758
- onResolveApproval?: (approvalId: string, decision: "approve" | "deny") => void;
1255
+ onResolveApproval?: (
1256
+ approvalId: string,
1257
+ decision: "approve" | "deny",
1258
+ argsOverride?: Record<string, unknown>,
1259
+ ) => void;
759
1260
  approvalBusyIds?: Record<string, boolean>;
760
1261
  }) {
761
1262
  const [copied, setCopied] = useState(false);
@@ -966,6 +1467,12 @@ function MessageCard({
966
1467
  {approvalItems.map((approval) => {
967
1468
  const isPending = approval.status === "requested";
968
1469
  const isBusy = Boolean(approvalBusyIds?.[approval.id]);
1470
+ const draftTemplate = parseDraftTemplate(approval);
1471
+ const showDraftEditor =
1472
+ isPending &&
1473
+ Boolean(onResolveApproval) &&
1474
+ Boolean(draftTemplate) &&
1475
+ (approval.toolName === "mail_send" || approval.toolName === "messages_send");
969
1476
  const callSummary = summarizeToolCall(approval.toolName, parsePayload(approval.argsJson));
970
1477
  const approvalLabel =
971
1478
  approval.status === "requested"
@@ -985,33 +1492,46 @@ function MessageCard({
985
1492
  {approvalLabel}
986
1493
  </span>
987
1494
  </div>
988
- <div className="mt-1 text-sm text-[var(--text-primary)]">{callSummary.title}</div>
989
- {callSummary.detail ? (
990
- <div className="mt-1 text-xs text-[var(--text-muted)]">{callSummary.detail}</div>
991
- ) : null}
992
- {approval.reason ? (
993
- <div className="mt-1 text-xs text-[var(--text-muted)]">{approval.reason}</div>
994
- ) : null}
995
- <div className="mt-2 flex items-center gap-2">
996
- {isPending && onResolveApproval ? (
997
- <>
998
- <button
999
- className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
1000
- onClick={() => onResolveApproval(approval.id, "approve")}
1001
- disabled={isBusy}
1002
- >
1003
- Approve
1004
- </button>
1005
- <button
1006
- className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
1007
- onClick={() => onResolveApproval(approval.id, "deny")}
1008
- disabled={isBusy}
1009
- >
1010
- Deny
1011
- </button>
1012
- </>
1013
- ) : null}
1014
- </div>
1495
+ {showDraftEditor && onResolveApproval ? (
1496
+ <div className="mt-2">
1497
+ <DraftApprovalEditor
1498
+ key={`draft-${approval.id}-${approval.argsJson ?? ""}`}
1499
+ approval={approval}
1500
+ isBusy={isBusy}
1501
+ onResolveApproval={onResolveApproval}
1502
+ />
1503
+ </div>
1504
+ ) : (
1505
+ <>
1506
+ <div className="mt-1 text-sm text-[var(--text-primary)]">{callSummary.title}</div>
1507
+ {callSummary.detail ? (
1508
+ <div className="mt-1 text-xs text-[var(--text-muted)]">{callSummary.detail}</div>
1509
+ ) : null}
1510
+ {approval.reason ? (
1511
+ <div className="mt-1 text-xs text-[var(--text-muted)]">{approval.reason}</div>
1512
+ ) : null}
1513
+ <div className="mt-2 flex items-center gap-2">
1514
+ {isPending && onResolveApproval ? (
1515
+ <>
1516
+ <button
1517
+ className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
1518
+ onClick={() => onResolveApproval(approval.id, "approve")}
1519
+ disabled={isBusy}
1520
+ >
1521
+ Approve
1522
+ </button>
1523
+ <button
1524
+ className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
1525
+ onClick={() => onResolveApproval(approval.id, "deny")}
1526
+ disabled={isBusy}
1527
+ >
1528
+ Deny
1529
+ </button>
1530
+ </>
1531
+ ) : null}
1532
+ </div>
1533
+ </>
1534
+ )}
1015
1535
  </div>
1016
1536
  );
1017
1537
  })}
@@ -122,6 +122,11 @@ export default function SettingsModal({
122
122
  const [toolsEnabled, setToolsEnabled] = useState(localTools.enabled);
123
123
  const [approvalMode, setApprovalMode] = useState<ApprovalMode>(localTools.approvalMode);
124
124
  const [safetyProfile, setSafetyProfile] = useState<SafetyProfile>(localTools.safetyProfile);
125
+ const [preSendDraftReview, setPreSendDraftReview] = useState(
126
+ typeof localTools.preSendDraftReview === "boolean"
127
+ ? localTools.preSendDraftReview
128
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview,
129
+ );
125
130
  const [allowedRootsText, setAllowedRootsText] = useState((localTools.allowedRoots ?? []).join("\n"));
126
131
  const [enableNotes, setEnableNotes] = useState(localTools.enableNotes);
127
132
  const [enableApps, setEnableApps] = useState(localTools.enableApps);
@@ -534,6 +539,7 @@ export default function SettingsModal({
534
539
  enabled: toolsEnabled,
535
540
  approvalMode,
536
541
  safetyProfile,
542
+ preSendDraftReview,
537
543
  allowedRoots:
538
544
  allowedRoots.length > 0 ? allowedRoots : [...DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots],
539
545
  enableNotes,
@@ -983,6 +989,7 @@ export default function SettingsModal({
983
989
  { label: "Enable music controls", checked: enableMusic, setter: setEnableMusic },
984
990
  { label: "Enable calendar/reminders tools", checked: enableCalendar, setter: setEnableCalendar },
985
991
  { label: "Enable mail/messages tools", checked: enableMail, setter: setEnableMail },
992
+ { label: "Require pre-send draft review for email/messages", checked: preSendDraftReview, setter: setPreSendDraftReview },
986
993
  { label: "Enable workflow tool", checked: enableWorkflow, setter: setEnableWorkflow },
987
994
  { label: "Enable system controls", checked: enableSystem, setter: setEnableSystem },
988
995
  ].map((item) => (
@@ -51,6 +51,7 @@ function isLegacyDefaultLocalToolsSettings(localTools: LocalToolsSettings): bool
51
51
  localTools.enabled === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enabled &&
52
52
  localTools.approvalMode === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode &&
53
53
  localTools.safetyProfile === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile &&
54
+ localTools.preSendDraftReview === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview &&
54
55
  sameStringArray(localTools.allowedRoots, LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots) &&
55
56
  localTools.enableNotes === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableNotes &&
56
57
  localTools.enableApps === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableApps &&
@@ -82,6 +83,10 @@ function normalizeLocalToolsSettings(
82
83
  localTools.approvalMode ?? DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode,
83
84
  safetyProfile:
84
85
  localTools.safetyProfile ?? DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile,
86
+ preSendDraftReview:
87
+ typeof localTools.preSendDraftReview === "boolean"
88
+ ? localTools.preSendDraftReview
89
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview,
85
90
  allowedRoots:
86
91
  Array.isArray(localTools.allowedRoots) && localTools.allowedRoots.length > 0
87
92
  ? localTools.allowedRoots.filter((root) => typeof root === "string" && root.trim())