@remit/web-client 0.0.92 → 0.0.93

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/DailyBrief.selection.test.ts +6 -2
  3. package/src/components/mail/DailyBrief.tsx +47 -110
  4. package/src/components/mail/MessageList.selection.test.ts +10 -5
  5. package/src/components/mail/MessageList.tsx +78 -82
  6. package/src/components/mail/SelectionWizardHost.tsx +648 -60
  7. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  8. package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
  9. package/src/components/settings/FilterEditor.tsx +1 -1
  10. package/src/hooks/useCreateMailbox.ts +16 -4
  11. package/src/hooks/useFilters.ts +30 -1
  12. package/src/hooks/useMatchSample.ts +63 -0
  13. package/src/hooks/useRulePreview.ts +23 -3
  14. package/src/lib/mail-context.ts +0 -9
  15. package/src/lib/organize/organize-model.test.ts +110 -0
  16. package/src/lib/organize/organize-model.ts +69 -0
  17. package/src/lib/organize/rule-model.ts +2 -0
  18. package/src/lib/wizard-history.ts +15 -0
  19. package/src/routes/mail.tsx +0 -7
  20. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  21. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  22. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  23. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  24. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  25. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  26. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  27. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  28. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  29. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  30. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -1,59 +1,387 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
2
  import {
2
3
  type ClauseDraft,
3
4
  type ClauseEditState,
5
+ crossAccountDestinationReason,
6
+ crossAccountRuleReason,
7
+ derivePropertyClauses,
8
+ deriveSenderClauses,
9
+ dominantSender,
4
10
  type MatchCount,
5
11
  type MatchMode,
12
+ type MoveMailboxOption,
13
+ type RuleClause,
6
14
  type RunState,
7
15
  SelectionWizard,
8
16
  type StepId,
17
+ senderLabel,
9
18
  stepBlockedReason,
10
19
  stepIndex,
11
20
  stepsFor,
21
+ suggestRuleName,
12
22
  type Verb,
13
23
  type WizardDraft,
14
24
  type WizardMessage,
15
25
  } from "@remit/ui";
16
- import { useCallback, useState } from "react";
26
+ import { useQuery } from "@tanstack/react-query";
27
+ import { useBlocker } from "@tanstack/react-router";
28
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
29
+ import {
30
+ useFolderAppointments,
31
+ useJunkMailbox,
32
+ } from "@/hooks/useArchiveMailbox";
33
+ import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
34
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
35
+ import {
36
+ type EscalatedAction,
37
+ useEscalatedActions,
38
+ } from "@/hooks/useEscalatedActions";
39
+ import { useCreateFilter } from "@/hooks/useFilters";
40
+ import { useMatchSample } from "@/hooks/useMatchSample";
41
+ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
42
+ import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
43
+ import { useRulePreview } from "@/hooks/useRulePreview";
44
+ import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
45
+ import type { BulkRunOutcome } from "@/lib/bulk-actions";
46
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
47
+ import { buildMoveTargets } from "@/lib/move-targets";
48
+ import {
49
+ buildWizardDraft,
50
+ canBackApplyDraft,
51
+ type OrganizeDraft,
52
+ type OrganizeScope,
53
+ organizeScopeFor,
54
+ } from "@/lib/organize/organize-model";
55
+ import {
56
+ normalizeClauseValue,
57
+ SUPPORTED_CLAUSE_FIELDS,
58
+ } from "@/lib/organize/rule-model";
59
+ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
17
60
  import { useWizardStep } from "@/lib/wizard-history";
18
61
 
19
62
  const OPENING_STEP: StepId = "match";
20
- const UNCOUNTED: MatchCount = { status: "uncounted" };
21
- const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "all" };
63
+ const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
64
+
65
+ /** A ticked row, as the wizard's samples and its clause prefill read it. */
66
+ export interface WizardSelectionMessage extends WizardMessage {
67
+ /** Sender address — the widen's literal fallback and the prefill match on it. */
68
+ email: string;
69
+ }
70
+
71
+ interface SelectionWizardSessionProps extends SelectionWizardHostProps {
72
+ /** The step the URL holds. The session is mounted only while there is one. */
73
+ step: StepId;
74
+ goToStep: (step: StepId) => void;
75
+ goBack: () => void;
76
+ closeWizard: (steps: readonly StepId[], step: StepId) => void;
77
+ }
22
78
 
