@remit/web-client 0.0.62 → 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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/MessageList.tsx +15 -0
  3. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +1 -0
  4. package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -6
  5. package/src/components/mail/organize/OrganizeDialog.render.test.ts +1 -0
  6. package/src/components/mail/organize/OrganizeDialog.tsx +15 -10
  7. package/src/components/mail/organize/OrganizePanel.progress.test.ts +113 -0
  8. package/src/components/mail/organize/OrganizePanel.render.test.ts +32 -2
  9. package/src/components/mail/organize/OrganizePanel.tsx +58 -20
  10. package/src/components/mail/organize/smart-organize.stories.tsx +94 -5
  11. package/src/components/self-update/SelfUpdateOverlay.tsx +45 -0
  12. package/src/components/settings/AdvancedNavIcon.tsx +23 -0
  13. package/src/components/settings/SelfUpdatePanel.tsx +47 -0
  14. package/src/hooks/use-system-update.test.ts +342 -0
  15. package/src/hooks/use-system-update.ts +215 -0
  16. package/src/hooks/useOrganizeWiden.test.ts +151 -0
  17. package/src/hooks/useOrganizeWiden.ts +104 -0
  18. package/src/lib/organize/organize-copy.test.ts +41 -0
  19. package/src/lib/organize/organize-copy.ts +29 -0
  20. package/src/lib/organize/sender-fallback.test.ts +56 -0
  21. package/src/lib/organize/sender-fallback.ts +64 -0
  22. package/src/lib/self-update-state.test.ts +517 -0
  23. package/src/lib/self-update-state.ts +564 -0
  24. package/src/routes/__root.tsx +13 -8
  25. package/src/routes/settings/advanced.tsx +7 -4
  26. package/src/routes/settings.tsx +3 -9
  27. package/src/hooks/useOrganizePreview.ts +0 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.62",
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: () => (