@remit/web-client 0.0.91 → 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 (31) 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 +18 -6
  5. package/src/components/mail/MessageList.tsx +83 -83
  6. package/src/components/mail/SelectionWizardHost.tsx +779 -0
  7. package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
  8. package/src/components/settings/FilterEditor.tsx +1 -1
  9. package/src/hooks/useCreateMailbox.ts +16 -4
  10. package/src/hooks/useFilters.ts +30 -1
  11. package/src/hooks/useMatchSample.ts +63 -0
  12. package/src/hooks/useRulePreview.ts +23 -3
  13. package/src/lib/organize/organize-model.test.ts +110 -0
  14. package/src/lib/organize/organize-model.ts +69 -0
  15. package/src/lib/organize/rule-model.ts +2 -0
  16. package/src/lib/selection-mode.test.ts +20 -6
  17. package/src/lib/selection-mode.ts +8 -1
  18. package/src/lib/wizard-history.test.ts +176 -0
  19. package/src/lib/wizard-history.ts +131 -0
  20. package/src/routes/mail.tsx +4 -0
  21. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  22. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  23. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  24. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  25. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  26. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  27. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  28. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  29. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  30. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  31. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -0,0 +1,779 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import {
3
+ type ClauseDraft,
4
+ type ClauseEditState,
5
+ crossAccountDestinationReason,
6
+ crossAccountRuleReason,
7
+ derivePropertyClauses,
8
+ deriveSenderClauses,
9
+ dominantSender,
10
+ type MatchCount,
11
+ type MatchMode,
12
+ type MoveMailboxOption,
13
+ type RuleClause,
14
+ type RunState,
15
+ SelectionWizard,
16
+ type StepId,
17
+ senderLabel,
18
+ stepBlockedReason,
19
+ stepIndex,
20
+ stepsFor,
21
+ suggestRuleName,
22
+ type Verb,
23
+ type WizardDraft,
24
+ type WizardMessage,
25
+ } from "@remit/ui";
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";
60
+ import { useWizardStep } from "@/lib/wizard-history";
61
+
62
+ const OPENING_STEP: StepId = "match";
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
+ }
78
+
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 {
95
+ state: RunState;
96
+ matched: number;
97
+ applied: number;
98
+ failed: number;
99
+ failures: readonly WizardMessage[];
100
+ }
101
+
102
+ const NOT_STARTED: RunSnapshot = {
103
+ state: "saving",
104
+ matched: 0,
105
+ applied: 0,
106
+ failed: 0,
107
+ failures: [],
108
+ };
109
+
110
+ const withIds = (
111
+ clauses: readonly Omit<RuleClause, "id">[],
112
+ prefix: string,
113
+ ): RuleClause[] =>
114
+ clauses.map((clause, index) => ({ ...clause, id: `${prefix}-${index}` }));
115
+
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({
161
+ verb,
162
+ accountId,
163
+ mailboxId,
164
+ selection,
165
+ crossAccount = false,
166
+ onFinished,
167
+ step,
168
+ goToStep,
169
+ goBack,
170
+ closeWizard,
171
+ }: SelectionWizardSessionProps) {
172
+ const [mode, setMode] = useState<MatchMode>("selected");
173
+ const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
174
+ const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
175
+ undefined,
176
+ );
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
+ ]);
385
+
386
+ const startAddClause = useCallback(() => {
387
+ setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
388
+ }, []);
389
+
390
+ const startEditClause = useCallback(
391
+ (clauseId: string) => {
392
+ const clause = draft.clauses.find((held) => held.id === clauseId);
393
+ if (!clause) return;
394
+ setClauseEdit({
395
+ mode: "edit",
396
+ clauseId,
397
+ draft: { field: clause.field, value: clause.value },
398
+ });
399
+ },
400
+ [draft.clauses],
401
+ );
402
+
403
+ const removeClause = useCallback((clauseId: string) => {
404
+ setDraft((held) => ({
405
+ ...held,
406
+ clauses: held.clauses.filter((clause) => clause.id !== clauseId),
407
+ }));
408
+ }, []);
409
+
410
+ const changeClauseDraft = useCallback((next: ClauseDraft) => {
411
+ setClauseEdit((edit) => edit && { ...edit, draft: next });
412
+ }, []);
413
+
414
+ const submitClause = useCallback(() => {
415
+ setClauseEdit((edit) => {
416
+ if (!edit) return undefined;
417
+ const value = normalizeClauseValue(edit.draft.field, edit.draft.value);
418
+ const next = { ...edit.draft, value };
419
+ setDraft((held) => ({
420
+ ...held,
421
+ clauses: edit.clauseId
422
+ ? held.clauses.map((clause) =>
423
+ clause.id === edit.clauseId ? { ...clause, ...next } : clause,
424
+ )
425
+ : [...held.clauses, { id: crypto.randomUUID(), ...next }],
426
+ }));
427
+ return undefined;
428
+ });
429
+ }, []);
430
+
431
+ const advance = useCallback(() => {
432
+ if (blockedReason) {
433
+ setNudgedStep(current);
434
+ return;
435
+ }
436
+ setNudgedStep(undefined);
437
+ const next = steps[stepIndex(steps, current) + 1];
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),
674
+ };
675
+
676
+ return (
677
+ <SelectionWizard
678
+ verb={verb}
679
+ steps={steps}
680
+ step={current}
681
+ blockedReason={blockedReason}
682
+ nudged={nudgedStep === current}
683
+ onBack={goBack}
684
+ onExit={cancel}
685
+ onContinue={advance}
686
+ onCommit={commit}
687
+ match={{
688
+ selectedCount: selection.length,
689
+ mode,
690
+ onModeChange: changeMode,
691
+ semanticUnavailable,
692
+ semanticErrorDetail:
693
+ widen.error instanceof Error ? widen.error.message : undefined,
694
+ semanticFallbackTaken,
695
+ onSemanticFallback: takeSemanticFallback,
696
+ sample,
697
+ }}
698
+ properties={{
699
+ clauses: named.clauses,
700
+ matchOperator: named.matchOperator,
701
+ onMatchOperatorChange: (matchOperator) =>
702
+ setDraft((held) => ({ ...held, matchOperator })),
703
+ clauseEdit,
704
+ onStartAddClause: startAddClause,
705
+ onStartEditClause: startEditClause,
706
+ onRemoveClause: removeClause,
707
+ onChangeDraft: changeClauseDraft,
708
+ onSubmitClause: submitClause,
709
+ onCancelClause: () => setClauseEdit(undefined),
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,
722
+ }}
723
+ rule={{
724
+ draft: named,
725
+ onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
726
+ onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
727
+ restriction: ruleRestriction,
728
+ }}
729
+ name={{
730
+ name: named.name ?? "",
731
+ onNameChange: (name) => setDraft((held) => ({ ...held, name })),
732
+ nudged: nudgedStep === current,
733
+ }}
734
+ review={{
735
+ verb,
736
+ mode,
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,
744
+ sample,
745
+ }}
746
+ run={{
747
+ state: run.state,
748
+ verb,
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,
756
+ }}
757
+ />
758
+ );
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
+ }