23
- /** How the commit ended, once something commits it (#483). */
24
- export interface WizardOutcome {
79
+ export interface SelectionWizardHostProps {
80
+ /** What the bar was pressed for. Every verb walks the same wizard. */
81
+ verb: Verb;
82
+ /** The account the ticked rows belong to, absent when they span several. */
83
+ accountId?: string;
84
+ /** Where the selection was made, so a run invalidates the listing it changed. */
85
+ mailboxId?: string;
86
+ selection: readonly WizardSelectionMessage[];
87
+ /** The ticked rows span more than one account, so no rule can be created. */
88
+ crossAccount?: boolean;
89
+ /** The wizard is done with the selection, and the list drops it. */
90
+ onFinished: () => void;
91
+ }
92
+
93
+ /** How far a commit has got, whichever of the three ways it took. */
94
+ interface RunSnapshot {
25
95
  state: RunState;
26
96
  matched: number;
27
97
  applied: number;
98
+ failed: number;
28
99
  failures: readonly WizardMessage[];
29
100
  }
30
101
 
31
- /** Nothing has been sent anywhere, so nothing can have been done. */
32
- const NOTHING_COMMITTED: WizardOutcome = {
33
- state: "commitFailed",
102
+ const NOT_STARTED: RunSnapshot = {
103
+ state: "saving",
34
104
  matched: 0,
35
105
  applied: 0,
106
+ failed: 0,
36
107
  failures: [],
37
108
  };
38
109
 
39
- export interface SelectionWizardHostProps {
40
- verb: Verb;
41
- selectedCount: number;
42
- outcome?: WizardOutcome;
43
- }
110
+ const withIds = (
111
+ clauses: readonly Omit<RuleClause, "id">[],
112
+ prefix: string,
113
+ ): RuleClause[] =>
114
+ clauses.map((clause, index) => ({ ...clause, id: `${prefix}-${index}` }));
44
115
 
45
- export function SelectionWizardHost({
116
+ /**
117
+ * The bulk call a verb makes over a concrete list of ids. Junk is a move: the
118
+ * message-flags API has no `$Junk` field, so the Junk verb files into the
119
+ * account's appointed Junk mailbox, and Move and Organize into the folder the
120
+ * folder step chose.
121
+ */
122
+ const bulkActionFor = (
123
+ verb: Verb,
124
+ moveMailboxId: string | undefined,
125
+ junkMailboxId: string | undefined,
126
+ ): EscalatedAction | undefined => {
127
+ if (verb === "delete") return { kind: "delete" };
128
+ if (verb === "markRead") return { kind: "markRead" };
129
+ const destinationMailboxId = verb === "junk" ? junkMailboxId : moveMailboxId;
130
+ return destinationMailboxId
131
+ ? { kind: "move", destinationMailboxId }
132
+ : undefined;
133
+ };
134
+
135
+ /**
136
+ * Whether a widened match is applied by the server's own pass rather than over
137
+ * the ids it matched. The back-apply job carries a move and nothing else, so
138
+ * Move, Junk and Organize hand it the whole match; Delete and Mark read have no
139
+ * server-side pass and act on the ids the preview matched.
140
+ */
141
+ const widenedRunsAsJob = (verb: Verb): boolean =>
142
+ verb === "move" || verb === "junk" || verb === "organize";
143
+
144
+ /**
145
+ * Where the wizard meets the app (#483). The steps and their bodies belong to
146
+ * `@remit/ui`; everything that talks to a server is here: the widen, the live
147
+ * count, the folder create, the bulk call, the back-apply job and the filter.
148
+ *
149
+ * Every commit routes through `organize-model.ts`, so the four `OrganizeScope`
150
+ * values are reconstructed in one place from the two answers the wizard asks
151
+ * for — what the action covers, and how long it holds.
152
+ *
153
+ * One walk of the wizard, and only that. Everything the walk answers — the door,
154
+ * the draft, whether a commit has been sent — lives here rather than in the host
155
+ * that mounts it, so closing the wizard takes all of it with the screen. Held a
156
+ * level up, on a component the list mounts for as long as it is on screen, the
157
+ * first commit's guard would still be up when the next selection reached Review,
158
+ * and the second one would press a control that does nothing at all.
159
+ */
160
+ function SelectionWizardSession({
46
161
  verb,
47
- selectedCount,
48
- outcome = NOTHING_COMMITTED,
49
- }: SelectionWizardHostProps) {
50
- const { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
162
+ accountId,
163
+ mailboxId,
164
+ selection,
165
+ crossAccount = false,
166
+ onFinished,
167
+ step,
168
+ goToStep,
169
+ goBack,
170
+ closeWizard,
171
+ }: SelectionWizardSessionProps) {
51
172
  const [mode, setMode] = useState<MatchMode>("selected");
52
173
  const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
53
174
  const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
54
175
  undefined,
55
176
  );
56
177
  const [nudgedStep, setNudgedStep] = useState<StepId | undefined>(undefined);
178
+ const [semanticFallbackTaken, setSemanticFallbackTaken] = useState(false);
179
+ const [committedScope, setCommittedScope] = useState<
180
+ OrganizeScope | undefined
181
+ >(undefined);
182
+ const [bulkRun, setBulkRun] = useState<
183
+ { matched: number; outcome?: BulkRunOutcome } | undefined
184
+ >(undefined);
185
+ // The predicate the create chains its pass to. Undefined when the rule cannot
186
+ // be back-applied — a `HasWords` clause the vector-free pass cannot evaluate —
187
+ // in which case the filter still saves and applies to incoming mail. Kept, not
188
+ // cleared, so a failed start can be retried.
189
+ const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
190
+ const commitSent = useRef(false);
191
+
192
+ const messageIds = useMemo(
193
+ () => selection.map((message) => message.id),
194
+ [selection],
195
+ );
196
+ const senders = useMemo(
197
+ () => selection.map((message) => message.email).filter(Boolean),
198
+ [selection],
199
+ );
200
+ const anchorMessageId = messageIds[0];
201
+ const subjects = useSelectedSubjects(messageIds);
202
+ const { junkMailboxId } = useJunkMailbox(accountId);
203
+
204
+ const widen = useOrganizeWiden(accountId, anchorMessageId, senders);
205
+ const { preview: probeWiden } = widen;
206
+ // The similar door has to know before it is pressed whether it can run, so
207
+ // the probe fires with the wizard rather than with the door (#477 3.6).
208
+ useEffect(() => {
209
+ probeWiden();
210
+ }, [probeWiden]);
211
+
212
+ const widened = mode !== "selected";
213
+ const literalPredicate: OrganizeMatchPredicate = useMemo(
214
+ () => ({
215
+ matchOperator: draft.matchOperator === "all" ? "And" : "Or",
216
+ literalClauses: draft.clauses.map((clause) => ({
217
+ field: clause.field,
218
+ value: clause.value,
219
+ })),
220
+ }),
221
+ [draft.clauses, draft.matchOperator],
222
+ );
223
+ const predicate =
224
+ mode === "similar" ? widen.matchPredicate : literalPredicate;
225
+ // The count and the ids both come from the server, never from paging the list
226
+ // in the browser (#477 5.3). The ticked list is its own count, so no request
227
+ // is made for it.
228
+ const { count: previewCount, matchedIds } = useRulePreview(
229
+ widened ? accountId : undefined,
230
+ predicate,
231
+ );
232
+ const matchSample = useMatchSample(widened ? matchedIds : []);
233
+
234
+ const count: MatchCount = widened
235
+ ? previewCount
236
+ : { status: "ready", count: selection.length };
237
+
238
+ const { data: mailboxesData } = useQuery({
239
+ ...mailboxOperationsListMailboxesOptions({
240
+ path: { accountId: accountId ?? "" },
241
+ }),
242
+ enabled: !!accountId,
243
+ staleTime: Number.POSITIVE_INFINITY,
244
+ });
245
+ const folderAppointments = useFolderAppointments(accountId);
246
+ const mailboxes = useMemo<MoveMailboxOption[]>(
247
+ () =>
248
+ buildMoveTargets(mailboxesData?.items ?? [], folderAppointments).map(
249
+ (mailbox) => ({
250
+ id: mailbox.mailboxId,
251
+ label: getMailboxDisplayName(mailbox.fullPath),
252
+ searchValue: mailbox.fullPath,
253
+ isCurrent: mailbox.mailboxId === mailboxId,
254
+ }),
255
+ ),
256
+ [mailboxesData?.items, folderAppointments, mailboxId],
257
+ );
258
+ const { createFolder } = useCreateMailbox(accountId);
259
+
260
+ const folderLabel = mailboxes.find(
261
+ (mailbox) => mailbox.id === draft.moveMailboxId,
262
+ )?.label;
263
+ const leadSender = dominantSender(
264
+ selection.map((message) => ({
265
+ normalizedEmail: message.email,
266
+ displayName: message.sender,
267
+ })),
268
+ );
269
+ const suggestedName = suggestRuleName({
270
+ match:
271
+ mode === "properties"
272
+ ? draft.clauses[0]?.value.trim() || undefined
273
+ : undefined,
274
+ sender: leadSender ? senderLabel(leadSender) : undefined,
275
+ folder: folderLabel,
276
+ });
277
+ // The name the rule commits under: what the user typed, or the suggestion the
278
+ // field is showing them. The two must be the same string, or Continue blocks
279
+ // on a name that is on screen.
280
+ const named = useMemo<WizardDraft>(
281
+ () => ({ ...draft, name: draft.name ?? suggestedName }),
282
+ [draft, suggestedName],
283
+ );
284
+
285
+ const clauseSuggestions = useClauseSuggestions(
286
+ clauseEdit?.draft.field,
287
+ clauseEdit?.draft.value ?? "",
288
+ senders,
289
+ );
290
+
291
+ const organizeJob = useOrganizeJob(accountId);
292
+ const createFilter = useCreateFilter(accountId);
293
+ const bulk = useEscalatedActions({
294
+ mailboxId: mailboxId ?? "",
295
+ accountId,
296
+ enabled: false,
297
+ predicateKey: "selection-wizard",
298
+ searchQuery: {},
299
+ });
300
+ const { runAction } = bulk;
301
+
302
+ const steps = stepsFor({ verb, mode, scope: draft.scope });
303
+ // The step the screens are on, which is the held one only while the answers
304
+ // still hold it. Reading the URL's step here and the resolved one on screen
305
+ // is how a footer comes to name a screen nobody is looking at.
306
+ const current = steps[stepIndex(steps, step)];
307
+
308
+ // A filter and a folder both belong to one account, so a selection spanning
309
+ // accounts reaches neither and is told so on the step that asks (#477 5.5).
310
+ // The one-off scope acts on the messages themselves and is unaffected.
311
+ const accountScoped = !crossAccount && !!accountId;
312
+ const ruleRestriction = accountScoped ? undefined : crossAccountRuleReason;
313
+ const folderRestriction = accountScoped
314
+ ? undefined
315
+ : crossAccountDestinationReason;
316
+ const restrictionFor = (step: StepId): string | undefined => {
317
+ if (step === "folder") return folderRestriction;
318
+ if (step !== "rule") return undefined;
319
+ return named.scope === "standing" || named.scope === "until"
320
+ ? ruleRestriction
321
+ : undefined;
322
+ };
323
+ const blockedReason =
324
+ restrictionFor(current) ?? stepBlockedReason(current, named, count);
325
+
326
+ const seedPropertyClauses = useCallback(
327
+ () => withIds(derivePropertyClauses(senders, subjects), "seed"),
328
+ [senders, subjects],
329
+ );
330
+
331
+ const changeMode = useCallback(
332
+ (next: MatchMode) => {
333
+ setMode(next);
334
+ setDraft((held) => ({
335
+ ...held,
336
+ widen:
337
+ next === "similar" && anchorMessageId
338
+ ? { anchorCount: Math.max(selection.length, 1) }
339
+ : undefined,
340
+ ...(next === "properties" && held.clauses.length === 0
341
+ ? { clauses: seedPropertyClauses() }
342
+ : {}),
343
+ }));
344
+ },
345
+ [anchorMessageId, selection.length, seedPropertyClauses],
346
+ );
347
+
348
+ // The dimmed similar door, pressed. The senders the widen would have fallen
349
+ // back to are filled in on the property step instead of standing in for the
350
+ // semantic match without saying so (#477 3.6).
351
+ const takeSemanticFallback = useCallback(() => {
352
+ setSemanticFallbackTaken(true);
353
+ setMode("properties");
354
+ setDraft((held) => ({
355
+ ...held,
356
+ widen: undefined,
357
+ clauses: withIds(deriveSenderClauses(senders), "sender"),
358
+ matchOperator: "any",
359
+ }));
360
+ goToStep("properties");
361
+ }, [senders, goToStep]);
362
+
363
+ // The probe lands after the door is on screen, so it can report the widen
364
+ // unavailable while the user is already holding it. The fallback is taken then
365
+ // rather than left as a door that counts nothing: the property step says, in
366
+ // so many words, that these are the senders it substituted (#477 3.6).
367
+ const semanticUnavailable = widen.semanticUnavailable || widen.isError;
368
+ // Only while the door is the screen: the probe re-fires when the anchor
369
+ // changes under a background refetch, and a late answer that moved the step
370
+ // from Review would push an entry the wizard does not own and leave the count
371
+ // it rewinds by wrong.
372
+ useEffect(() => {
373
+ if (current !== "match") return;
374
+ if (mode !== "similar" || !semanticUnavailable || semanticFallbackTaken) {
375
+ return;
376
+ }
377
+ takeSemanticFallback();
378
+ }, [
379
+ current,
380
+ mode,
381
+ semanticUnavailable,
382
+ semanticFallbackTaken,
383
+ takeSemanticFallback,
384
+ ]);
57
385
 
58
386
  const startAddClause = useCallback(() => {
59
387
  setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
@@ -86,41 +414,263 @@ export function SelectionWizardHost({
86
414
  const submitClause = useCallback(() => {
87
415
  setClauseEdit((edit) => {
88
416
  if (!edit) return undefined;
417
+ const value = normalizeClauseValue(edit.draft.field, edit.draft.value);
418
+ const next = { ...edit.draft, value };
89
419
  setDraft((held) => ({
90
420
  ...held,
91
421
  clauses: edit.clauseId
92
422
  ? held.clauses.map((clause) =>
93
- clause.id === edit.clauseId
94
- ? { ...clause, ...edit.draft }
95
- : clause,
423
+ clause.id === edit.clauseId ? { ...clause, ...next } : clause,
96
424
  )
97
- : [...held.clauses, { id: crypto.randomUUID(), ...edit.draft }],
425
+ : [...held.clauses, { id: crypto.randomUUID(), ...next }],
98
426
  }));
99
427
  return undefined;
100
428
  });
101
429
  }, []);
102
430
 
103
- if (!step) return null;
104
-
105
- const steps = stepsFor({ verb, mode, scope: draft.scope });
106
- // The step the screens are on, which is the held one only while the answers
107
- // still hold it. Reading the URL's step here and the resolved one on screen
108
- // is how a footer comes to name a screen nobody is looking at.
109
- const current = steps[stepIndex(steps, step)];
110
- const blockedReason = stepBlockedReason(current, draft, UNCOUNTED);
111
- const sample = {
112
- messages: [],
113
- count: UNCOUNTED,
114
- label: "Messages this covers",
115
- };
116
-
117
- const advance = () => {
431
+ const advance = useCallback(() => {
118
432
  if (blockedReason) {
119
433
  setNudgedStep(current);
120
434
  return;
121
435
  }
436
+ setNudgedStep(undefined);
122
437
  const next = steps[stepIndex(steps, current) + 1];
123
438
  if (next) goToStep(next);
439
+ }, [blockedReason, current, steps, goToStep]);
440
+
441
+ const runBulk = useCallback(
442
+ async (ids: readonly string[]) => {
443
+ const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
444
+ if (!action) {
445
+ setBulkRun({
446
+ matched: ids.length,
447
+ outcome: {
448
+ done: 0,
449
+ failedIds: [...ids],
450
+ cancelled: false,
451
+ error: new Error(
452
+ verb === "junk"
453
+ ? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
454
+ : "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
455
+ ),
456
+ },
457
+ });
458
+ return;
459
+ }
460
+ setBulkRun({ matched: ids.length });
461
+ const outcome = await runAction(action, [...ids]);
462
+ setBulkRun({ matched: ids.length, outcome });
463
+ },
464
+ [verb, named.moveMailboxId, junkMailboxId, runAction],
465
+ );
466
+
467
+ const { start: startJob } = organizeJob;
468
+ const { createFilterAsync } = createFilter;
469
+
470
+ const sendCommit = useCallback(() => {
471
+ const scope = organizeScopeFor({ mode, ruleScope: named.scope });
472
+ const organizeDraft = buildWizardDraft({
473
+ mode,
474
+ ruleScope: named.scope,
475
+ anchorMessageId,
476
+ clauses: named.clauses,
477
+ matchOperator: named.matchOperator,
478
+ moveMailboxId: named.moveMailboxId,
479
+ until: named.until,
480
+ });
481
+ setCommittedScope(scope);
482
+
483
+ if (scope === "standing" || scope === "temporary") {
484
+ // Creating a filter also moves the mail that already matches, not only
485
+ // the mail that arrives next. The pass is chained to the create's own
486
+ // request rather than to this screen: the screen offers a Close while
487
+ // the create is still in flight, and a rule that saved with no pass
488
+ // behind it — and nothing left to retry from — is a silent no-op.
489
+ const backApply = canBackApplyDraft(organizeDraft)
490
+ ? organizeDraft
491
+ : undefined;
492
+ setBackApplyDraft(backApply);
493
+ void createFilterAsync(
494
+ organizeDraft,
495
+ scope,
496
+ (named.name ?? "").trim(),
497
+ ).then((created) => {
498
+ if (created && backApply) startJob(backApply);
499
+ });
500
+ return;
501
+ }
502
+ if (scope === "all-like-these" && widenedRunsAsJob(verb)) {
503
+ startJob(organizeDraft);
504
+ return;
505
+ }
506
+ void runBulk(scope === "just-these" ? messageIds : matchedIds);
507
+ }, [
508
+ mode,
509
+ named,
510
+ anchorMessageId,
511
+ verb,
512
+ messageIds,
513
+ matchedIds,
514
+ createFilterAsync,
515
+ startJob,
516
+ runBulk,
517
+ ]);
518
+
519
+ // Two presses land in the same frame before either navigate settles, and both
520
+ // would send. The guard is a ref rather than the committed scope because that
521
+ // is state, and state has not been applied yet by the second press.
522
+ const commit = useCallback(() => {
523
+ if (blockedReason) {
524
+ setNudgedStep(current);
525
+ return;
526
+ }
527
+ if (commitSent.current) return;
528
+ commitSent.current = true;
529
+ goToStep("run");
530
+ sendCommit();
531
+ }, [blockedReason, current, goToStep, sendCommit]);
532
+
533
+ const jobSnapshot = useCallback((): RunSnapshot => {
534
+ const progress = organizeJob.progress;
535
+ const shared = {
536
+ matched: progress.matchedCount,
537
+ applied: progress.appliedCount,
538
+ failures: [],
539
+ };
540
+ if (organizeJob.isError) {
541
+ return { ...NOT_STARTED, state: "commitFailed" };
542
+ }
543
+ if (organizeJob.isDone) {
544
+ return {
545
+ ...shared,
546
+ state:
547
+ progress.failedCount > 0 ? "backApplyFailed" : "backApplyComplete",
548
+ failed: progress.failedCount,
549
+ };
550
+ }
551
+ if (organizeJob.isStarting || organizeJob.isRunning) {
552
+ return { ...shared, state: "backApplyRunning", failed: 0 };
553
+ }
554
+ return NOT_STARTED;
555
+ }, [organizeJob]);
556
+
557
+ const rowsById = useMemo(() => {
558
+ const rows = new Map<string, WizardMessage>();
559
+ for (const message of [...selection, ...matchSample.messages]) {
560
+ rows.set(message.id, message);
561
+ }
562
+ return rows;
563
+ }, [selection, matchSample.messages]);
564
+
565
+ const bulkSnapshot = useCallback((): RunSnapshot => {
566
+ if (!bulkRun) return NOT_STARTED;
567
+ const { matched, outcome } = bulkRun;
568
+ if (!outcome) {
569
+ return {
570
+ state: "backApplyRunning",
571
+ matched,
572
+ applied: bulk.progress?.done ?? 0,
573
+ failed: 0,
574
+ failures: [],
575
+ };
576
+ }
577
+ if (outcome.done === 0 && (outcome.error || outcome.failedIds.length > 0)) {
578
+ return { ...NOT_STARTED, state: "commitFailed" };
579
+ }
580
+ const failures = outcome.failedIds
581
+ .map((id) => rowsById.get(id))
582
+ .filter((message): message is WizardMessage => message !== undefined);
583
+ return {
584
+ state:
585
+ outcome.failedIds.length > 0 ? "backApplyFailed" : "backApplyComplete",
586
+ matched,
587
+ applied: outcome.done,
588
+ failed: outcome.failedIds.length,
589
+ failures,
590
+ };
591
+ }, [bulkRun, bulk.progress, rowsById]);
592
+
593
+ const runSnapshot = (): RunSnapshot => {
594
+ if (committedScope === "standing" || committedScope === "temporary") {
595
+ if (createFilter.isError)
596
+ return { ...NOT_STARTED, state: "commitFailed" };
597
+ if (!createFilter.isSuccess) return NOT_STARTED;
598
+ if (!backApplyDraft) return { ...NOT_STARTED, state: "filterSaved" };
599
+ if (organizeJob.isError) {
600
+ return { ...NOT_STARTED, state: "backApplyStartFailed" };
601
+ }
602
+ return jobSnapshot();
603
+ }
604
+ if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
605
+ return jobSnapshot();
606
+ }
607
+ return bulkSnapshot();
608
+ };
609
+
610
+ // Retry stays on the run screen: it re-sends the same commit rather than
611
+ // walking back to Review, which would push an entry the wizard does not own
612
+ // and leave Cancel rewinding to a step instead of out.
613
+ const retry = (): void => {
614
+ if (committedScope === "standing" || committedScope === "temporary") {
615
+ if (createFilter.isError) {
616
+ createFilter.reset();
617
+ sendCommit();
618
+ return;
619
+ }
620
+ if (backApplyDraft) startJob(backApplyDraft);
621
+ return;
622
+ }
623
+ if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
624
+ sendCommit();
625
+ return;
626
+ }
627
+ const outstanding = bulkRun?.outcome?.failedIds ?? [];
628
+ void runBulk(outstanding.length > 0 ? outstanding : messageIds);
629
+ };
630
+
631
+ // Cancel rewinds the entries the wizard owns and leaves the selection where
632
+ // it was — nothing has happened to it (#477 1.6).
633
+ const cancel = useCallback(() => {
634
+ closeWizard(steps, current);
635
+ }, [closeWizard, steps, current]);
636
+
637
+ // The run screen's way out. The action has happened, so the rows it was
638
+ // aimed at are no longer a selection waiting to be acted on.
639
+ const dismiss = useCallback(() => {
640
+ closeWizard(steps, current);
641
+ onFinished();
642
+ }, [closeWizard, steps, current, onFinished]);
643
+
644
+ // Hardware back on the run screen leaves the wizard, the movement the header
645
+ // arrow and the footer already make there (`backExits`). Run is an ordinary
646
+ // pushed entry, so without this the button pops back to Review, where the
647
+ // commit is offered a second time; the run screen holds the outcome, which is
648
+ // what makes rewinding the entries under it safe (#477 1.6).
649
+ //
650
+ // The rewind `dismiss` performs is `router.history.go(-N)`, which the history
651
+ // classifies as `"GO"` — so it does not re-enter this blocker and recurse.
652
+ // `selection-wizard-history.spec.ts` walks a real browser back off the run
653
+ // screen, which is what pins that classification.
654
+ useBlocker({
655
+ shouldBlockFn: ({ action }) => {
656
+ if (action !== "BACK") return false;
657
+ dismiss();
658
+ return true;
659
+ },
660
+ enableBeforeUnload: false,
661
+ disabled: current !== "run",
662
+ });
663
+
664
+ const run = runSnapshot();
665
+ const sample = {
666
+ messages: widened ? matchSample.messages : selection,
667
+ count,
668
+ label: widened ? "A sample of what matches" : "Your selection",
669
+ emptyReason:
670
+ mode === "similar" && semanticUnavailable
671
+ ? ("notIndexed" as const)
672
+ : ("noMatch" as const),
673
+ loading: widened && (count.status === "loading" || matchSample.isPending),
124
674
  };
125
675
 
126
676
  return (
@@ -131,19 +681,23 @@ export function SelectionWizardHost({
131
681
  blockedReason={blockedReason}
132
682
  nudged={nudgedStep === current}
133
683
  onBack={goBack}
134
- onExit={() => closeWizard(steps, current)}
684
+ onExit={cancel}
135
685
  onContinue={advance}
136
- onCommit={() => goToStep("run")}
686
+ onCommit={commit}
137
687
  match={{
138
- selectedCount,
688
+ selectedCount: selection.length,
139
689
  mode,
140
- onModeChange: setMode,
141
- onSemanticFallback: () => setMode("properties"),
690
+ onModeChange: changeMode,
691
+ semanticUnavailable,
692
+ semanticErrorDetail:
693
+ widen.error instanceof Error ? widen.error.message : undefined,
694
+ semanticFallbackTaken,
695
+ onSemanticFallback: takeSemanticFallback,
142
696
  sample,
143
697
  }}
144
698
  properties={{
145
- clauses: draft.clauses,
146
- matchOperator: draft.matchOperator,
699
+ clauses: named.clauses,
700
+ matchOperator: named.matchOperator,
147
701
  onMatchOperatorChange: (matchOperator) =>
148
702
  setDraft((held) => ({ ...held, matchOperator })),
149
703
  clauseEdit,
@@ -153,39 +707,73 @@ export function SelectionWizardHost({
153
707
  onChangeDraft: changeClauseDraft,
154
708
  onSubmitClause: submitClause,
155
709
  onCancelClause: () => setClauseEdit(undefined),
156
- sample,
710
+ clauseFields: SUPPORTED_CLAUSE_FIELDS,
711
+ clauseSuggestions,
712
+ semanticFallbackTaken,
713
+ sample: { ...sample, label: "What this matches" },
714
+ }}
715
+ folder={{
716
+ mailboxes,
717
+ mailboxId: named.moveMailboxId,
718
+ onSelect: (moveMailboxId) =>
719
+ setDraft((held) => ({ ...held, moveMailboxId })),
720
+ onCreateFolder: accountId ? createFolder : undefined,
721
+ restriction: folderRestriction,
157
722
  }}
158
723
  rule={{
159
- draft,
724
+ draft: named,
160
725
  onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
161
726
  onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
727
+ restriction: ruleRestriction,
162
728
  }}
163
729
  name={{
164
- name: draft.name ?? "",
730
+ name: named.name ?? "",
165
731
  onNameChange: (name) => setDraft((held) => ({ ...held, name })),
166
732
  nudged: nudgedStep === current,
167
733
  }}
168
734
  review={{
169
735
  verb,
170
736
  mode,
171
- selectedCount,
172
- clauses: draft.clauses,
173
- matchOperator: draft.matchOperator,
174
- scope: draft.scope,
175
- until: draft.until,
176
- ruleName: draft.name,
737
+ selectedCount: selection.length,
738
+ clauses: named.clauses,
739
+ matchOperator: named.matchOperator,
740
+ folder: folderLabel,
741
+ scope: named.scope,
742
+ until: named.until,
743
+ ruleName: steps.includes("name") ? named.name : undefined,
177
744
  sample,
178
745
  }}
179
746
  run={{
180
- state: outcome.state,
747
+ state: run.state,
181
748
  verb,
182
- scope: draft.scope,
183
- matched: outcome.matched,
184
- applied: outcome.applied,
185
- failures: outcome.failures,
186
- onRetry: goBack,
187
- onDismiss: () => closeWizard(steps, current),
749
+ scope: named.scope,
750
+ matched: run.matched,
751
+ applied: run.applied,
752
+ failedCount: run.failed,
753
+ failures: run.failures,
754
+ onRetry: retry,
755
+ onDismiss: dismiss,
188
756
  }}
189
757
  />
190
758
  );
191
759
  }
760
+
761
+ /**
762
+ * The wizard's mount point, beside the list that opens it. It owns the step in
763
+ * the URL and the history entries the wizard walks; the walk itself is a
764
+ * separate component, mounted only while a step is held, so every answer it
765
+ * collects is gone by the time the next one starts.
766
+ */
767
+ export function SelectionWizardHost(props: SelectionWizardHostProps) {
768
+ const { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
769
+ if (!step) return null;
770
+ return (
771
+ <SelectionWizardSession
772
+ {...props}
773
+ step={step}
774
+ goToStep={goToStep}
775
+ goBack={goBack}
776
+ closeWizard={closeWizard}
777
+ />
778
+ );
779
+ }