@remit/web-client 0.0.94 → 0.0.95

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.
@@ -33,15 +33,11 @@ import {
33
33
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
34
34
  import {
35
35
  bulkActionCompletionText,
36
- bulkActionPartialText,
37
36
  bulkActionProgressLabel,
38
37
  bulkActionProgressTone,
39
38
  } from "@/lib/bulk-action-copy";
40
39
  import {
41
- BULK_ACTION_CHUNK_SIZE,
42
- resolveSelectionAfterRun,
43
- } from "@/lib/bulk-actions";
44
- import {
40
+ describeSearchScope,
45
41
  escalatedStatusLabel,
46
42
  escalationActionLabel,
47
43
  } from "@/lib/escalation-label";
@@ -57,10 +53,10 @@ import {
57
53
  shouldExitSelectionOnNavigate,
58
54
  } from "@/lib/selection-mode";
59
55
  import { cn } from "@/lib/utils";
60
- import { useOpenWizard, useWizardStepValue } from "@/lib/wizard-history";
56
+ import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
61
57
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
62
- import { MoveToTrigger } from "./MoveToTrigger";
63
58
  import {
59
+ type EscalatedSelection,
64
60
  SelectionWizardHost,
65
61
  type WizardSelectionMessage,
66
62
  } from "./SelectionWizardHost";
@@ -85,11 +81,15 @@ export interface MessageListCommands {
85
81
  /** Returns true when there was a selection to clear — Esc consumes it. */
86
82
  clearSelection: () => boolean;
87
83
  /**
88
- * Opens the move-to-Trash confirmation for the selection, or the focused row
89
- * when nothing is selected. Returns false when there is nothing to delete, so
90
- * the route can fall back to its own reading-pane delete.
84
+ * Runs a verb over the list's selection by opening the wizard on it, which is
85
+ * where every selection action is reviewed before it reaches the mail server
86
+ * (#477 1.4, #508). Returns false when the list did not take the press, so
87
+ * the pane's own verb can act on the focused row instead — a verb aimed at
88
+ * one row is not a bulk action and does not walk the wizard. Delete is the
89
+ * exception with nothing ticked: the list still claims it, to confirm the
90
+ * move to Trash and then place the cursor on a surviving row.
91
91
  */
92
- requestDelete: () => boolean;
92
+ requestVerb: (verb: Verb) => boolean;
93
93
  toggleDensity: () => void;
94
94
  }
95
95
 
@@ -110,7 +110,6 @@ interface MessageListProps {
110
110
  */
111
111
  searchPredicate?: EscalationSearchQuery;
112
112
  onDeleteMessages?: (messageIds: string[]) => void;
113
- onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
114
113
  isDeleting?: boolean;
115
114
  isMoving?: boolean;
116
115
  onLoadMore?: () => void;
@@ -158,9 +157,11 @@ interface MessageListProps {
158
157
  */
159
158
  hasList: boolean;
160
159
  /**
161
- * Whether the list has a modal open that owns the keyboard. The route
162
- * suspends the whole triage layer while it does, so no shortcut can act
163
- * behind the dialog a second Delete press must not reach a delete.
160
+ * Whether the list has a modal open that owns the keyboard the delete
161
+ * confirmation, or the wizard. The route suspends the whole triage layer
162
+ * while it does, so no shortcut can act behind it: a second Delete press
163
+ * must not reach a delete, and none may start a second flow behind the
164
+ * screen already asking about one.
164
165
  */
165
166
  blocksKeyboard: boolean;
166
167
  }) => void;
@@ -181,11 +182,6 @@ const COMPACT_ITEM_HEIGHT = 32;
181
182
  const OVERSCAN_COUNT = 5;
182
183
  const DENSITY_STORAGE_KEY = "remit:list-density";
183
184
 
184
- // Stable action values for the two bulk actions that carry no parameters, so
185
- // the callbacks running them keep stable dependencies.
186
- const DELETE_ACTION: EscalatedAction = { kind: "delete" };
187
- const MARK_READ_ACTION: EscalatedAction = { kind: "markRead" };
188
-
189
185
  const readStoredDensity = (): Density => {
190
186
  try {
191
187
  const stored = localStorage.getItem(DENSITY_STORAGE_KEY);
@@ -222,7 +218,6 @@ export const MessageList = ({
222
218
  searchQuery,
223
219
  searchPredicate,
224
220
  onDeleteMessages,
225
- onMoveMessages,
226
221
  isDeleting = false,
227
222
  isMoving = false,
228
223
  onLoadMore,
@@ -240,12 +235,9 @@ export const MessageList = ({
240
235
  const parentRef = useRef<HTMLDivElement>(null);
241
236
  const navigate = useNavigate();
242
237
  const isDesktop = useIsDesktop();
238
+ const wizard = useSelectionWizard();
239
+ const { verb: wizardVerb, start: startWizard, startFromSearch } = wizard;
243
240
  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");
249
241
  const isSearching = !!searchQuery?.trim();
250
242
  const listHeaderChrome = useListHeaderChrome();
251
243
  const { labels } = useLabelList(accountId);
@@ -322,18 +314,12 @@ export const MessageList = ({
322
314
  // it back to the count, and across that render the two disagree.
323
315
  const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
324
316
 
325
- // Pending delete, awaiting confirmation. `null` means the dialog is closed.
326
- // `source: "ids"` snapshots the concrete ids at request time so a selection
327
- // change behind the dialog can't retarget the delete — every keyboard/desktop
328
- // entry point, and any bounded mobile delete, uses this. `source: "predicate"`
329
- // is mobile-only: an escalated selection has no materialized id list to
330
- // snapshot (D2, issue #92) only the count it was confirmed against.
331
- type PendingDelete =
332
- | { source: "ids"; ids: string[] }
333
- | { source: "predicate"; total: number };
334
- const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(
335
- null,
336
- );
317
+ // The one delete that does not walk the wizard: the Delete key with nothing
318
+ // ticked, which acts on the row under the cursor. The ids are snapshotted at
319
+ // request time so a cursor move behind the dialog cannot retarget it. Every
320
+ // delete over a selection ticked or escalated goes through the wizard's
321
+ // review screen instead.
322
+ const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
337
323
 
338
324
  // Search-scoped escalated selection + chunked bulk actions (issues #92, #212):
339
325
  // available on both surfaces, and only while search has more matches than
@@ -361,20 +347,11 @@ export const MessageList = ({
361
347
  clearSelection();
362
348
  }, [clearSelection, clearEscalation, escalationPhaseKind]);
363
349
 
364
- // Non-null exactly once a bounded/escalated run just ended with some ids
365
- // the action never reached: which action ran and the count that DID succeed,
366
- // so the partial-failure notice can say "N moved to Trash" alongside
367
- // "Retry" and Retry can resend the same action. `selectedIds` (materialized
368
- // to the failed ids) is the source of truth for how many are left; this only
369
- // supplies the other half of that sentence.
370
- const [lastRun, setLastRun] = useState<{
371
- action: EscalatedAction;
372
- succeeded: number;
373
- } | null>(null);
374
350
  // Transient, manually-dismissed success banner shown in place of the
375
- // selection bar once a chunked/escalated run finishes cleanly see
376
- // `processRunOutcome`. Honest about IMAP's async catch-up rather than
377
- // claiming a finality the bulk endpoint's response doesn't have.
351
+ // selection bar once a single-row delete lands on mobile, where the list
352
+ // stays up and nothing else says it happened (#202). Honest about IMAP's
353
+ // async catch-up rather than claiming a finality the bulk endpoint's
354
+ // response doesn't have.
378
355
  const [completionBanner, setCompletionBanner] = useState<string | null>(null);
379
356
 
380
357
  // Set when a keyboard command moves the roving cursor. Real DOM focus then
@@ -407,37 +384,6 @@ export const MessageList = ({
407
384
  const commandsAvailable = !isLoading && threads.length > 0;
408
385
  const confirmOpen = pendingDelete !== null;
409
386
 
410
- // Single choke point for what selection looks like once a chunked/escalated
411
- // run ends, for any reason (issue #92 requirement 10). A clean run with
412
- // nothing left over exits selection mode and hands off to a transient
413
- // completion banner instead of silently claiming a finality the bulk
414
- // endpoint's response doesn't have (IMAP applies the move asynchronously).
415
- // Anything left over becomes the new bounded selection — precisely what
416
- // Retry resends, per `resolveSelectionAfterRun`.
417
- const processRunOutcome = useCallback(
418
- (
419
- action: EscalatedAction,
420
- outcome: Parameters<typeof resolveSelectionAfterRun>[0],
421
- ) => {
422
- const { exit, retryIds } = resolveSelectionAfterRun(outcome);
423
- if (exit) {
424
- setCompletionBanner(
425
- bulkActionCompletionText(action.kind, outcome.done),
426
- );
427
- setLastRun(null);
428
- exitSelection();
429
- return;
430
- }
431
- if (retryIds.length > 0) {
432
- setLastRun({ action, succeeded: outcome.done });
433
- selectAll(retryIds);
434
- return;
435
- }
436
- setLastRun(null);
437
- },
438
- [exitSelection, selectAll],
439
- );
440
-
441
387
  const virtualizer = useVirtualizer({
442
388
  count: threads.length,
443
389
  getScrollElement: () => parentRef.current,
@@ -558,27 +504,18 @@ export const MessageList = ({
558
504
  ],
559
505
  );
560
506
 
561
- // Open the delete confirmation for an explicit set of ids. All delete
562
- // entry points (toolbar Trash2, Delete/Backspace key) funnel through here
563
- // so the move-to-Trash confirmation is consistent.
507
+ // Open the delete confirmation for the row under the cursor. A delete over a
508
+ // selection is a bulk action and walks the wizard instead, so this is the one
509
+ // delete the dialog still covers.
564
510
  const requestDelete = useCallback(
565
511
  (ids: string[]) => {
566
512
  if (!onDeleteMessages || ids.length === 0) return;
567
513
  focusBeforeConfirmRef.current = focusedMessageId ?? null;
568
- setPendingDelete({ source: "ids", ids });
514
+ setPendingDelete(ids);
569
515
  },
570
516
  [onDeleteMessages, focusedMessageId],
571
517
  );
572
518
 
573
- // Open the delete confirmation for the escalated predicate. `escalation.phase`
574
- // must already be "escalated" — the selection bar's onDelete (both surfaces)
575
- // only wires this up in that state.
576
- const requestEscalatedDelete = useCallback(() => {
577
- if (escalation.phase.kind !== "escalated") return;
578
- focusBeforeConfirmRef.current = focusedMessageId ?? null;
579
- setPendingDelete({ source: "predicate", total: escalation.phase.total });
580
- }, [escalation.phase, focusedMessageId]);
581
-
582
519
  // Keyboard shift-arrow range extend: move focus one row in `direction` and
583
520
  // extend the selection range from the existing anchor to the new focus —
584
521
  // the keyboard equivalent of shift-click. The anchor stays fixed across
@@ -611,33 +548,41 @@ export const MessageList = ({
611
548
  }
612
549
  }, [orderedIds, selectAll]);
613
550
 
614
- // Delete / Backspace: confirm-delete the selection, or the focused row when
615
- // nothing is selected.
616
- // Returns true when it took the keypress, so the route's fallback delete
617
- // (which skips the confirmation) doesn't also fire.
618
- const handleDeleteKey = useCallback((): boolean => {
619
- // The confirmation is already asking about a delete: the keypress belongs
620
- // to it, and answering it is the Confirm button's job. Claiming the press
621
- // here is what stops a second Delete from reaching an unconfirmed delete.
622
- if (pendingDelete !== null) return true;
623
- if (!onDeleteMessages) return false;
624
- if (selectedCount > 0) {
625
- requestDelete(Array.from(selectedIds));
626
- return true;
627
- }
628
- if (focusedMessageId) {
551
+ // A keyboard verb, routed the same way the bar routes its own. Over a
552
+ // selection every verb opens the wizard, so the keyboard cannot reach a bulk
553
+ // action the bar would have reviewed. Over a bare cursor only Delete is the
554
+ // list's, and it keeps the confirmation and the cursor hand-back.
555
+ const requestVerb = useCallback(
556
+ (verb: Verb): boolean => {
557
+ // The confirmation is already asking about a delete: the keypress belongs
558
+ // to it, and answering it is the Confirm button's job. Claiming the press
559
+ // here is what stops a second Delete from reaching an unconfirmed delete.
560
+ if (pendingDelete !== null) return true;
561
+ if (hasSelection) {
562
+ // Every verb on the bar opens the wizard (#477 1.4), whatever the
563
+ // selection is: the ticked rows, a selection spanning accounts — the
564
+ // wizard is where that restriction is stated, on the step that needs
565
+ // one account (#477 5.5) — or the predicate the list escalated to,
566
+ // which the wizard names on its match step and counts on its review
567
+ // screen before anything is sent (#508).
568
+ startWizard(verb);
569
+ return true;
570
+ }
571
+ if (verb !== "delete" || !onDeleteMessages || !focusedMessageId) {
572
+ return false;
573
+ }
629
574
  requestDelete([focusedMessageId]);
630
575
  return true;
631
- }
632
- return false;
633
- }, [
634
- pendingDelete,
635
- onDeleteMessages,
636
- selectedCount,
637
- selectedIds,
638
- focusedMessageId,
639
- requestDelete,
640
- ]);
576
+ },
577
+ [
578
+ pendingDelete,
579
+ onDeleteMessages,
580
+ hasSelection,
581
+ startWizard,
582
+ focusedMessageId,
583
+ requestDelete,
584
+ ],
585
+ );
641
586
 
642
587
  // Enter: open the focused row in the reading pane (sets selected/URL). This
643
588
  // is the focus→open transition of the 2-state model.
@@ -678,63 +623,31 @@ export const MessageList = ({
678
623
  open: followFocusOpen,
679
624
  });
680
625
 
681
- // Toolbar Trash2: confirm-delete the current selection.
682
- const handleDelete = useCallback(() => {
683
- if (selectedCount > 0) {
684
- requestDelete(Array.from(selectedIds));
685
- }
686
- }, [requestDelete, selectedCount, selectedIds]);
687
-
688
- // Confirm handler for the escalated predicate, or a bounded selection past
689
- // the 100-id bulk-call cap (>100 loaded rows selected — rare, but the write
690
- // side would 400 on a single call past that). Neither case gets the
691
- // cursor-repositioning treatment below: the run takes real time and the
692
- // user's attention is on the progress bar, not the roving cursor, and
693
- // rows update via cache invalidation once the run ends rather than an
694
- // optimistic per-row removal.
695
- // Runs one bulk action and hands its outcome to the single choke point that
696
- // decides what selection looks like afterwards. `ids` omitted means the
697
- // escalated predicate (#114) — delete, move and mark-read all take this
698
- // path, so an escalated selection is never delete-only.
699
- const runEscalatedAction = useCallback(
700
- async (action: EscalatedAction, ids?: string[]) => {
701
- const outcome = await escalation.runAction(action, ids);
702
- processRunOutcome(action, outcome);
703
- },
704
- [escalation, processRunOutcome],
705
- );
706
-
707
- const runChunkedConfirmDelete = useCallback(
708
- async (ids: string[] | undefined) => {
709
- setPendingDelete(null);
710
- focusBeforeConfirmRef.current = null;
711
- await runEscalatedAction(DELETE_ACTION, ids);
712
- },
713
- [runEscalatedAction],
714
- );
626
+ // The escalated selection as the wizard walks it (#508): the words the bar
627
+ // already names the predicate with, the count it was escalated to, and the
628
+ // chunked runner that re-resolves it. The wizard's review screen stands in
629
+ // front of that runner and its run screen drives it, so nothing here reports
630
+ // an outcome of its own.
631
+ const escalatedSelection: EscalatedSelection | undefined =
632
+ escalation.phase.kind === "escalated"
633
+ ? {
634
+ scope: describeSearchScope(searchPredicate ?? {}),
635
+ total: escalation.phase.total,
636
+ searchQuery: searchPredicate ?? {},
637
+ run: (action: EscalatedAction) => escalation.runAction(action),
638
+ }
639
+ : undefined;
715
640
 
716
- // Confirm handler: run the actual bulk delete, then clear selection and
717
- // move focus to a sensible neighbor (the row after the first deleted one).
641
+ // Confirm handler: run the actual delete, then clear selection and move
642
+ // focus to a sensible neighbor (the row after the first deleted one).
718
643
  const handleConfirmDelete = useCallback(() => {
719
644
  if (!pendingDelete) return;
720
645
 
721
- if (pendingDelete.source === "predicate") {
722
- void runChunkedConfirmDelete(undefined);
723
- return;
724
- }
725
-
726
- const { ids } = pendingDelete;
646
+ const ids = pendingDelete;
727
647
  if (ids.length === 0) {
728
648
  setPendingDelete(null);
729
649
  return;
730
650
  }
731
- if (ids.length > BULK_ACTION_CHUNK_SIZE) {
732
- // Selection stays put (still `ids`) for the duration of the run —
733
- // `processRunOutcome` is the one place that clears it, on success,
734
- // or replaces it with whatever's left to retry.
735
- void runChunkedConfirmDelete(ids);
736
- return;
737
- }
738
651
 
739
652
  const deletedSet = new Set(ids);
740
653
  const firstDeletedIndex = threads.findIndex((t) =>
@@ -784,9 +697,9 @@ export const MessageList = ({
784
697
  }
785
698
  }
786
699
 
787
- // Mobile keeps the list up, so it needs its own signal the delete landed
788
- // the transient completion banner a chunked run already raises (#202). On
789
- // desktop the rows leaving the list beside the reading pane is signal enough.
700
+ // Mobile keeps the list up, so it needs its own signal the delete landed
701
+ // (#202). On desktop the rows leaving the list beside the reading pane is
702
+ // signal enough.
790
703
  if (!isDesktop) {
791
704
  setCompletionBanner(bulkActionCompletionText("delete", ids.length));
792
705
  }
@@ -798,7 +711,6 @@ export const MessageList = ({
798
711
  navigate,
799
712
  mailboxId,
800
713
  isDesktop,
801
- runChunkedConfirmDelete,
802
714
  setFocusedMessageId,
803
715
  ]);
804
716
 
@@ -816,48 +728,6 @@ export const MessageList = ({
816
728
  setFocusedMessageId(restoreTo);
817
729
  }, [setFocusedMessageId]);
818
730
 
819
- // Mark read, bounded and escalated alike (#114). Both go through the same
820
- // chunked run: the bulk flags call caps at 100 ids, so a selection past
821
- // that would 400 as a single call, and an escalated selection has no id
822
- // list at all — it pages its predicate instead.
823
- const handleMarkAsRead = useCallback(() => {
824
- if (escalation.phase.kind === "escalated") {
825
- void runEscalatedAction(MARK_READ_ACTION);
826
- return;
827
- }
828
- if (selectedCount === 0) return;
829
- void runEscalatedAction(MARK_READ_ACTION, Array.from(selectedIds));
830
- }, [escalation.phase, runEscalatedAction, selectedCount, selectedIds]);
831
-
832
- // Move to a chosen folder, escalated selections included (#114).
833
- const handleMoveSelected = useCallback(
834
- (destinationMailboxId: string) => {
835
- if (escalation.phase.kind === "escalated") {
836
- void runEscalatedAction({ kind: "move", destinationMailboxId });
837
- return;
838
- }
839
- if (!onMoveMessages || selectedCount === 0) return;
840
- onMoveMessages(Array.from(selectedIds), destinationMailboxId);
841
- exitSelection();
842
- },
843
- [
844
- escalation.phase,
845
- runEscalatedAction,
846
- onMoveMessages,
847
- selectedCount,
848
- selectedIds,
849
- exitSelection,
850
- ],
851
- );
852
-
853
- // Junk quick action (mobile sheet): move the selection to the appointed Junk
854
- // mailbox. Reuses the same bounded/escalated move path as any other move, so
855
- // the chunked run and the cross-account guard apply unchanged.
856
- const handleJunk = useCallback(() => {
857
- if (!junkMailboxId) return;
858
- handleMoveSelected(junkMailboxId);
859
- }, [junkMailboxId, handleMoveSelected]);
860
-
861
731
  // Cross-account guard: every selected thread row must belong to the
862
732
  // same account as the current mailbox. The list is already scoped to
863
733
  // one mailbox so in practice this is always single-account, but we
@@ -881,22 +751,20 @@ export const MessageList = ({
881
751
  return undefined;
882
752
  }, [selectedCount, selectedIds, threads]);
883
753
 
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
- );
754
+ // Organize builds a rule out of clauses, and a search predicate is not a set
755
+ // of clauses its facets have no `ClauseField`. Over an escalated selection
756
+ // the verb therefore opens the wizard through the search entry instead, on the
757
+ // property step with the query already converted (#477 1.8): the same door the
758
+ // make-filter affordance opens, which the bar hides while rows are ticked. The
759
+ // control stays pressable and lands somewhere that works (#477 1.7) rather
760
+ // than disappearing the moment a selection escalates.
761
+ const organizeSelection = useCallback(() => {
762
+ if (escalatedSelection) {
763
+ startFromSearch();
764
+ return;
765
+ }
766
+ startWizard("organize");
767
+ }, [escalatedSelection, startFromSearch, startWizard]);
900
768
 
901
769
  // Swipe-to-delete single message
902
770
  const handleSwipeDelete = useCallback(
@@ -925,17 +793,6 @@ export const MessageList = ({
925
793
  [isDesktop, select],
926
794
  );
927
795
 
928
- // The selection bar's Trash tap (both surfaces): an escalated selection has
929
- // no materialized ids to hand `requestDelete`, so it opens the predicate
930
- // confirmation instead.
931
- const handleSelectionDelete = useCallback(() => {
932
- if (escalation.phase.kind === "escalated") {
933
- requestEscalatedDelete();
934
- return;
935
- }
936
- handleDelete();
937
- }, [escalation.phase, requestEscalatedDelete, handleDelete]);
938
-
939
796
  // The selection bar's X (both surfaces): means "stop what's happening"
940
797
  // throughout, not just "cancel selection" (issue #92 — the review flagged
941
798
  // the X reading as ambiguous once a run is going). Counting and a chunked
@@ -955,21 +812,6 @@ export const MessageList = ({
955
812
  escalation.clear();
956
813
  }, [escalation]);
957
814
 
958
- // Partial-failure notice's Retry: rerun the same action against exactly the
959
- // ids it never reached last time (`selectedIds`, materialized there by
960
- // `processRunOutcome`) — never the original selection. A retried delete
961
- // keeps going through the confirmation path so the dialog's own state is
962
- // cleared with it.
963
- const handleRetryFailed = useCallback(() => {
964
- if (!lastRun) return;
965
- const ids = Array.from(selectedIds);
966
- if (lastRun.action.kind === "delete") {
967
- void runChunkedConfirmDelete(ids);
968
- return;
969
- }
970
- void runEscalatedAction(lastRun.action, ids);
971
- }, [lastRun, runChunkedConfirmDelete, runEscalatedAction, selectedIds]);
972
-
973
815
  // Scroll the roving focus cursor into view as it moves (j/k). Falls back to
974
816
  // the open thread when nothing is focused yet.
975
817
  useEffect(() => {
@@ -1019,13 +861,14 @@ export const MessageList = ({
1019
861
  focusedMessageId,
1020
862
  selectedIds: Array.from(selectedIds),
1021
863
  hasList: commandsAvailable,
1022
- blocksKeyboard: confirmOpen,
864
+ blocksKeyboard: confirmOpen || wizard.isOpen,
1023
865
  });
1024
866
  }, [
1025
867
  focusedMessageId,
1026
868
  selectedIds,
1027
869
  commandsAvailable,
1028
870
  confirmOpen,
871
+ wizard.isOpen,
1029
872
  onTriageContextChange,
1030
873
  ]);
1031
874
 
@@ -1050,18 +893,12 @@ export const MessageList = ({
1050
893
  // only the ids that left and keeping every survivor — K-9's
1051
894
  // `selected.intersect(uniqueIds)`, the reference behavior #92's D2 cites.
1052
895
  // Wiping the whole selection because one id left (#111) cost the other 49
1053
- // rows on an ordinary refresh, and could take the post-delete Retry
1054
- // selection with it: `processRunOutcome` materializes the failed ids as
1055
- // the new selection and resets this effect to live by returning escalation
1056
- // to idle, so the cache invalidation's refetch ran this same effect against
1057
- // the retry set — a clear here would have dropped the Retry notice
1058
- // (gated on `selectedCount > 0`) along with it.
896
+ // rows on an ordinary refresh.
1059
897
  // Skipped while an escalated run is active: `selectedIds` there is a stale
1060
898
  // loaded-rows snapshot from the moment escalation started (the real
1061
899
  // selection is the predicate, D2), not something a background refetch
1062
900
  // reshuffling `threads` should be allowed to narrow out from under a count
1063
- // or a delete in progress — that would silently exit selection mode
1064
- // mid-run.
901
+ // or a run in progress — that would silently exit selection mode mid-run.
1065
902
  useEffect(() => {
1066
903
  if (escalation.phase.kind !== "idle" || escalation.isRunning) return;
1067
904
  intersectWith(threads.map((t) => t.messageId));
@@ -1153,7 +990,7 @@ export const MessageList = ({
1153
990
  exitSelection();
1154
991
  return true;
1155
992
  },
1156
- requestDelete: handleDeleteKey,
993
+ requestVerb,
1157
994
  toggleDensity,
1158
995
  };
1159
996
  return () => {
@@ -1173,7 +1010,7 @@ export const MessageList = ({
1173
1010
  handleSelectAll,
1174
1011
  hasSelection,
1175
1012
  exitSelection,
1176
- handleDeleteKey,
1013
+ requestVerb,
1177
1014
  toggleDensity,
1178
1015
  ]);
1179
1016
 
@@ -1262,10 +1099,8 @@ export const MessageList = ({
1262
1099
 
1263
1100
  // At most one escalation notice at a time, ranked by how actionable it is:
1264
1101
  // an in-progress counting/escalated state and its own action always wins;
1265
- // otherwise a fresh escalation offer; otherwise a just-finished partial
1266
- // failure's Retry. The (rare) cross-account move hint is layered on per
1267
- // surface below — the desktop toolbar shows it inline, the mobile sheet
1268
- // folds it in as the lowest-priority notice.
1102
+ // otherwise a fresh escalation offer. The (rare) cross-account move hint is
1103
+ // layered on behind them below.
1269
1104
  const escalationNotice =
1270
1105
  escalation.phase.kind === "counting"
1271
1106
  ? {
@@ -1291,36 +1126,7 @@ export const MessageList = ({
1291
1126
  onClick: escalation.escalate,
1292
1127
  },
1293
1128
  }
1294
- : lastRun !== null && selectedCount > 0
1295
- ? {
1296
- tone: "danger" as const,
1297
- text: bulkActionPartialText(
1298
- lastRun.action.kind,
1299
- lastRun.succeeded,
1300
- selectedCount,
1301
- ),
1302
- action: {
1303
- label: `Retry ${formatNumber(selectedCount)}`,
1304
- onClick: handleRetryFailed,
1305
- },
1306
- }
1307
- : undefined;
1308
-
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).
1311
- const selectionMoveSlot =
1312
- !canOpenWizard &&
1313
- (onMoveMessages || escalation.phase.kind === "escalated") &&
1314
- accountId &&
1315
- mailboxId ? (
1316
- <MoveToTrigger
1317
- accountId={accountId}
1318
- currentMailboxId={mailboxId}
1319
- onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
1320
- disabledHint={moveDisabledHint}
1321
- label="Move selected messages"
1322
- />
1323
- ) : undefined;
1129
+ : undefined;
1324
1130
 
1325
1131
  // The bar shows one notice at a time, so the cross-account move restriction
1326
1132
  // rides in behind the escalation states.
@@ -1346,24 +1152,15 @@ export const MessageList = ({
1346
1152
  idleSlot={listHeaderChrome.makeFilterSlot}
1347
1153
  count={selectionCount}
1348
1154
  onCancel={handleSelectionCancel}
1349
- onDelete={
1350
- canOpenWizard ? () => startWizard("delete") : handleSelectionDelete
1351
- }
1352
- onMove={canOpenWizard ? () => startWizard("move") : undefined}
1353
- moveSlot={selectionMoveSlot}
1354
- onOrganize={canOpenWizard ? () => startWizard("organize") : undefined}
1155
+ onDelete={() => startWizard("delete")}
1156
+ onMove={() => startWizard("move")}
1157
+ onOrganize={organizeSelection}
1355
1158
  onJunk={
1356
1159
  junkMailboxId && junkMailboxId !== mailboxId
1357
- ? canOpenWizard
1358
- ? () => startWizard("junk")
1359
- : !moveDisabledHint
1360
- ? handleJunk
1361
- : undefined
1160
+ ? () => startWizard("junk")
1362
1161
  : undefined
1363
1162
  }
1364
- onMarkRead={
1365
- canOpenWizard ? () => startWizard("markRead") : handleMarkAsRead
1366
- }
1163
+ onMarkRead={() => startWizard("markRead")}
1367
1164
  overflowSlot={
1368
1165
  accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
1369
1166
  <LabelApplyTrigger
@@ -1465,20 +1262,6 @@ export const MessageList = ({
1465
1262
  </>
1466
1263
  );
1467
1264
 
1468
- const pendingDeleteCount = pendingDelete
1469
- ? pendingDelete.source === "ids"
1470
- ? pendingDelete.ids.length
1471
- : pendingDelete.total
1472
- : 0;
1473
-
1474
- // The predicate case (#109): `pendingDelete.total` is `countMatches`'s
1475
- // frozen page-through, and the delete itself re-pages the same predicate a
1476
- // second, independent time. Mail arriving or leaving between the two can
1477
- // make them differ, so the dialog says "about" instead of promising an
1478
- // exact number it may not honour. A materialized (bounded) selection's
1479
- // count is exact — it's the delete's own input, not an estimate of it.
1480
- const pendingDeleteIsEstimate = pendingDelete?.source === "predicate";
1481
-
1482
1265
  return (
1483
1266
  <>
1484
1267
  {completionBanner && !isDesktop && (
@@ -1525,15 +1308,8 @@ export const MessageList = ({
1525
1308
  />
1526
1309
  <ConfirmDialog
1527
1310
  isOpen={pendingDelete !== null}
1528
- title={formatDeleteToTrashTitle(
1529
- pendingDeleteCount,
1530
- pendingDeleteIsEstimate,
1531
- )}
1532
- description={
1533
- pendingDeleteIsEstimate
1534
- ? "This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later."
1535
- : "You can restore them from Trash later."
1536
- }
1311
+ title={formatDeleteToTrashTitle(pendingDelete?.length ?? 0)}
1312
+ description="You can restore them from Trash later."
1537
1313
  confirmLabel="Move to Trash"
1538
1314
  destructive
1539
1315
  isBusy={isDeleting}
@@ -1546,6 +1322,8 @@ export const MessageList = ({
1546
1322
  mailboxId={mailboxId}
1547
1323
  selection={wizardSelection}
1548
1324
  crossAccount={moveDisabledHint !== undefined}
1325
+ escalated={escalatedSelection}
1326
+ escalatedProgress={escalation.progress}
1549
1327
  onFinished={exitSelection}
1550
1328
  />
1551
1329
  </>