@remit/web-client 0.0.63 → 0.0.64

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": "@remit/web-client",
3
- "version": "0.0.63",
3
+ "version": "0.0.64",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -474,6 +474,19 @@ export const MessageList = ({
474
474
  // handled the click (caller should preventDefault and skip navigation);
475
475
  // false for a plain click (caller lets the Link navigate).
476
476
  const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
477
+
478
+ // Sender addresses of the checked rows, for the organize widen's literal
479
+ // fallback when this deployment ships no vector pipeline (the sheet matches
480
+ // all mail from these senders). Read off the already-loaded thread rows, so
481
+ // no extra round-trip; the widen hook dedupes.
482
+ const selectedSenders = useMemo(() => {
483
+ const emails: string[] = [];
484
+ for (const thread of threads) {
485
+ if (!selectedIds.has(thread.messageId)) continue;
486
+ if (thread.fromEmail) emails.push(thread.fromEmail);
487
+ }
488
+ return emails;
489
+ }, [threads, selectedIds]);
477
490
  const handleRowSelect = useCallback(
478
491
  (messageId: string, modifiers: SelectionModifiers): boolean => {
479
492
  if (modifiers.shiftKey) {
@@ -1256,6 +1269,7 @@ export const MessageList = ({
1256
1269
  accountId={accountId}
1257
1270
  mailboxId={mailboxId}
1258
1271
  selectedMessageIds={Array.from(selectedIds)}
1272
+ selectedSenders={selectedSenders}
1259
1273
  junkMailboxId={junkMailboxId}
1260
1274
  onClose={() => {
1261
1275
  setMobileOrganizeEntry(null);
@@ -1497,6 +1511,7 @@ export const MessageList = ({
1497
1511
  accountId={accountId}
1498
1512
  mailboxId={mailboxId}
1499
1513
  selectedMessageIds={Array.from(selectedIds)}
1514
+ selectedSenders={selectedSenders}
1500
1515
  onClose={() => setOrganizeOpen(false)}
1501
1516
  />
1502
1517
  )}
@@ -25,6 +25,7 @@ const render = (entry: OrganizeEntry) =>
25
25
  accountId: "acc-1",
26
26
  mailboxId: "mbx-inbox",
27
27
  selectedMessageIds: ["msg-1", "msg-2"],
28
+ selectedSenders: ["npm@github.com"],
28
29
  onClose: () => undefined,
29
30
  }),
30
31
  ),
@@ -3,7 +3,7 @@ import { BottomSheet, Button } from "@remit/ui";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { Loader2 } from "lucide-react";
5
5
  import { useEffect, useMemo, useState } from "react";
6
- import { useOrganizePreview } from "@/hooks/useOrganizePreview";
6
+ import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
7
7
  import { getMailboxDisplayName } from "@/lib/folder-roles";
8
8
  import { buildMoveTargets } from "@/lib/move-targets";
9
9
  import {
@@ -20,6 +20,11 @@ interface MobileOrganizeFlowProps {
20
20
  accountId: string;
21
21
  mailboxId: string;
22
22
  selectedMessageIds: string[];
23
+ /**
24
+ * Sender addresses of the selected messages, driving the literal fallback on
25
+ * a deployment without the vector pipeline (semantic-capability.ts).
26
+ */
27
+ selectedSenders: string[];
23
28
  junkMailboxId?: string;
24
29
  /** Close the flow and return to the list — dismiss, "Not now", and Done all use it. */
25
30
  onClose: () => void;
@@ -50,6 +55,7 @@ export function MobileOrganizeFlow({
50
55
  accountId,
51
56
  mailboxId,
52
57
  selectedMessageIds,
58
+ selectedSenders,
53
59
  junkMailboxId,
54
60
  onClose,
55
61
  }: MobileOrganizeFlowProps) {
@@ -60,15 +66,16 @@ export function MobileOrganizeFlow({
60
66
  preview,
61
67
  matchedCount,
62
68
  semanticUnavailable,
69
+ senders,
70
+ matchPredicate,
63
71
  isPending,
64
72
  isError,
65
73
  error,
66
- } = useOrganizePreview(accountId);
74
+ } = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
67
75
 
68
76
  useEffect(() => {
69
- if (!anchorMessageId) return;
70
- preview({ anchorMessageId, matchOperator: "And", literalClauses: [] });
71
- }, [anchorMessageId, preview]);
77
+ preview();
78
+ }, [preview]);
72
79
 
73
80
  const { data: mailboxesData } = useQuery({
74
81
  ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
@@ -122,12 +129,13 @@ export function MobileOrganizeFlow({
122
129
  accountId={accountId}
123
130
  mailboxId={mailboxId}
124
131
  selectedMessageIds={selectedMessageIds}
125
- anchorMessageId={anchorMessageId}
132
+ matchPredicate={matchPredicate}
126
133
  matchedCount={stage.matchedCount}
127
134
  initialScope={seed?.scope}
128
135
  seedMailboxId={seed?.moveMailboxId}
129
136
  fallback={stage.fallback}
130
137
  semanticUnavailable={semanticUnavailable}
138
+ senders={senders}
131
139
  onClose={onClose}
132
140
  />
133
141
  )}
@@ -20,6 +20,7 @@ const render = (open: boolean) =>
20
20
  accountId: "acc-1",
21
21
  mailboxId: "mbx-inbox",
22
22
  selectedMessageIds: ["msg-1", "msg-2"],
23
+ selectedSenders: ["npm@github.com"],
23
24
  onClose: () => undefined,
24
25
  }),
25
26
  ) as never,
@@ -1,7 +1,7 @@
1
1
  import { Button, Dialog } from "@remit/ui";
2
2
  import { Loader2 } from "lucide-react";
3
3
  import { useEffect } from "react";
4
- import { useOrganizePreview } from "@/hooks/useOrganizePreview";
4
+ import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
5
5
  import { OrganizePanel } from "./OrganizePanel";
6
6
 
7
7
  interface OrganizeDialogProps {
@@ -9,6 +9,11 @@ interface OrganizeDialogProps {
9
9
  accountId: string;
10
10
  mailboxId: string;
11
11
  selectedMessageIds: string[];
12
+ /**
13
+ * Sender addresses of the selected messages, driving the literal fallback on
14
+ * a deployment without the vector pipeline (semantic-capability.ts).
15
+ */
16
+ selectedSenders: string[];
12
17
  onClose: () => void;
13
18
  }
14
19
 
@@ -23,6 +28,7 @@ export function OrganizeDialog({
23
28
  accountId,
24
29
  mailboxId,
25
30
  selectedMessageIds,
31
+ selectedSenders,
26
32
  onClose,
27
33
  }: OrganizeDialogProps) {
28
34
  const anchorMessageId = selectedMessageIds[0];
@@ -31,19 +37,17 @@ export function OrganizeDialog({
31
37
  reset,
32
38
  matchedCount,
33
39
  semanticUnavailable,
40
+ senders,
41
+ matchPredicate,
34
42
  isPending,
35
43
  isError,
36
44
  error,
37
- } = useOrganizePreview(accountId);
45
+ } = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
38
46
 
39
47
  useEffect(() => {
40
- if (!open || !anchorMessageId) return;
41
- preview({
42
- anchorMessageId,
43
- matchOperator: "And",
44
- literalClauses: [],
45
- });
46
- }, [open, anchorMessageId, preview]);
48
+ if (!open) return;
49
+ preview();
50
+ }, [open, preview]);
47
51
 
48
52
  const handleClose = () => {
49
53
  reset();
@@ -80,9 +84,10 @@ export function OrganizeDialog({
80
84
  accountId={accountId}
81
85
  mailboxId={mailboxId}
82
86
  selectedMessageIds={selectedMessageIds}
83
- anchorMessageId={anchorMessageId}
87
+ matchPredicate={matchPredicate}
84
88
  matchedCount={matchedCount}
85
89
  semanticUnavailable={semanticUnavailable}
90
+ senders={senders}
86
91
  onClose={handleClose}
87
92
  />
88
93
  )}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The back-apply progress copy. On a deployment without the vector pipeline the
3
+ * "all like these" job moves the sender-matched set, so the in-progress line must
4
+ * state the sender semantics — never "similar mail" (#250's honesty
5
+ * requirement). The semantic path keeps the "similar mail" wording.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { afterEach, describe, it } from "node:test";
10
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
11
+ import { createElement } from "react";
12
+ import type { OrganizeMatchPredicate } from "../../../lib/organize/sender-fallback";
13
+ import { createDomHarness, type DomHarness } from "../../../test-support/dom";
14
+ import { makeMailbox } from "../../../test-support/fixtures";
15
+ import { type HttpMock, mockFetch } from "../../../test-support/http";
16
+ import { OrganizePanel } from "./OrganizePanel";
17
+
18
+ let harness: DomHarness | undefined;
19
+ let http: HttpMock | undefined;
20
+
21
+ afterEach(() => {
22
+ harness?.close();
23
+ harness = undefined;
24
+ http?.restore();
25
+ http = undefined;
26
+ });
27
+
28
+ const ACCOUNT_ID = "acc-1";
29
+
30
+ const MAILBOXES = [
31
+ makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
32
+ makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
33
+ ];
34
+
35
+ const SEMANTIC_PREDICATE: OrganizeMatchPredicate = {
36
+ anchorMessageId: "msg-1",
37
+ matchOperator: "And",
38
+ literalClauses: [],
39
+ };
40
+
41
+ const SENDER_PREDICATE: OrganizeMatchPredicate = {
42
+ matchOperator: "Or",
43
+ literalClauses: [{ field: "From", value: "npm@github.com" }],
44
+ };
45
+
46
+ const mount = (
47
+ props: Partial<Parameters<typeof OrganizePanel>[0]>,
48
+ ): DomHarness => {
49
+ http = mockFetch((call) => {
50
+ if (call.path.endsWith("/organize") && call.method === "POST") {
51
+ return { organizeJobId: "job-1", state: "Processing" };
52
+ }
53
+ if (call.path.endsWith("/organize/job-1") && call.method === "GET") {
54
+ return {
55
+ organizeJobId: "job-1",
56
+ state: "Processing",
57
+ matchedCount: 128,
58
+ appliedCount: 0,
59
+ failedCount: 0,
60
+ };
61
+ }
62
+ return {};
63
+ });
64
+ harness = createDomHarness();
65
+ harness.queryClient.setQueryData(
66
+ mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
67
+ { items: MAILBOXES },
68
+ );
69
+ harness.renderApp(
70
+ createElement(OrganizePanel, {
71
+ accountId: ACCOUNT_ID,
72
+ mailboxId: "mbx-inbox",
73
+ selectedMessageIds: ["msg-1", "msg-2"],
74
+ matchPredicate: SEMANTIC_PREDICATE,
75
+ matchedCount: 128,
76
+ seedMailboxId: "mbx-archive",
77
+ onClose: () => undefined,
78
+ ...props,
79
+ }),
80
+ );
81
+ return harness;
82
+ };
83
+
84
+ async function startJob(dom: DomHarness): Promise<void> {
85
+ dom.click(dom.byText("button", "Organize"));
86
+ for (let attempt = 0; attempt < 20; attempt += 1) {
87
+ await dom.flush();
88
+ if (/Organizing/.test(dom.text())) return;
89
+ await dom.wait(1);
90
+ }
91
+ }
92
+
93
+ describe("OrganizePanel back-apply progress copy", () => {
94
+ it("states the sender semantics while organizing in the sender fallback", async () => {
95
+ const dom = mount({
96
+ matchPredicate: SENDER_PREDICATE,
97
+ semanticUnavailable: true,
98
+ senders: ["npm@github.com"],
99
+ });
100
+ await startJob(dom);
101
+
102
+ assert.match(dom.text(), /Organizing mail from these senders/);
103
+ assert.doesNotMatch(dom.text(), /Organizing similar mail/);
104
+ });
105
+
106
+ it("keeps the similar-mail wording on the semantic path", async () => {
107
+ const dom = mount({});
108
+ await startJob(dom);
109
+
110
+ assert.match(dom.text(), /Organizing similar mail/);
111
+ assert.doesNotMatch(dom.text(), /from these senders/);
112
+ });
113
+ });
@@ -26,7 +26,11 @@ const render = (overrides: Partial<Parameters<typeof OrganizePanel>[0]> = {}) =>
26
26
  accountId: "acc-1",
27
27
  mailboxId: "mbx-inbox",
28
28
  selectedMessageIds: ["msg-1", "msg-2"],
29
- anchorMessageId: "msg-1",
29
+ matchPredicate: {
30
+ anchorMessageId: "msg-1",
31
+ matchOperator: "And",
32
+ literalClauses: [],
33
+ },
30
34
  matchedCount: 47,
31
35
  onClose: () => undefined,
32
36
  ...overrides,
@@ -62,12 +66,38 @@ describe("OrganizePanel", () => {
62
66
  assert.match(html, /organizing just your 2 selected/);
63
67
  });
64
68
 
65
- it("names the missing vector pipeline and organizes just the selection when the widen is unavailable (#226/#201)", () => {
69
+ it("names the missing vector pipeline and organizes just the selection when the widen is unavailable and no senders are known (#226/#201)", () => {
66
70
  const html = render({ matchedCount: 0, semanticUnavailable: true });
67
71
  assert.match(html, /available on this server/);
68
72
  assert.match(html, /organizing just your 2 selected/);
69
73
  });
70
74
 
75
+ it("names the senders and states it is matching all mail from them when the widen fell back (no vector pipeline)", () => {
76
+ const html = render({
77
+ matchedCount: 128,
78
+ semanticUnavailable: true,
79
+ senders: ["npm@github.com", "notifications@github.com"],
80
+ matchPredicate: {
81
+ matchOperator: "Or",
82
+ literalClauses: [
83
+ { field: "From", value: "npm@github.com" },
84
+ { field: "From", value: "notifications@github.com" },
85
+ ],
86
+ },
87
+ });
88
+ assert.match(html, /matching all mail from/);
89
+ assert.match(html, /npm@github\.com/);
90
+ assert.match(html, /notifications@github\.com/);
91
+ assert.match(html, /128 matches/);
92
+ // Never claims the matched set is semantically similar (only denies that
93
+ // similar-mail matching is available).
94
+ assert.doesNotMatch(html, /similar message/i);
95
+ assert.doesNotMatch(html, /similar ones found/i);
96
+ // The sender fallback is a real widen: the scopes reach it, so the
97
+ // default scope is "all like these", not "just these".
98
+ assert.match(html, /All like these/);
99
+ });
100
+
71
101
  it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
72
102
  const html = render({ initialScope: "standing" });
73
103
  // The standing scope's "Always keep" phrasing only renders when it is active.
@@ -13,17 +13,25 @@ import {
13
13
  commitButtonLabel,
14
14
  commitDisabledReason,
15
15
  scopeActionCount,
16
+ senderFallbackSummary,
16
17
  } from "@/lib/organize/organize-copy";
17
18
  import type {
18
19
  OrganizeDraft,
19
20
  OrganizeScope,
20
21
  } from "@/lib/organize/organize-model";
22
+ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
21
23
 
22
24
  interface OrganizePanelProps {
23
25
  accountId: string;
24
26
  mailboxId: string;
25
27
  selectedMessageIds: string[];
26
- anchorMessageId: string;
28
+ /**
29
+ * The predicate the widen previewed — the semantic anchor, or the
30
+ * sender-derived literal clauses when the vector pipeline is absent. Every
31
+ * commit scope carries exactly this, so the previewed set equals the set the
32
+ * scope acts on.
33
+ */
34
+ matchPredicate: OrganizeMatchPredicate;
27
35
  /** Similar messages the widen preview matched. */
28
36
  matchedCount: number;
29
37
  /**
@@ -44,10 +52,17 @@ interface OrganizePanelProps {
44
52
  fallback?: boolean;
45
53
  /**
46
54
  * The server ran no semantic widen because this deployment ships no vector
47
- * pipeline. Like {@link fallback} it organizes just the selection, but the
48
- * heading names the real reason rather than "no similar found".
55
+ * pipeline. With senders to match on ({@link senders}) the widen fell back to
56
+ * matching all mail from those senders; with none it organizes just the
57
+ * selection. Either way the heading names the real reason, never "similar".
49
58
  */
50
59
  semanticUnavailable?: boolean;
60
+ /**
61
+ * Distinct sender addresses driving the sender fallback. Non-empty only when
62
+ * {@link semanticUnavailable} and the fallback produced a literal match set;
63
+ * named in the heading and folder line so the copy states the real semantics.
64
+ */
65
+ senders?: string[];
51
66
  onClose: () => void;
52
67
  }
53
68
 
@@ -78,17 +93,22 @@ export function OrganizePanel({
78
93
  accountId,
79
94
  mailboxId,
80
95
  selectedMessageIds,
81
- anchorMessageId,
96
+ matchPredicate,
82
97
  matchedCount,
83
98
  initialScope,
84
99
  seedMailboxId,
85
100
  fallback = false,
86
101
  semanticUnavailable = false,
102
+ senders = [],
87
103
  onClose,
88
104
  }: OrganizePanelProps) {
105
+ // The vector pipeline is absent but the selection has senders to match on:
106
+ // the widen fell back to sender matching, which produces a real widened set
107
+ // the commit scopes reach — unlike a missing or empty widen.
108
+ const senderFallback = semanticUnavailable && senders.length > 0;
89
109
  // A missing widen and an empty widen both organize just the selection; only
90
- // the heading distinguishes them.
91
- const noWiden = fallback || semanticUnavailable;
110
+ // the heading distinguishes them. The sender fallback is a real widen.
111
+ const noWiden = (fallback || semanticUnavailable) && !senderFallback;
92
112
  const [scope, setScope] = useState<OrganizeScope>(
93
113
  initialScope ?? (noWiden ? "just-these" : "all-like-these"),
94
114
  );
@@ -113,14 +133,12 @@ export function OrganizePanel({
113
133
 
114
134
  const draft: OrganizeDraft = useMemo(
115
135
  () => ({
116
- anchorMessageId,
117
- matchOperator: "And",
118
- literalClauses: [],
136
+ ...matchPredicate,
119
137
  moveMailboxId: moveMailboxId || undefined,
120
138
  expiresAt:
121
139
  scope === "temporary" ? pickedDateToExpiresAt(pickedDate) : undefined,
122
140
  }),
123
- [anchorMessageId, moveMailboxId, scope, pickedDate],
141
+ [matchPredicate, moveMailboxId, scope, pickedDate],
124
142
  );
125
143
 
126
144
  const { moveMessages } = useMoveMessages({ mailboxId, accountId });
@@ -161,6 +179,7 @@ export function OrganizePanel({
161
179
  <JobProgress
162
180
  progress={organizeJob.progress}
163
181
  isDone={organizeJob.isDone}
182
+ senderFallback={senderFallback}
164
183
  onClose={onClose}
165
184
  />
166
185
  );
@@ -188,13 +207,17 @@ export function OrganizePanel({
188
207
  <div className="border-b border-line px-5 py-3">
189
208
  <h2 className="text-sm font-semibold text-fg">Organize</h2>
190
209
  <p className="mt-0.5 text-xs text-fg-muted">
191
- {semanticUnavailable
192
- ? `Finding similar mail isn't available on this server — organizing just your ${selectionCount} selected.`
193
- : fallback
194
- ? `No similar messages found — organizing just your ${selectionCount} selected.`
195
- : `${matchedCount} similar message${matchedCount === 1 ? "" : "s"} found${
196
- selectionCount > 0 ? ` from ${selectionCount} selected` : ""
197
- }.`}
210
+ {senderFallback
211
+ ? `${senderFallbackSummary(senders)} ${matchedCount} match${
212
+ matchedCount === 1 ? "" : "es"
213
+ }.`
214
+ : semanticUnavailable
215
+ ? `Finding similar mail isn't available on this server — organizing just your ${selectionCount} selected.`
216
+ : fallback
217
+ ? `No similar messages found — organizing just your ${selectionCount} selected.`
218
+ : `${matchedCount} similar message${matchedCount === 1 ? "" : "s"} found${
219
+ selectionCount > 0 ? ` from ${selectionCount} selected` : ""
220
+ }.`}
198
221
  </p>
199
222
  </div>
200
223
 
@@ -205,7 +228,12 @@ export function OrganizePanel({
205
228
  <span className="font-semibold text-fg">Always keep </span>
206
229
  )}
207
230
  {scope === "standing" ? "" : "Keep "}
208
- {scope === "just-these" ? "these" : "emails like these"} in
231
+ {scope === "just-these"
232
+ ? "these"
233
+ : senderFallback
234
+ ? "mail from these senders"
235
+ : "emails like these"}{" "}
236
+ in
209
237
  </p>
210
238
  <Select
211
239
  aria-label="Destination folder"
@@ -232,6 +260,10 @@ export function OrganizePanel({
232
260
  <section className="space-y-1.5">
233
261
  {SCOPE_OPTIONS.map((option) => {
234
262
  const active = scope === option.id;
263
+ const caption =
264
+ senderFallback && option.id === "all-like-these"
265
+ ? "including others from these senders"
266
+ : option.caption;
235
267
  return (
236
268
  <button
237
269
  key={option.id}
@@ -263,7 +295,7 @@ export function OrganizePanel({
263
295
  {option.label}
264
296
  </span>
265
297
  <span className="block text-xs text-fg-subtle">
266
- {option.caption}
298
+ {caption}
267
299
  </span>
268
300
  </span>
269
301
  </button>
@@ -330,10 +362,12 @@ export function OrganizePanel({
330
362
  function JobProgress({
331
363
  progress,
332
364
  isDone,
365
+ senderFallback,
333
366
  onClose,
334
367
  }: {
335
368
  progress: ReturnType<typeof useOrganizeJob>["progress"];
336
369
  isDone: boolean;
370
+ senderFallback: boolean;
337
371
  onClose: () => void;
338
372
  }) {
339
373
  const failed = progress.state === "Failed";
@@ -350,7 +384,11 @@ function JobProgress({
350
384
  )}
351
385
 
352
386
  {!isDone && (
353
- <p className="text-sm font-medium text-fg">Organizing similar mail…</p>
387
+ <p className="text-sm font-medium text-fg">
388
+ {senderFallback
389
+ ? "Organizing mail from these senders…"
390
+ : "Organizing similar mail…"}
391
+ </p>
354
392
  )}
355
393
 
356
394
  {isDone && !failed && (
@@ -117,7 +117,11 @@ export const Organize: Story = {
117
117
  accountId={ACCOUNT_ID}
118
118
  mailboxId="mbx-inbox"
119
119
  selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
120
- anchorMessageId="msg-1"
120
+ matchPredicate={{
121
+ anchorMessageId: "msg-1",
122
+ matchOperator: "And",
123
+ literalClauses: [],
124
+ }}
121
125
  matchedCount={47}
122
126
  onClose={() => undefined}
123
127
  />
@@ -137,7 +141,11 @@ export const FromSearch: Story = {
137
141
  accountId={ACCOUNT_ID}
138
142
  mailboxId="mbx-inbox"
139
143
  selectedMessageIds={["msg-1"]}
140
- anchorMessageId="msg-1"
144
+ matchPredicate={{
145
+ anchorMessageId: "msg-1",
146
+ matchOperator: "And",
147
+ literalClauses: [],
148
+ }}
141
149
  matchedCount={412}
142
150
  seedMailboxId="mbx-archive"
143
151
  onClose={() => undefined}
@@ -155,7 +163,11 @@ export const AlwaysRule: Story = {
155
163
  accountId={ACCOUNT_ID}
156
164
  mailboxId="mbx-inbox"
157
165
  selectedMessageIds={["msg-1", "msg-2"]}
158
- anchorMessageId="msg-1"
166
+ matchPredicate={{
167
+ anchorMessageId: "msg-1",
168
+ matchOperator: "And",
169
+ literalClauses: [],
170
+ }}
159
171
  matchedCount={47}
160
172
  initialScope="standing"
161
173
  seedMailboxId="mbx-travel"
@@ -174,7 +186,11 @@ export const NoSimilarFound: Story = {
174
186
  accountId={ACCOUNT_ID}
175
187
  mailboxId="mbx-inbox"
176
188
  selectedMessageIds={["msg-1", "msg-2"]}
177
- anchorMessageId="msg-1"
189
+ matchPredicate={{
190
+ anchorMessageId: "msg-1",
191
+ matchOperator: "And",
192
+ literalClauses: [],
193
+ }}
178
194
  matchedCount={0}
179
195
  fallback
180
196
  onClose={() => undefined}
@@ -196,7 +212,11 @@ export const SemanticUnavailable: Story = {
196
212
  accountId={ACCOUNT_ID}
197
213
  mailboxId="mbx-inbox"
198
214
  selectedMessageIds={["msg-1", "msg-2"]}
199
- anchorMessageId="msg-1"
215
+ matchPredicate={{
216
+ anchorMessageId: "msg-1",
217
+ matchOperator: "And",
218
+ literalClauses: [],
219
+ }}
200
220
  matchedCount={0}
201
221
  semanticUnavailable
202
222
  onClose={() => undefined}
@@ -205,6 +225,75 @@ export const SemanticUnavailable: Story = {
205
225
  ),
206
226
  };
207
227
 
228
+ /**
229
+ * No vector pipeline, but the selection has senders to match on — the widen
230
+ * fell back to matching all mail from those senders. The heading names the
231
+ * senders and states the real semantics, and every commit scope (including the
232
+ * standing filter) carries the sender `From` clauses, so it reaches the widened
233
+ * set and keeps working on future mail.
234
+ */
235
+ export const SenderFallback: Story = {
236
+ name: "Sender Fallback",
237
+ render: () => (
238
+ <SheetStage>
239
+ <OrganizePanel
240
+ accountId={ACCOUNT_ID}
241
+ mailboxId="mbx-inbox"
242
+ selectedMessageIds={["msg-1", "msg-2"]}
243
+ matchPredicate={{
244
+ matchOperator: "Or",
245
+ literalClauses: [
246
+ { field: "From", value: "npm@github.com" },
247
+ { field: "From", value: "notifications@github.com" },
248
+ ],
249
+ }}
250
+ matchedCount={128}
251
+ semanticUnavailable
252
+ senders={["npm@github.com", "notifications@github.com"]}
253
+ onClose={() => undefined}
254
+ />
255
+ </SheetStage>
256
+ ),
257
+ };
258
+
259
+ /**
260
+ * The sender fallback with a standing rule pre-selected — the "Always keep mail
261
+ * from these senders" sentence, the filter that will match future mail at index
262
+ * time.
263
+ */
264
+ export const SenderFallbackStanding: Story = {
265
+ name: "Sender Fallback (standing)",
266
+ render: () => (
267
+ <SheetStage>
268
+ <OrganizePanel
269
+ accountId={ACCOUNT_ID}
270
+ mailboxId="mbx-inbox"
271
+ selectedMessageIds={["msg-1", "msg-2", "msg-3", "msg-4"]}
272
+ matchPredicate={{
273
+ matchOperator: "Or",
274
+ literalClauses: [
275
+ { field: "From", value: "npm@github.com" },
276
+ { field: "From", value: "notifications@github.com" },
277
+ { field: "From", value: "noreply@medium.com" },
278
+ { field: "From", value: "digest@substack.com" },
279
+ ],
280
+ }}
281
+ matchedCount={412}
282
+ semanticUnavailable
283
+ senders={[
284
+ "npm@github.com",
285
+ "notifications@github.com",
286
+ "noreply@medium.com",
287
+ "digest@substack.com",
288
+ ]}
289
+ initialScope="standing"
290
+ seedMailboxId="mbx-archive"
291
+ onClose={() => undefined}
292
+ />
293
+ </SheetStage>
294
+ ),
295
+ };
296
+
208
297
  /** The "Something else" fallback: shortcuts derived from folders + an input. */
209
298
  export const SomethingElse: Story = {
210
299
  render: () => (
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The widen step's two-phase preview. On a semantic-capable deployment it runs
3
+ * the anchor preview once and stops. On a deployment that reports
4
+ * `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
5
+ * re-previews with sender-derived literal clauses — one `From` clause per
6
+ * distinct sender, combined with `Or`, no anchor — and reports the literal match
7
+ * count, so every commit scope acts on the sender-matched set.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { afterEach, describe, it } from "node:test";
12
+ import { createElement, useEffect } from "react";
13
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
14
+ import { type HttpMock, mockFetch } from "../test-support/http";
15
+ import { useOrganizeWiden } from "./useOrganizeWiden";
16
+
17
+ let harness: DomHarness | undefined;
18
+ let http: HttpMock | undefined;
19
+
20
+ afterEach(() => {
21
+ harness?.close();
22
+ harness = undefined;
23
+ http?.restore();
24
+ http = undefined;
25
+ });
26
+
27
+ type Widen = ReturnType<typeof useOrganizeWiden>;
28
+ type Responder = Parameters<typeof mockFetch>[0];
29
+
30
+ const PREVIEW_PATH = "/organize/preview";
31
+
32
+ const mount = (senders: string[], responder: Responder) => {
33
+ http = mockFetch(responder);
34
+ harness = createDomHarness();
35
+ const holder: { current: Widen | undefined } = { current: undefined };
36
+ function Probe() {
37
+ const widen = useOrganizeWiden("acc-1", "msg-1", senders);
38
+ holder.current = widen;
39
+ const { preview } = widen;
40
+ useEffect(() => {
41
+ preview();
42
+ }, [preview]);
43
+ return null;
44
+ }
45
+ harness.renderApp(createElement(Probe));
46
+ return holder;
47
+ };
48
+
49
+ async function settle(minCalls: number): Promise<void> {
50
+ const dom = harness;
51
+ const mock = http;
52
+ if (!dom || !mock) throw new Error("not mounted");
53
+ for (let attempt = 0; attempt < 40; attempt += 1) {
54
+ await dom.flush();
55
+ if (mock.to(PREVIEW_PATH).length >= minCalls) {
56
+ await dom.flush();
57
+ await dom.wait(1);
58
+ await dom.flush();
59
+ return;
60
+ }
61
+ await dom.wait(1);
62
+ }
63
+ }
64
+
65
+ describe("useOrganizeWiden — semantic-capable deployment", () => {
66
+ it("previews the anchor once and reports the semantic match, no fallback", async () => {
67
+ const holder = mount([], () => ({
68
+ matchedCount: 42,
69
+ messageIds: ["a", "b"],
70
+ semanticUnavailable: false,
71
+ }));
72
+ await settle(1);
73
+
74
+ assert.equal(http?.to(PREVIEW_PATH).length, 1);
75
+ const [call] = http?.to(PREVIEW_PATH) ?? [];
76
+ assert.equal(call.body?.anchorMessageId, "msg-1");
77
+ assert.deepEqual(call.body?.literalClauses, []);
78
+
79
+ const widen = holder.current;
80
+ assert.equal(widen?.matchedCount, 42);
81
+ assert.equal(widen?.semanticUnavailable, false);
82
+ assert.equal(widen?.senderFallback, false);
83
+ assert.deepEqual(widen?.matchPredicate, {
84
+ anchorMessageId: "msg-1",
85
+ matchOperator: "And",
86
+ literalClauses: [],
87
+ });
88
+ });
89
+ });
90
+
91
+ describe("useOrganizeWiden — no vector pipeline, senders present", () => {
92
+ it("re-previews with distinct sender From clauses combined with Or and no anchor", async () => {
93
+ const holder = mount(
94
+ ["npm@github.com", "npm@github.com", "a@x.com"],
95
+ (call) =>
96
+ call.body?.anchorMessageId
97
+ ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
98
+ : {
99
+ matchedCount: 128,
100
+ messageIds: ["m"],
101
+ semanticUnavailable: false,
102
+ },
103
+ );
104
+ await settle(2);
105
+
106
+ const calls = http?.to(PREVIEW_PATH) ?? [];
107
+ assert.equal(calls.length, 2);
108
+ // The second preview is the literal fallback: no anchor, Or, distinct
109
+ // sender From clauses.
110
+ const second = calls[1];
111
+ assert.equal(second.body?.anchorMessageId, undefined);
112
+ assert.equal(second.body?.matchOperator, "Or");
113
+ assert.deepEqual(second.body?.literalClauses, [
114
+ { field: "From", value: "npm@github.com" },
115
+ { field: "From", value: "a@x.com" },
116
+ ]);
117
+
118
+ const widen = holder.current;
119
+ assert.equal(widen?.matchedCount, 128);
120
+ assert.equal(widen?.semanticUnavailable, true);
121
+ assert.equal(widen?.senderFallback, true);
122
+ assert.deepEqual(widen?.senders, ["npm@github.com", "a@x.com"]);
123
+ assert.deepEqual(widen?.matchPredicate, {
124
+ matchOperator: "Or",
125
+ literalClauses: [
126
+ { field: "From", value: "npm@github.com" },
127
+ { field: "From", value: "a@x.com" },
128
+ ],
129
+ });
130
+ });
131
+ });
132
+
133
+ describe("useOrganizeWiden — no vector pipeline, no senders", () => {
134
+ it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
135
+ const holder = mount([], () => ({
136
+ matchedCount: 0,
137
+ messageIds: [],
138
+ semanticUnavailable: true,
139
+ }));
140
+ await settle(1);
141
+ // Give any spurious second preview a chance to fire before asserting none did.
142
+ await harness?.flush();
143
+
144
+ assert.equal(http?.to(PREVIEW_PATH).length, 1);
145
+ const widen = holder.current;
146
+ assert.equal(widen?.semanticUnavailable, true);
147
+ assert.equal(widen?.senderFallback, false);
148
+ assert.deepEqual(widen?.senders, []);
149
+ assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
150
+ });
151
+ });
@@ -0,0 +1,104 @@
1
+ import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { buildOrganizeInput } from "@/lib/organize/organize-model";
5
+ import {
6
+ buildSenderFallbackDraft,
7
+ deriveSenderClauses,
8
+ distinctSenders,
9
+ type OrganizeMatchPredicate,
10
+ } from "@/lib/organize/sender-fallback";
11
+
12
+ /**
13
+ * "Select similar messages" (the widen step) with a sender-derived fallback for
14
+ * deployments without the vector pipeline.
15
+ *
16
+ * The first preview runs the semantic anchor (POST /organize/preview). When the
17
+ * server reports `semanticUnavailable` — no vector pipeline on this deployment
18
+ * (semantic-capability.ts) — the widen re-previews with the literal vocabulary
19
+ * that matches vector-free: one `From` clause per distinct sender in the
20
+ * selection, combined with `Or`, no anchor. The counts shown, the one-time
21
+ * back-apply, and any standing filter then all carry that same literal
22
+ * predicate, so the previewed set equals the set every commit scope acts on.
23
+ *
24
+ * A semantic-capable deployment never fires the second preview and keeps exactly
25
+ * the anchor behaviour.
26
+ */
27
+ export const useOrganizeWiden = (
28
+ accountId: string | undefined,
29
+ anchorMessageId: string | undefined,
30
+ senders: readonly string[],
31
+ ) => {
32
+ const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
33
+ const { mutate, reset: mutationReset } = mutation;
34
+ const [fellBack, setFellBack] = useState(false);
35
+
36
+ const distinct = useMemo(() => distinctSenders(senders), [senders]);
37
+
38
+ const preview = useCallback(() => {
39
+ if (!accountId || !anchorMessageId) return;
40
+ setFellBack(false);
41
+ mutate({
42
+ path: { accountId },
43
+ body: buildOrganizeInput({
44
+ anchorMessageId,
45
+ matchOperator: "And",
46
+ literalClauses: [],
47
+ }),
48
+ });
49
+ }, [accountId, anchorMessageId, mutate]);
50
+
51
+ const reset = useCallback(() => {
52
+ setFellBack(false);
53
+ mutationReset();
54
+ }, [mutationReset]);
55
+
56
+ const data = mutation.data;
57
+ // The first preview came back capability-absent and there are senders to
58
+ // match on: re-preview with the literal clauses before showing any count, so
59
+ // the count the user sees is the sender-match count, never a flash of the
60
+ // empty semantic result.
61
+ const willFallBack =
62
+ data?.semanticUnavailable === true && !fellBack && distinct.length > 0;
63
+
64
+ useEffect(() => {
65
+ if (!willFallBack || !accountId) return;
66
+ setFellBack(true);
67
+ mutate({
68
+ path: { accountId },
69
+ body: buildOrganizeInput(buildSenderFallbackDraft(distinct)),
70
+ });
71
+ }, [willFallBack, accountId, distinct, mutate]);
72
+
73
+ // The literal re-preview has no anchor, so its response reports
74
+ // `semanticUnavailable: false`; `fellBack` is what remembers the capability
75
+ // was absent once the fallback has run.
76
+ const capabilityAbsent = fellBack || (data?.semanticUnavailable ?? false);
77
+ const isPending = mutation.isPending || willFallBack;
78
+
79
+ const matchPredicate: OrganizeMatchPredicate = fellBack
80
+ ? { matchOperator: "Or", literalClauses: deriveSenderClauses(distinct) }
81
+ : {
82
+ ...(anchorMessageId ? { anchorMessageId } : {}),
83
+ matchOperator: "And",
84
+ literalClauses: [],
85
+ };
86
+
87
+ return {
88
+ preview,
89
+ reset,
90
+ matchedCount: isPending ? undefined : data?.matchedCount,
91
+ messageIds: isPending ? undefined : data?.messageIds,
92
+ // The deployment ships no vector pipeline — the widen matched on senders
93
+ // (or, with no senders, on nothing) rather than semantic similarity.
94
+ semanticUnavailable: capabilityAbsent,
95
+ // The widen fell back to sender matching and produced a usable literal
96
+ // match set: the commit scopes reach it, and the copy names the senders.
97
+ senderFallback: fellBack,
98
+ senders: distinct,
99
+ matchPredicate,
100
+ isPending,
101
+ isError: mutation.isError,
102
+ error: mutation.error,
103
+ };
104
+ };
@@ -3,7 +3,9 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  commitButtonLabel,
5
5
  commitDisabledReason,
6
+ formatSenderList,
6
7
  scopeActionCount,
8
+ senderFallbackSummary,
7
9
  } from "./organize-copy";
8
10
  import type { OrganizeDraft } from "./organize-model";
9
11
 
@@ -91,3 +93,42 @@ describe("scopeActionCount", () => {
91
93
  assert.equal(scopeActionCount("standing", 3, 48), 48);
92
94
  });
93
95
  });
96
+
97
+ describe("formatSenderList", () => {
98
+ it("names a single sender bare", () => {
99
+ assert.equal(formatSenderList(["npm@github.com"]), "npm@github.com");
100
+ });
101
+
102
+ it("joins a couple with 'and'", () => {
103
+ assert.equal(
104
+ formatSenderList(["a@x.com", "b@y.com"]),
105
+ "a@x.com and b@y.com",
106
+ );
107
+ });
108
+
109
+ it("lists up to three in full", () => {
110
+ assert.equal(
111
+ formatSenderList(["a@x.com", "b@y.com", "c@z.com"]),
112
+ "a@x.com, b@y.com and c@z.com",
113
+ );
114
+ });
115
+
116
+ it("truncates past three, summing the rest", () => {
117
+ assert.equal(
118
+ formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com"]),
119
+ "a@x.com, b@y.com, c@z.com and 1 other",
120
+ );
121
+ assert.equal(
122
+ formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com", "e@v.com"]),
123
+ "a@x.com, b@y.com, c@z.com and 2 others",
124
+ );
125
+ });
126
+ });
127
+
128
+ describe("senderFallbackSummary", () => {
129
+ it("states it is matching all mail from the senders, never 'similar'", () => {
130
+ const summary = senderFallbackSummary(["npm@github.com"]);
131
+ assert.match(summary, /isn't available on this server/);
132
+ assert.match(summary, /matching all mail from npm@github\.com instead/);
133
+ });
134
+ });
@@ -59,3 +59,32 @@ export const scopeActionCount = (
59
59
  selectionCount: number,
60
60
  matchedCount: number,
61
61
  ): number => (scope === "just-these" ? selectionCount : matchedCount);
62
+
63
+ /** How many sender addresses are named before the rest are summed as "N others". */
64
+ const MAX_SENDERS_SHOWN = 3;
65
+
66
+ /**
67
+ * The sender addresses read out in prose: all of them when there are a few, or
68
+ * the first {@link MAX_SENDERS_SHOWN} and a count of the rest so a long selection
69
+ * stays legible in the sheet.
70
+ */
71
+ export const formatSenderList = (senders: readonly string[]): string => {
72
+ if (senders.length === 0) return "these senders";
73
+ if (senders.length === 1) return senders[0];
74
+ if (senders.length <= MAX_SENDERS_SHOWN) {
75
+ return `${senders.slice(0, -1).join(", ")} and ${senders[senders.length - 1]}`;
76
+ }
77
+ const shown = senders.slice(0, MAX_SENDERS_SHOWN);
78
+ const rest = senders.length - MAX_SENDERS_SHOWN;
79
+ return `${shown.join(", ")} and ${rest} other${rest === 1 ? "" : "s"}`;
80
+ };
81
+
82
+ /**
83
+ * The heading when the widen fell back to sender matching (no vector pipeline on
84
+ * this server). States the actual semantics — matching every mail from these
85
+ * senders — and never claims semantic similarity.
86
+ */
87
+ export const senderFallbackSummary = (senders: readonly string[]): string =>
88
+ `Similar-mail matching isn't available on this server — matching all mail from ${formatSenderList(
89
+ senders,
90
+ )} instead.`;
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildSenderFallbackDraft,
5
+ deriveSenderClauses,
6
+ distinctSenders,
7
+ } from "./sender-fallback";
8
+
9
+ describe("distinctSenders", () => {
10
+ it("drops empties and blanks, trimming what remains", () => {
11
+ assert.deepEqual(
12
+ distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
13
+ ["npm@github.com", "a@x.com"],
14
+ );
15
+ });
16
+
17
+ it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
18
+ assert.deepEqual(
19
+ distinctSenders([
20
+ "NPM@github.com",
21
+ "a@x.com",
22
+ "npm@GITHUB.com",
23
+ "a@x.com",
24
+ ]),
25
+ ["NPM@github.com", "a@x.com"],
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("deriveSenderClauses", () => {
31
+ it("emits one From clause per distinct sender", () => {
32
+ assert.deepEqual(
33
+ deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
34
+ [
35
+ { field: "From", value: "npm@github.com" },
36
+ { field: "From", value: "a@x.com" },
37
+ ],
38
+ );
39
+ });
40
+
41
+ it("is empty when no sender survives", () => {
42
+ assert.deepEqual(deriveSenderClauses(["", " "]), []);
43
+ });
44
+ });
45
+
46
+ describe("buildSenderFallbackDraft", () => {
47
+ it("combines the sender clauses with Or and carries no anchor", () => {
48
+ const draft = buildSenderFallbackDraft(["npm@github.com", "a@x.com"]);
49
+ assert.equal(draft.matchOperator, "Or");
50
+ assert.equal(draft.anchorMessageId, undefined);
51
+ assert.deepEqual(draft.literalClauses, [
52
+ { field: "From", value: "npm@github.com" },
53
+ { field: "From", value: "a@x.com" },
54
+ ]);
55
+ });
56
+ });
@@ -0,0 +1,64 @@
1
+ import type {
2
+ RemitImapFilterClause,
3
+ RemitImapOrganizeInput,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import type { OrganizeDraft } from "./organize-model";
6
+
7
+ /**
8
+ * The widen fallback for a deployment that ships no vector pipeline (self-host
9
+ * sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
10
+ * so a widen degrades to the literal vocabulary RFC 031 already matches
11
+ * vector-free: one `From` clause per distinct sender in the selection, combined
12
+ * with `Or`, no anchor. The same predicate matches at index time (RFC 034), so a
13
+ * standing filter built from it keeps working on future mail.
14
+ */
15
+
16
+ /**
17
+ * Distinct sender addresses from the selection, trimmed, empties dropped, and
18
+ * de-duplicated case-insensitively while preserving first-seen casing and order.
19
+ */
20
+ export const distinctSenders = (senders: readonly string[]): string[] => {
21
+ const seen = new Set<string>();
22
+ const out: string[] = [];
23
+ for (const raw of senders) {
24
+ const value = raw.trim();
25
+ if (value === "") continue;
26
+ const key = value.toLowerCase();
27
+ if (seen.has(key)) continue;
28
+ seen.add(key);
29
+ out.push(value);
30
+ }
31
+ return out;
32
+ };
33
+
34
+ /**
35
+ * One `From` literal clause per distinct sender address. A `From` clause matches
36
+ * the sender address or display name (match.ts `clauseMatches`), so the address
37
+ * is the precise, stable key.
38
+ */
39
+ export const deriveSenderClauses = (
40
+ senders: readonly string[],
41
+ ): RemitImapFilterClause[] =>
42
+ distinctSenders(senders).map((value) => ({ field: "From", value }));
43
+
44
+ /**
45
+ * The literal predicate that stands in for the semantic anchor: the sender `From`
46
+ * clauses combined with `Or` and no anchor. The preview, the one-time back-apply,
47
+ * and the standing filter all carry exactly this.
48
+ */
49
+ export const buildSenderFallbackDraft = (
50
+ senders: readonly string[],
51
+ ): OrganizeDraft => ({
52
+ matchOperator: "Or",
53
+ literalClauses: deriveSenderClauses(senders),
54
+ });
55
+
56
+ /**
57
+ * The predicate the widen previewed, handed to the organize sentence so the set
58
+ * it previews equals the set every commit scope acts on. Either the semantic
59
+ * anchor or the sender-derived literal fallback, never both.
60
+ */
61
+ export type OrganizeMatchPredicate = Pick<
62
+ RemitImapOrganizeInput,
63
+ "anchorMessageId" | "matchOperator" | "literalClauses"
64
+ >;
@@ -1,43 +0,0 @@
1
- import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { useMutation } from "@tanstack/react-query";
3
- import { useCallback } from "react";
4
- import {
5
- buildOrganizeInput,
6
- type OrganizeDraft,
7
- } from "@/lib/organize/organize-model";
8
-
9
- /**
10
- * "Select similar messages" (the widen step). Runs the read-only matcher once
11
- * server-side (POST /organize/preview) and returns the messages the same
12
- * predicate would apply an action to — the previewed set equals the set a job
13
- * with the same input would apply to. This is the only corpus-wide query the
14
- * flow makes; there is no client-side pagination over messages.
15
- */
16
- export const useOrganizePreview = (accountId: string | undefined) => {
17
- const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
18
- const { mutate } = mutation;
19
-
20
- const preview = useCallback(
21
- (draft: OrganizeDraft) => {
22
- if (!accountId) return;
23
- mutate({
24
- path: { accountId },
25
- body: buildOrganizeInput(draft),
26
- });
27
- },
28
- [accountId, mutate],
29
- );
30
-
31
- return {
32
- preview,
33
- reset: mutation.reset,
34
- matchedCount: mutation.data?.matchedCount,
35
- messageIds: mutation.data?.messageIds,
36
- // True when the server ran no semantic widen because this deployment
37
- // ships no vector pipeline — the matched set is literal-only (or empty).
38
- semanticUnavailable: mutation.data?.semanticUnavailable ?? false,
39
- isPending: mutation.isPending,
40
- isError: mutation.isError,
41
- error: mutation.error,
42
- };
43
- };