@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.91",
3
+ "version": "0.0.93",
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": {
@@ -47,7 +47,11 @@ describe("the brief's select-all", () => {
47
47
  );
48
48
  });
49
49
 
50
- it("keeps the free-text organize entry reachable, in the overflow", () => {
51
- assert.match(chromeBody(), /onSomethingElse=\{/);
50
+ it("sends every verb on the bar into the wizard (#477 1.4)", () => {
51
+ const body = chromeBody();
52
+ for (const verb of ["delete", "move", "junk", "markRead", "organize"]) {
53
+ assert.match(body, new RegExp(`startWizard\\("${verb}"\\)`));
54
+ }
55
+ assert.doesNotMatch(body, /onSomethingElse/);
52
56
  });
53
57
  });
@@ -46,6 +46,7 @@ import {
46
46
  SelectionTopBar,
47
47
  type ThreadRowData,
48
48
  type ThreadSection,
49
+ type Verb,
49
50
  } from "@remit/ui";
50
51
  import { useQueries, useQuery } from "@tanstack/react-query";
51
52
  import { useNavigate } from "@tanstack/react-router";
@@ -54,7 +55,6 @@ import {
54
55
  type ReactNode,
55
56
  type RefObject,
56
57
  useCallback,
57
- useEffect,
58
58
  useMemo,
59
59
  useState,
60
60
  } from "react";
@@ -65,7 +65,6 @@ import {
65
65
  } from "@/hooks/useInitialSyncProgress";
66
66
  import { useLabelList } from "@/hooks/useLabels";
67
67
  import { useIsDesktop } from "@/hooks/useMediaQuery";
68
- import { useMoveMessages } from "@/hooks/useMoveMessages";
69
68
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
70
69
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
71
70
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
@@ -83,13 +82,15 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
83
82
  import { useMailContext } from "@/lib/mail-context";
84
83
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
85
84
  import { parseSearchTokens } from "@/lib/search-tokens";
85
+ import { useOpenWizard } from "@/lib/wizard-history";
86
86
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
87
87
  import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
88
88
  import type { MessageListCommands } from "./MessageList";
89
89
  import { MessageRow } from "./MessageRow";
90
- import { MoveToTrigger } from "./MoveToTrigger";
91
- import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
92
- import { OrganizeDialog } from "./organize/OrganizeDialog";
90
+ import {
91
+ SelectionWizardHost,
92
+ type WizardSelectionMessage,
93
+ } from "./SelectionWizardHost";
93
94
  import {
94
95
  type OpenMessageOptions,
95
96
  ThreadListInteraction,
@@ -293,7 +294,6 @@ interface BriefSelectionChromeProps {
293
294
  >;
294
295
  /** The rows the list is showing, for resolving the selection's scope. */
295
296
  rows: readonly ThreadRowData[];
296
- isDesktop: boolean;
297
297
  onMarkMessagesRead?: (messageIds: string[]) => void;
298
298
  children: ReactNode;
299
299
  }
@@ -307,7 +307,6 @@ interface BriefSelectionChromeProps {
307
307
  function BriefSelectionChrome({
308
308
  header,
309
309
  rows,
310
- isDesktop,
311
310
  onMarkMessagesRead,
312
311
  children,
313
312
  }: BriefSelectionChromeProps) {
@@ -315,7 +314,6 @@ function BriefSelectionChrome({
315
314
  selectedIds,
316
315
  selectedCount,
317
316
  exitSelection,
318
- requestDeleteSelection,
319
317
  orderedIds,
320
318
  allSelected,
321
319
  toggleAllLoaded,
@@ -327,57 +325,40 @@ function BriefSelectionChrome({
327
325
  );
328
326
  const { junkMailboxId } = useJunkMailbox(scope.accountId);
329
327
  const { labels } = useLabelList(scope.accountId);
330
- const { moveMessages, isPending: isMoving } = useMoveMessages({
331
- mailboxId: scope.mailboxId ?? "",
332
- accountId: scope.accountId,
333
- });
334
-
335
- const [organizeOpen, setOrganizeOpen] = useState(false);
336
- const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
337
- "select-similar" | "something-else" | null
338
- >(null);
339
328
 
340
- // An emptied selection takes the flows it opened with it, so a later
341
- // re-selection can't reopen one on a stale entry.
342
- useEffect(() => {
343
- if (selectedCount > 0) return;
344
- setOrganizeOpen(false);
345
- setMobileOrganizeEntry(null);
346
- }, [selectedCount]);
329
+ const openWizard = useOpenWizard();
330
+ // The verb the bar was pressed for. The wizard is open for as long as the URL
331
+ // holds a step, so a back that pops the last one closes it.
332
+ const [wizardVerb, setWizardVerb] = useState<Verb>("organize");
333
+ const startWizard = useCallback(
334
+ (verb: Verb) => {
335
+ setWizardVerb(verb);
336
+ openWizard("match");
337
+ },
338
+ [openWizard],
339
+ );
347
340
 
348
341
  const selectedMessageIds = useMemo(
349
342
  () => Array.from(selectedIds),
350
343
  [selectedIds],
351
344
  );
352
- const selectedSenders = useMemo(() => {
353
- const emails: string[] = [];
354
- for (const row of rows) {
355
- if (selectedIds.has(row.id) && row.fromEmail) emails.push(row.fromEmail);
356
- }
357
- return emails;
358
- }, [rows, selectedIds]);
359
-
360
- const handleMarkAsRead = useCallback(() => {
361
- onMarkMessagesRead?.(selectedMessageIds);
362
- exitSelection();
363
- }, [onMarkMessagesRead, selectedMessageIds, exitSelection]);
364
-
365
- const handleMove = useCallback(
366
- (destinationMailboxId: string) => {
367
- if (selectedMessageIds.length === 0) return;
368
- moveMessages(selectedMessageIds, destinationMailboxId);
369
- exitSelection();
370
- },
371
- [moveMessages, selectedMessageIds, exitSelection],
345
+ // The ticked rows as the wizard reads them — the sample under every screen
346
+ // that names a match, and the senders its widen falls back to.
347
+ const wizardSelection = useMemo<WizardSelectionMessage[]>(
348
+ () =>
349
+ rows
350
+ .filter((row) => selectedIds.has(row.id))
351
+ .map((row) => ({
352
+ id: row.id,
353
+ sender: row.fromName,
354
+ email: row.fromEmail,
355
+ subject: row.subject,
356
+ date: row.timeLabel,
357
+ })),
358
+ [rows, selectedIds],
372
359
  );
373
360
 
374
- const handleJunk = useCallback(() => {
375
- if (junkMailboxId) handleMove(junkMailboxId);
376
- }, [junkMailboxId, handleMove]);
377
-
378
- const scoped = !!scope.accountId && !!scope.mailboxId;
379
- const canJunk =
380
- scoped && !!junkMailboxId && junkMailboxId !== scope.mailboxId;
361
+ const canJunk = !!junkMailboxId && junkMailboxId !== scope.mailboxId;
381
362
 
382
363
  // One select-all for both surfaces: the desktop toolbar and the touch sheet
383
364
  // offer the same control over the same rendered rows, so the verb a phone
@@ -410,31 +391,12 @@ function BriefSelectionChrome({
410
391
  idleSlot={chrome.makeFilterSlot}
411
392
  count={selectedCount}
412
393
  onCancel={exitSelection}
413
- onDelete={requestDeleteSelection}
414
- onOrganize={
415
- scoped
416
- ? () =>
417
- isDesktop
418
- ? setOrganizeOpen(true)
419
- : setMobileOrganizeEntry("select-similar")
420
- : undefined
421
- }
422
- onJunk={canJunk ? handleJunk : undefined}
423
- onMarkRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
424
- onSomethingElse={
425
- scoped && !isDesktop
426
- ? () => setMobileOrganizeEntry("something-else")
427
- : undefined
428
- }
429
- moveSlot={
430
- scoped && scope.accountId && scope.mailboxId ? (
431
- <MoveToTrigger
432
- accountId={scope.accountId}
433
- currentMailboxId={scope.mailboxId}
434
- onMove={isMoving ? () => {} : handleMove}
435
- label="Move selected messages"
436
- />
437
- ) : undefined
394
+ onDelete={() => startWizard("delete")}
395
+ onMove={() => startWizard("move")}
396
+ onOrganize={() => startWizard("organize")}
397
+ onJunk={canJunk ? () => startWizard("junk") : undefined}
398
+ onMarkRead={
399
+ onMarkMessagesRead ? () => startWizard("markRead") : undefined
438
400
  }
439
401
  overflowSlot={
440
402
  scope.accountId &&
@@ -448,48 +410,24 @@ function BriefSelectionChrome({
448
410
  />
449
411
  ) : undefined
450
412
  }
451
- isBusy={isMoving}
452
413
  selectAll={selectAll}
453
414
  notice={notice}
454
415
  />
455
416
  );
456
417
 
457
- const organizeFlow =
458
- mobileOrganizeEntry &&
459
- scope.accountId &&
460
- !isDesktop &&
461
- selectedCount > 0 ? (
462
- <MobileOrganizeFlow
463
- entry={mobileOrganizeEntry}
464
- accountId={scope.accountId}
465
- selectedMessageIds={selectedMessageIds}
466
- selectedSenders={selectedSenders}
467
- junkMailboxId={junkMailboxId}
468
- onClose={() => {
469
- setMobileOrganizeEntry(null);
470
- exitSelection();
471
- }}
472
- />
473
- ) : undefined;
474
-
475
418
  return (
476
419
  <>
477
- <MailListHeader
478
- {...header}
479
- selectionBar={selectionBar}
480
- paneOverlay={organizeFlow}
481
- >
420
+ <MailListHeader {...header} selectionBar={selectionBar}>
482
421
  {children}
483
422
  </MailListHeader>
484
- {organizeOpen && scope.accountId && (
485
- <OrganizeDialog
486
- open={organizeOpen}
487
- accountId={scope.accountId}
488
- selectedMessageIds={selectedMessageIds}
489
- selectedSenders={selectedSenders}
490
- onClose={() => setOrganizeOpen(false)}
491
- />
492
- )}
423
+ <SelectionWizardHost
424
+ verb={wizardVerb}
425
+ accountId={scope.accountId}
426
+ mailboxId={scope.mailboxId}
427
+ selection={wizardSelection}
428
+ crossAccount={scope.moveDisabledHint !== undefined}
429
+ onFinished={exitSelection}
430
+ />
493
431
  </>
494
432
  );
495
433
  }
@@ -850,7 +788,6 @@ export function DailyBrief({
850
788
  onSelectSearchResult,
851
789
  }}
852
790
  rows={filteredRows}
853
- isDesktop={isDesktop}
854
791
  onMarkMessagesRead={onMarkMessagesRead}
855
792
  >
856
793
  <div className="flex h-full flex-col">
@@ -45,10 +45,17 @@ describe("MessageList selection mode", () => {
45
45
  it("turns the back gesture into an exit, and only while selecting", () => {
46
46
  assert.match(
47
47
  source,
48
- /shouldExitSelectionOnNavigate\(action, hasSelection\)/,
48
+ /shouldExitSelectionOnNavigate\(action, hasSelection, wizardStep\)/,
49
49
  );
50
50
  assert.match(source, /disabled: !hasSelection/);
51
51
  });
52
+
53
+ it("leaves the back gesture to the wizard while the wizard is open", () => {
54
+ assert.match(
55
+ source,
56
+ /disabled: !hasSelection \|\| wizardStep !== undefined/,
57
+ );
58
+ });
52
59
  });
53
60
 
54
61
  /**
@@ -71,8 +78,9 @@ describe("MessageList escalated actions", () => {
71
78
 
72
79
  it("keeps mark-read and the move slot on an escalated selection", () => {
73
80
  // The bar always carries the mark-read verb (it drops it itself while
74
- // counting or busy); an escalated selection must never lose it.
75
- assert.match(source, /onMarkRead=\{handleMarkAsRead\}/);
81
+ // counting or busy); an escalated selection must never lose it — it falls
82
+ // back to the predicate run, which the wizard cannot take.
83
+ assert.match(source, /: handleMarkAsRead\s*\}/);
76
84
  // The move slot is offered for a bounded selection or an escalated one —
77
85
  // #114's rule that an escalated selection is never delete-only.
78
86
  assert.match(
@@ -135,9 +143,13 @@ describe("MessageList escalation reaches desktop (#212)", () => {
135
143
  assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
136
144
  });
137
145
 
138
- it("keeps the free-text organize entry reachable, in the overflow", () => {
139
- assert.match(source, /onSomethingElse=\{/);
140
- assert.match(source, /setMobileOrganizeEntry\("something-else"\)/);
146
+ it("sends every verb on the bar into the wizard (#477 1.4)", () => {
147
+ const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
148
+ for (const verb of ["delete", "move", "junk", "markRead", "organize"]) {
149
+ assert.match(bar, new RegExp(`startWizard\\("${verb}"\\)`));
150
+ }
151
+ // The one selection surface has no second organize entry beside them.
152
+ assert.doesNotMatch(source, /onSomethingElse/);
141
153
  });
142
154
 
143
155
  it("offers the label picker only when the account has labels", () => {
@@ -6,6 +6,7 @@ import {
6
6
  MessageListLoadingMore,
7
7
  MessageListPane,
8
8
  SelectionTopBar,
9
+ type Verb,
9
10
  } from "@remit/ui";
10
11
  import { useBlocker, useNavigate } from "@tanstack/react-router";
11
12
  import { useVirtualizer } from "@tanstack/react-virtual";
@@ -44,7 +45,11 @@ import {
44
45
  escalatedStatusLabel,
45
46
  escalationActionLabel,
46
47
  } from "@/lib/escalation-label";
47
- import { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
48
+ import {
49
+ formatDeleteToTrashTitle,
50
+ formatEmailDate,
51
+ formatNumber,
52
+ } from "@/lib/format";
48
53
  import { tabStopId } from "@/lib/list-focus";
49
54
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
50
55
  import {
@@ -52,10 +57,13 @@ import {
52
57
  shouldExitSelectionOnNavigate,
53
58
  } from "@/lib/selection-mode";
54
59
  import { cn } from "@/lib/utils";
60
+ import { useOpenWizard, useWizardStepValue } from "@/lib/wizard-history";
55
61
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
56
62
  import { MoveToTrigger } from "./MoveToTrigger";
57
- import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
58
- import { OrganizeDialog } from "./organize/OrganizeDialog";
63
+ import {
64
+ SelectionWizardHost,
65
+ type WizardSelectionMessage,
66
+ } from "./SelectionWizardHost";
59
67
  import { SwipeableMessageRow } from "./SwipeableMessageRow";
60
68
 
61
69
  /**
@@ -232,13 +240,12 @@ export const MessageList = ({
232
240
  const parentRef = useRef<HTMLDivElement>(null);
233
241
  const navigate = useNavigate();
234
242
  const isDesktop = useIsDesktop();
235
- const [organizeOpen, setOrganizeOpen] = useState(false);
236
- // Mobile only: which selection-sheet entry opened the guided organize flow
237
- // (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
238
- // instead a sheet is a touch pattern (issue #211).
239
- const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
240
- "select-similar" | "something-else" | null
241
- >(null);
243
+ const wizardStep = useWizardStepValue();
244
+ const openWizard = useOpenWizard();
245
+ // The verb the bar was pressed for. The wizard itself is open for as long as
246
+ // the URL holds a step, so a back that pops the last one closes it without
247
+ // anything here having to hear about it.
248
+ const [wizardVerb, setWizardVerb] = useState<Verb>("organize");
242
249
  const isSearching = !!searchQuery?.trim();
243
250
  const listHeaderChrome = useListHeaderChrome();
244
251
  const { labels } = useLabelList(accountId);
@@ -315,13 +322,6 @@ export const MessageList = ({
315
322
  // it back to the count, and across that render the two disagree.
316
323
  const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
317
324
 
318
- // When the selection empties — cancel, a completed action, a mailbox switch —
319
- // the guided organize entry must reset too, so a later re-selection doesn't
320
- // reopen the flow on a stale entry (issue #211).
321
- useEffect(() => {
322
- if (selectedCount === 0) setMobileOrganizeEntry(null);
323
- }, [selectedCount]);
324
-
325
325
  // Pending delete, awaiting confirmation. `null` means the dialog is closed.
326
326
  // `source: "ids"` snapshots the concrete ids at request time so a selection
327
327
  // change behind the dialog can't retarget the delete — every keyboard/desktop
@@ -509,17 +509,23 @@ export const MessageList = ({
509
509
  // false for a plain click (caller lets the Link navigate).
510
510
  const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
511
511
 
512
- // Sender addresses of the checked rows, for the organize widen's literal
513
- // fallback when this deployment ships no vector pipeline (the sheet matches
514
- // all mail from these senders). Read off the already-loaded thread rows, so
515
- // no extra round-trip; the widen hook dedupes.
516
- const selectedSenders = useMemo(() => {
517
- const emails: string[] = [];
512
+ // The checked rows as the wizard reads them: the sample it shows under every
513
+ // screen that names a match, and the senders its widen falls back to on a
514
+ // deployment with no vector pipeline. Read off the already-loaded thread rows,
515
+ // so no extra round-trip.
516
+ const wizardSelection = useMemo<WizardSelectionMessage[]>(() => {
517
+ const rows: WizardSelectionMessage[] = [];
518
518
  for (const thread of threads) {
519
519
  if (!selectedIds.has(thread.messageId)) continue;
520
- if (thread.fromEmail) emails.push(thread.fromEmail);
520
+ rows.push({
521
+ id: thread.messageId,
522
+ sender: thread.fromName ?? thread.fromEmail ?? "Unknown",
523
+ email: thread.fromEmail ?? "",
524
+ subject: thread.subject ?? "(No subject)",
525
+ date: formatEmailDate(thread.sentDate),
526
+ });
521
527
  }
522
- return emails;
528
+ return rows;
523
529
  }, [threads, selectedIds]);
524
530
  const handleRowSelect = useCallback(
525
531
  (messageId: string, modifiers: SelectionModifiers): boolean => {
@@ -875,6 +881,23 @@ export const MessageList = ({
875
881
  return undefined;
876
882
  }, [selectedCount, selectedIds, threads]);
877
883
 
884
+ // Every verb on the bar opens the wizard on the ticked rows (#477 1.4),
885
+ // including a selection spanning accounts — the wizard is where that
886
+ // restriction is stated, on the step that needs one account (#477 5.5). It is
887
+ // withdrawn only once the selection escalates to a predicate or a run takes
888
+ // over: an escalated selection is a predicate, which no bounded list of ids
889
+ // stands in for (#477 3.1), and keeps the bar's own chunked runner.
890
+ const canOpenWizard =
891
+ escalation.phase.kind === "idle" && !escalation.isRunning;
892
+
893
+ const startWizard = useCallback(
894
+ (verb: Verb) => {
895
+ setWizardVerb(verb);
896
+ openWizard("match");
897
+ },
898
+ [openWizard],
899
+ );
900
+
878
901
  // Swipe-to-delete single message
879
902
  const handleSwipeDelete = useCallback(
880
903
  (messageId: string) => {
@@ -1060,12 +1083,14 @@ export const MessageList = ({
1060
1083
  // — is never blocked, and the blocker is off entirely with nothing selected.
1061
1084
  useBlocker({
1062
1085
  shouldBlockFn: ({ action }) => {
1063
- if (!shouldExitSelectionOnNavigate(action, hasSelection)) return false;
1086
+ if (!shouldExitSelectionOnNavigate(action, hasSelection, wizardStep)) {
1087
+ return false;
1088
+ }
1064
1089
  exitSelection();
1065
1090
  return true;
1066
1091
  },
1067
1092
  enableBeforeUnload: false,
1068
- disabled: !hasSelection,
1093
+ disabled: !hasSelection || wizardStep !== undefined,
1069
1094
  });
1070
1095
 
1071
1096
  // Load more when scrolling near the bottom
@@ -1281,15 +1306,10 @@ export const MessageList = ({
1281
1306
  }
1282
1307
  : undefined;
1283
1308
 
1284
- // The bar shows one notice at a time, so the cross-account move restriction
1285
- // rides in behind the escalation states.
1286
- const selectionNotice =
1287
- escalationNotice ??
1288
- (moveDisabledHint
1289
- ? { tone: "warning" as const, text: moveDisabledHint }
1290
- : undefined);
1291
-
1309
+ // An escalated selection is a predicate the wizard cannot take, so Move there
1310
+ // keeps the caller's own folder picker and the chunked predicate run (#114).
1292
1311
  const selectionMoveSlot =
1312
+ !canOpenWizard &&
1293
1313
  (onMoveMessages || escalation.phase.kind === "escalated") &&
1294
1314
  accountId &&
1295
1315
  mailboxId ? (
@@ -1302,28 +1322,13 @@ export const MessageList = ({
1302
1322
  />
1303
1323
  ) : undefined;
1304
1324
 
1305
- // The guided organize flow (issue #211) covers the pane, which is its
1306
- // positioned ancestor.
1307
- const mobileOrganizeFlow =
1308
- mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
1309
- <MobileOrganizeFlow
1310
- entry={mobileOrganizeEntry}
1311
- accountId={accountId}
1312
- selectedMessageIds={Array.from(selectedIds)}
1313
- selectedSenders={selectedSenders}
1314
- junkMailboxId={junkMailboxId}
1315
- onClose={() => {
1316
- setMobileOrganizeEntry(null);
1317
- exitSelection();
1318
- }}
1319
- />
1320
- ) : undefined;
1321
-
1322
- // Organize acts on the materialized selection within one account, so it is
1323
- // withdrawn the moment the selection escalates to a predicate or a run
1324
- // takes over — both of which name themselves through `statusLabel`.
1325
- const canOrganize =
1326
- !!accountId && !!mailboxId && !moveDisabledHint && !selectionStatusLabel;
1325
+ // The bar shows one notice at a time, so the cross-account move restriction
1326
+ // rides in behind the escalation states.
1327
+ const selectionNotice =
1328
+ escalationNotice ??
1329
+ (moveDisabledHint
1330
+ ? { tone: "warning" as const, text: moveDisabledHint }
1331
+ : undefined);
1327
1332
 
1328
1333
  // One selection surface at every width, always mounted: it is the list
1329
1334
  // header. With nothing ticked it names the mailbox and carries the header's
@@ -1341,27 +1346,24 @@ export const MessageList = ({
1341
1346
  idleSlot={listHeaderChrome.makeFilterSlot}
1342
1347
  count={selectionCount}
1343
1348
  onCancel={handleSelectionCancel}
1344
- onDelete={handleSelectionDelete}
1345
- onOrganize={
1346
- canOrganize
1347
- ? () =>
1348
- isDesktop
1349
- ? setOrganizeOpen(true)
1350
- : setMobileOrganizeEntry("select-similar")
1351
- : undefined
1349
+ onDelete={
1350
+ canOpenWizard ? () => startWizard("delete") : handleSelectionDelete
1352
1351
  }
1352
+ onMove={canOpenWizard ? () => startWizard("move") : undefined}
1353
+ moveSlot={selectionMoveSlot}
1354
+ onOrganize={canOpenWizard ? () => startWizard("organize") : undefined}
1353
1355
  onJunk={
1354
- junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
1355
- ? handleJunk
1356
+ junkMailboxId && junkMailboxId !== mailboxId
1357
+ ? canOpenWizard
1358
+ ? () => startWizard("junk")
1359
+ : !moveDisabledHint
1360
+ ? handleJunk
1361
+ : undefined
1356
1362
  : undefined
1357
1363
  }
1358
- onMarkRead={handleMarkAsRead}
1359
- onSomethingElse={
1360
- canOrganize && !isDesktop
1361
- ? () => setMobileOrganizeEntry("something-else")
1362
- : undefined
1364
+ onMarkRead={
1365
+ canOpenWizard ? () => startWizard("markRead") : handleMarkAsRead
1363
1366
  }
1364
- moveSlot={selectionMoveSlot}
1365
1367
  overflowSlot={
1366
1368
  accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
1367
1369
  <LabelApplyTrigger
@@ -1516,7 +1518,6 @@ export const MessageList = ({
1516
1518
  isDesktop={isDesktop}
1517
1519
  hideHeader={hideHeader}
1518
1520
  selectionBar={activeSelectionBar}
1519
- paneOverlay={mobileOrganizeFlow}
1520
1521
  listBody={
1521
1522
  listHeaderChrome.searchResults ??
1522
1523
  (listState === "ready" ? virtualBody : undefined)
@@ -1539,15 +1540,14 @@ export const MessageList = ({
1539
1540
  onConfirm={handleConfirmDelete}
1540
1541
  onCancel={handleCancelDelete}
1541
1542
  />
1542
- {organizeOpen && accountId && (
1543
- <OrganizeDialog
1544
- open={organizeOpen}
1545
- accountId={accountId}
1546
- selectedMessageIds={Array.from(selectedIds)}
1547
- selectedSenders={selectedSenders}
1548
- onClose={() => setOrganizeOpen(false)}
1549
- />
1550
- )}
1543
+ <SelectionWizardHost
1544
+ verb={wizardVerb}
1545
+ accountId={accountId}
1546
+ mailboxId={mailboxId}
1547
+ selection={wizardSelection}
1548
+ crossAccount={moveDisabledHint !== undefined}
1549
+ onFinished={exitSelection}
1550
+ />
1551
1551
  </>
1552
1552
  );
1553
1553
  };