@remit/web-client 0.0.25 → 0.0.26

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
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": {
@@ -81,6 +81,7 @@ import {
81
81
  dropDeletedThreads,
82
82
  useDeleteMessages,
83
83
  } from "@/hooks/useDeleteMessages";
84
+ import type { EscalationSearchQuery } from "@/hooks/useEscalatedDelete";
84
85
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
85
86
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
86
87
  import { useLayoutTier } from "@/hooks/useLayoutTier";
@@ -183,6 +184,14 @@ interface MailboxPaneContextValue {
183
184
  onClearFilters: () => void;
184
185
  intelligenceOpen: boolean;
185
186
  onToggleIntelligence: () => void;
187
+ /**
188
+ * The active search predicate — undefined when not searching. Threaded
189
+ * down so the mobile list can re-issue the identical filter while paging
190
+ * past what's loaded (escalated select-all, issue #92); the display string
191
+ * on `searchQuery`/`useMailContext` only carries what's shown in the
192
+ * header, not enough to reproduce the query server-side.
193
+ */
194
+ searchPredicate: EscalationSearchQuery | undefined;
186
195
  // List actions
187
196
  onDeleteMessages: (ids: string[]) => void;
188
197
  onMoveMessages: (ids: string[], dest: string) => void;
@@ -866,6 +875,7 @@ function MailboxPaneProvider({
866
875
  onClearFilters,
867
876
  intelligenceOpen,
868
877
  onToggleIntelligence,
878
+ searchPredicate: hasSearchQuery ? searchThreadsQuery : undefined,
869
879
  onDeleteMessages: handleDeleteMessages,
870
880
  onMoveMessages: handleMoveMessages,
871
881
  isDeleting,
@@ -935,6 +945,7 @@ function MailboxList() {
935
945
  onSelectFilterCategory,
936
946
  onToggleFilterAttribute,
937
947
  onClearFilters,
948
+ searchPredicate,
938
949
  } = useMailboxPane();
939
950
  const { searchQuery, searchInput, accounts } = useMailContext();
940
951
  const tier = useLayoutTier();
@@ -1013,6 +1024,7 @@ function MailboxList() {
1013
1024
  error={error}
1014
1025
  onRetry={onRetry}
1015
1026
  searchQuery={searchQuery}
1027
+ searchPredicate={searchPredicate}
1016
1028
  onDeleteMessages={onDeleteMessages}
1017
1029
  onMoveMessages={onMoveMessages}
1018
1030
  isDeleting={isDeleting}
@@ -1,5 +1,10 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
- import { type Density, MessageListPane, SelectionTopBar } from "@remit/ui";
2
+ import {
3
+ Banner,
4
+ type Density,
5
+ MessageListPane,
6
+ SelectionTopBar,
7
+ } from "@remit/ui";
3
8
  import { useNavigate } from "@tanstack/react-router";
4
9
  import { useVirtualizer } from "@tanstack/react-virtual";
5
10
  import { Search, Sparkles } from "lucide-react";
@@ -7,6 +12,10 @@ import type { RefObject } from "react";
7
12
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
13
  import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
9
14
  import { formatErrorMessage } from "@/components/ui/ErrorState";
15
+ import {
16
+ type EscalationSearchQuery,
17
+ useEscalatedDelete,
18
+ } from "@/hooks/useEscalatedDelete";
10
19
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
11
20
  import { useIsDesktop } from "@/hooks/useMediaQuery";
12
21
  import {
@@ -15,8 +24,17 @@ import {
15
24
  useSelection,
16
25
  } from "@/hooks/useSelection";
17
26
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
18
- import { formatDeleteToTrashTitle } from "@/lib/format";
27
+ import {
28
+ BULK_DELETE_CHUNK_SIZE,
29
+ resolveSelectionAfterDelete,
30
+ } from "@/lib/bulk-delete";
31
+ import {
32
+ escalatedStatusLabel,
33
+ escalationActionLabel,
34
+ } from "@/lib/escalation-label";
35
+ import { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
19
36
  import { tabStopId } from "@/lib/list-focus";
37
+ import { cn } from "@/lib/utils";
20
38
  import { MoveToTrigger } from "./MoveToTrigger";
21
39
  import { OrganizeDialog } from "./organize/OrganizeDialog";
22
40
  import { SelectionToolbar } from "./SelectionToolbar";
@@ -58,6 +76,13 @@ interface MessageListProps {
58
76
  error?: unknown;
59
77
  onRetry?: () => void;
60
78
  searchQuery?: string;
79
+ /**
80
+ * The active search predicate (undefined when not searching). Re-issued
81
+ * with fresh continuation tokens to page past what's loaded — the
82
+ * escalated select-all flow (issue #92) — since `searchQuery` above is
83
+ * only the display string.
84
+ */
85
+ searchPredicate?: EscalationSearchQuery;
61
86
  onDeleteMessages?: (messageIds: string[]) => void;
62
87
  onMarkAsRead?: (messageIds: string[]) => void;
63
88
  onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
@@ -156,6 +181,7 @@ export const MessageList = ({
156
181
  error,
157
182
  onRetry,
158
183
  searchQuery,
184
+ searchPredicate,
159
185
  onDeleteMessages,
160
186
  onMarkAsRead,
161
187
  onMoveMessages,
@@ -176,6 +202,7 @@ export const MessageList = ({
176
202
  const isDesktop = useIsDesktop();
177
203
  const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
178
204
  const [organizeOpen, setOrganizeOpen] = useState(false);
205
+ const isSearching = !!searchQuery?.trim();
179
206
 
180
207
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
181
208
  // from the open thread (`selectedMessageId` in the URL). j/k move this
@@ -216,15 +243,48 @@ export const MessageList = ({
216
243
  selectRange,
217
244
  setAnchor,
218
245
  selectAll,
246
+ toggleAll,
219
247
  } = useSelection();
220
248
 
221
- // Ids queued for deletion, awaiting confirmation. `null` means the dialog
222
- // is closed. Snapshotted at request time so a selection change behind the
223
- // dialog can't retarget the delete.
224
- const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(
249
+ // Pending delete, awaiting confirmation. `null` means the dialog is closed.
250
+ // `source: "ids"` snapshots the concrete ids at request time so a selection
251
+ // change behind the dialog can't retarget the delete — every keyboard/desktop
252
+ // entry point, and any bounded mobile delete, uses this. `source: "predicate"`
253
+ // is mobile-only: an escalated selection has no materialized id list to
254
+ // snapshot (D2, issue #92) — only the count it was confirmed against.
255
+ type PendingDelete =
256
+ | { source: "ids"; ids: string[] }
257
+ | { source: "predicate"; total: number };
258
+ const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(
225
259
  null,
226
260
  );
227
261
 
262
+ // Search-scoped escalated selection + chunked bulk delete (issue #92):
263
+ // mobile only, and only while search has more matches than are loaded.
264
+ // `orderedIds` below feeds `allLoadedSelected`; declared after this hook so
265
+ // its callback deps stay simple — see the `orderedIds`/`handleRowSelect`
266
+ // block.
267
+ const escalationEnabled = !isDesktop && isSearching && !!searchPredicate;
268
+ const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
269
+ const escalation = useEscalatedDelete({
270
+ mailboxId,
271
+ accountId,
272
+ enabled: escalationEnabled,
273
+ predicateKey,
274
+ searchQuery: searchPredicate ?? {},
275
+ });
276
+ // Non-null exactly once a bounded/escalated run just ended with some ids
277
+ // still not confirmed deleted: the count that DID succeed in that run, so
278
+ // the partial-failure notice can say "N moved to Trash" alongside "Retry".
279
+ // `selectedIds` (materialized to the failed ids) is the source of truth for
280
+ // how many are left; this only supplies the other half of that sentence.
281
+ const [lastRunSucceeded, setLastRunSucceeded] = useState<number | null>(null);
282
+ // Transient, manually-dismissed success banner shown in place of the
283
+ // selection bar once a chunked/escalated delete finishes cleanly — see
284
+ // `processDeleteOutcome`. Honest about IMAP's async catch-up rather than
285
+ // claiming a finality the bulk endpoint's response doesn't have.
286
+ const [completionBanner, setCompletionBanner] = useState<string | null>(null);
287
+
228
288
  // Set when a keyboard command moves the roving cursor. Real DOM focus then
229
289
  // follows the cursor onto the row once the virtualizer has rendered it, so
230
290
  // the browser's own focus — and therefore Tab, Shift+Tab and the focus ring
@@ -253,7 +313,7 @@ export const MessageList = ({
253
313
  // the route fall through to its own unconfirmed delete on a second Delete
254
314
  // press. The route suspends the whole keyboard layer for the dialog instead.
255
315
  const commandsAvailable = !isLoading && threads.length > 0;
256
- const confirmOpen = pendingDeleteIds !== null;
316
+ const confirmOpen = pendingDelete !== null;
257
317
 
258
318
  // Auto-exit multi-select when selection becomes empty
259
319
  useEffect(() => {
@@ -262,6 +322,34 @@ export const MessageList = ({
262
322
  }
263
323
  }, [isMultiSelectMode, selectedCount]);
264
324
 
325
+ // Single choke point for what selection looks like once a chunked/escalated
326
+ // run ends, for any reason (issue #92 requirement 10). A clean run with
327
+ // nothing left over exits selection mode and hands off to a transient
328
+ // completion banner instead of silently claiming a finality the bulk
329
+ // endpoint's response doesn't have (IMAP applies the move asynchronously).
330
+ // Anything left over becomes the new bounded selection — precisely what
331
+ // Retry resends, per `resolveSelectionAfterDelete`.
332
+ const processDeleteOutcome = useCallback(
333
+ (outcome: Parameters<typeof resolveSelectionAfterDelete>[0]) => {
334
+ const { exit, retryIds } = resolveSelectionAfterDelete(outcome);
335
+ if (exit) {
336
+ setCompletionBanner(
337
+ `${formatNumber(outcome.done)} moved to Trash. Your mail server is still catching up.`,
338
+ );
339
+ setLastRunSucceeded(null);
340
+ clearSelection();
341
+ return;
342
+ }
343
+ if (retryIds.length > 0) {
344
+ setLastRunSucceeded(outcome.done);
345
+ selectAll(retryIds);
346
+ return;
347
+ }
348
+ setLastRunSucceeded(null);
349
+ },
350
+ [clearSelection, selectAll],
351
+ );
352
+
265
353
  const virtualizer = useVirtualizer({
266
354
  count: threads.length,
267
355
  getScrollElement: () => parentRef.current,
@@ -359,11 +447,20 @@ export const MessageList = ({
359
447
  (ids: string[]) => {
360
448
  if (!onDeleteMessages || ids.length === 0) return;
361
449
  focusBeforeConfirmRef.current = focusedMessageId ?? null;
362
- setPendingDeleteIds(ids);
450
+ setPendingDelete({ source: "ids", ids });
363
451
  },
364
452
  [onDeleteMessages, focusedMessageId],
365
453
  );
366
454
 
455
+ // Mobile-only: open the delete confirmation for the escalated predicate.
456
+ // `escalation.phase` must already be "escalated" — the caller (the mobile
457
+ // bar's onDelete) only wires this up in that state.
458
+ const requestEscalatedDelete = useCallback(() => {
459
+ if (escalation.phase.kind !== "escalated") return;
460
+ focusBeforeConfirmRef.current = focusedMessageId ?? null;
461
+ setPendingDelete({ source: "predicate", total: escalation.phase.total });
462
+ }, [escalation.phase, focusedMessageId]);
463
+
367
464
  // Keyboard shift-arrow range extend: move focus one row in `direction` and
368
465
  // extend the selection range from the existing anchor to the new focus —
369
466
  // the keyboard equivalent of shift-click. The anchor stays fixed across
@@ -402,7 +499,7 @@ export const MessageList = ({
402
499
  // The confirmation is already asking about a delete: the keypress belongs
403
500
  // to it, and answering it is the Confirm button's job. Claiming the press
404
501
  // here is what stops a second Delete from reaching an unconfirmed delete.
405
- if (pendingDeleteIds !== null) return true;
502
+ if (pendingDelete !== null) return true;
406
503
  if (!onDeleteMessages) return false;
407
504
  if (selectedCount > 0) {
408
505
  requestDelete(Array.from(selectedIds));
@@ -414,7 +511,7 @@ export const MessageList = ({
414
511
  }
415
512
  return false;
416
513
  }, [
417
- pendingDeleteIds,
514
+ pendingDelete,
418
515
  onDeleteMessages,
419
516
  selectedCount,
420
517
  selectedIds,
@@ -440,14 +537,47 @@ export const MessageList = ({
440
537
  }
441
538
  }, [requestDelete, selectedCount, selectedIds]);
442
539
 
540
+ // Confirm handler for the escalated predicate, or a bounded selection past
541
+ // the 100-id bulk-call cap (>100 loaded rows selected — rare, but the write
542
+ // side would 400 on a single call past that). Neither case gets the
543
+ // cursor-repositioning treatment below: the run takes real time and the
544
+ // user's attention is on the progress bar, not the roving cursor, and
545
+ // rows update via cache invalidation once the run ends rather than an
546
+ // optimistic per-row removal.
547
+ const runChunkedConfirmDelete = useCallback(
548
+ async (ids: string[] | undefined) => {
549
+ setPendingDelete(null);
550
+ focusBeforeConfirmRef.current = null;
551
+ const outcome = await escalation.runDelete(ids);
552
+ processDeleteOutcome(outcome);
553
+ },
554
+ [escalation, processDeleteOutcome],
555
+ );
556
+
443
557
  // Confirm handler: run the actual bulk delete, then clear selection and
444
558
  // move focus to a sensible neighbor (the row after the first deleted one).
445
559
  const handleConfirmDelete = useCallback(() => {
446
- if (!pendingDeleteIds || pendingDeleteIds.length === 0) {
447
- setPendingDeleteIds(null);
560
+ if (!pendingDelete) return;
561
+
562
+ if (pendingDelete.source === "predicate") {
563
+ void runChunkedConfirmDelete(undefined);
564
+ return;
565
+ }
566
+
567
+ const { ids } = pendingDelete;
568
+ if (ids.length === 0) {
569
+ setPendingDelete(null);
448
570
  return;
449
571
  }
450
- const deletedSet = new Set(pendingDeleteIds);
572
+ if (ids.length > BULK_DELETE_CHUNK_SIZE) {
573
+ // Selection stays put (still `ids`) for the duration of the run —
574
+ // `processDeleteOutcome` is the one place that clears it, on success,
575
+ // or replaces it with whatever's left to retry.
576
+ void runChunkedConfirmDelete(ids);
577
+ return;
578
+ }
579
+
580
+ const deletedSet = new Set(ids);
451
581
  const firstDeletedIndex = threads.findIndex((t) =>
452
582
  deletedSet.has(t.messageId),
453
583
  );
@@ -469,10 +599,10 @@ export const MessageList = ({
469
599
  }
470
600
  }
471
601
 
472
- onDeleteMessages?.(pendingDeleteIds);
602
+ onDeleteMessages?.(ids);
473
603
  clearSelection();
474
604
  focusBeforeConfirmRef.current = null;
475
- setPendingDeleteIds(null);
605
+ setPendingDelete(null);
476
606
 
477
607
  if (nextFocus !== undefined) {
478
608
  // Same hand-back as cancelling, aimed at the surviving neighbour
@@ -488,12 +618,13 @@ export const MessageList = ({
488
618
  });
489
619
  }
490
620
  }, [
491
- pendingDeleteIds,
621
+ pendingDelete,
492
622
  threads,
493
623
  onDeleteMessages,
494
624
  clearSelection,
495
625
  navigate,
496
626
  mailboxId,
627
+ runChunkedConfirmDelete,
497
628
  ]);
498
629
 
499
630
  // Every way out of the confirmation that isn't the delete — Escape, Cancel,
@@ -503,7 +634,7 @@ export const MessageList = ({
503
634
  const handleCancelDelete = useCallback(() => {
504
635
  const restoreTo = focusBeforeConfirmRef.current;
505
636
  focusBeforeConfirmRef.current = null;
506
- setPendingDeleteIds(null);
637
+ setPendingDelete(null);
507
638
  if (restoreTo === null) return;
508
639
  pendingDomFocusRef.current = restoreTo;
509
640
  cursorMovedByPointerRef.current = false;
@@ -583,6 +714,45 @@ export const MessageList = ({
583
714
  clearSelection();
584
715
  }, [clearSelection]);
585
716
 
717
+ // Mobile bar's Trash tap: an escalated selection has no materialized ids to
718
+ // hand `requestDelete`, so it opens the predicate confirmation instead.
719
+ const handleMobileDelete = useCallback(() => {
720
+ if (escalation.phase.kind === "escalated") {
721
+ requestEscalatedDelete();
722
+ return;
723
+ }
724
+ handleDelete();
725
+ }, [escalation.phase, requestEscalatedDelete, handleDelete]);
726
+
727
+ // Mobile bar's X: means "stop what's happening" throughout, not just
728
+ // "cancel selection" (issue #92 — the review flagged the X reading as
729
+ // ambiguous once a delete is running). Counting and deleting both stop at
730
+ // the next page boundary; an escalated-but-idle selection drops back to
731
+ // bounded on the way out, same as tapping "Clear selection" first.
732
+ const handleMobileCancel = useCallback(() => {
733
+ if (escalation.isDeleting || escalation.phase.kind === "counting") {
734
+ escalation.stop();
735
+ return;
736
+ }
737
+ if (escalation.phase.kind === "escalated") {
738
+ escalation.clear();
739
+ }
740
+ handleCancelMultiSelect();
741
+ }, [escalation, handleCancelMultiSelect]);
742
+
743
+ // The escalation notice's "Clear selection" action: drop back to the
744
+ // bounded (all-loaded) selection without touching selection mode itself.
745
+ const handleClearEscalation = useCallback(() => {
746
+ escalation.clear();
747
+ }, [escalation]);
748
+
749
+ // Partial-failure notice's Retry: resend exactly the ids that didn't
750
+ // confirm deleted last time (`selectedIds`, materialized there by
751
+ // `processDeleteOutcome`) — never the original selection.
752
+ const handleRetryFailed = useCallback(() => {
753
+ void runChunkedConfirmDelete(Array.from(selectedIds));
754
+ }, [runChunkedConfirmDelete, selectedIds]);
755
+
586
756
  // Scroll the roving focus cursor into view as it moves (j/k). Falls back to
587
757
  // the open thread when nothing is focused yet.
588
758
  useEffect(() => {
@@ -659,8 +829,14 @@ export const MessageList = ({
659
829
  [],
660
830
  );
661
831
 
662
- // Clear selection when threads change (e.g., after delete)
832
+ // Clear selection when threads change (e.g., after delete). Skipped while
833
+ // an escalated run is active: `selectedIds` there is a stale loaded-rows
834
+ // snapshot from the moment escalation started (the real selection is the
835
+ // predicate, D2), not something a background refetch reshuffling `threads`
836
+ // should be allowed to blow away out from under a count or a delete in
837
+ // progress — that would silently exit selection mode mid-run.
663
838
  useEffect(() => {
839
+ if (escalation.phase.kind !== "idle" || escalation.isDeleting) return;
664
840
  const threadIds = new Set(threads.map((t) => t.messageId));
665
841
  const hasOrphanedSelection = Array.from(selectedIds).some(
666
842
  (id) => !threadIds.has(id),
@@ -668,7 +844,13 @@ export const MessageList = ({
668
844
  if (hasOrphanedSelection) {
669
845
  clearSelection();
670
846
  }
671
- }, [threads, selectedIds, clearSelection]);
847
+ }, [
848
+ threads,
849
+ selectedIds,
850
+ clearSelection,
851
+ escalation.phase,
852
+ escalation.isDeleting,
853
+ ]);
672
854
 
673
855
  // Load more when scrolling near the bottom
674
856
  useEffect(() => {
@@ -793,22 +975,124 @@ export const MessageList = ({
793
975
  />
794
976
  ) : undefined;
795
977
 
978
+ // Tier one of the two-tier select-all (issue #92, following Gmail web):
979
+ // every loaded row is checked. Computed against actual membership, not a
980
+ // count comparison, so a transient mismatch (mid-render, before the
981
+ // orphaned-selection effect settles) can't read as "all loaded" by
982
+ // coincidence.
983
+ const allLoadedSelected =
984
+ orderedIds.length > 0 && orderedIds.every((id) => selectedIds.has(id));
985
+
986
+ // Tier two: offered only once tier one is complete and search has more
987
+ // matches than are loaded — never a bare "Select all" (requirement 4).
988
+ const escalationAvailable =
989
+ escalationEnabled &&
990
+ hasMore &&
991
+ allLoadedSelected &&
992
+ !isDeleting &&
993
+ !isMoving &&
994
+ escalation.phase.kind === "idle" &&
995
+ !escalation.isDeleting;
996
+
997
+ const mobileIsBusy = isDeleting || isMoving || escalation.isDeleting;
998
+ const mobileCount =
999
+ escalation.phase.kind === "escalated"
1000
+ ? escalation.phase.total
1001
+ : selectedCount;
1002
+
1003
+ const mobileStatusLabel = escalation.isDeleting
1004
+ ? `Deleting ${formatNumber(escalation.deleteProgress?.done ?? 0)} of ${formatNumber(escalation.deleteProgress?.total ?? 0)}…`
1005
+ : escalation.phase.kind === "counting"
1006
+ ? escalation.phase.countSoFar >= 5000
1007
+ ? `Counting… ${formatNumber(escalation.phase.countSoFar)} so far. This is a big result set.`
1008
+ : `Counting… ${formatNumber(escalation.phase.countSoFar)} so far`
1009
+ : escalation.phase.kind === "escalated"
1010
+ ? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
1011
+ : undefined;
1012
+
1013
+ const mobileSelectAll =
1014
+ escalation.phase.kind !== "escalated" && orderedIds.length > 0
1015
+ ? {
1016
+ checked: allLoadedSelected,
1017
+ indeterminate: selectedCount > 0 && !allLoadedSelected,
1018
+ onChange: () => toggleAll(orderedIds),
1019
+ }
1020
+ : undefined;
1021
+
1022
+ // At most one notice at a time, ranked by how actionable it is: an
1023
+ // in-progress counting/escalated state and its own action always wins;
1024
+ // otherwise a fresh escalation offer; otherwise a just-finished partial
1025
+ // failure's Retry; otherwise the (rare) cross-account move hint.
1026
+ const mobileNotice =
1027
+ escalation.phase.kind === "counting"
1028
+ ? {
1029
+ tone: "info" as const,
1030
+ text: "",
1031
+ action: { label: "Stop", onClick: escalation.stop },
1032
+ }
1033
+ : escalation.phase.kind === "escalated" && !escalation.isDeleting
1034
+ ? {
1035
+ tone: "info" as const,
1036
+ text: "",
1037
+ action: {
1038
+ label: "Clear selection",
1039
+ onClick: handleClearEscalation,
1040
+ },
1041
+ }
1042
+ : escalationAvailable
1043
+ ? {
1044
+ tone: "info" as const,
1045
+ text: "",
1046
+ action: {
1047
+ label: escalationActionLabel(searchPredicate ?? {}),
1048
+ onClick: escalation.escalate,
1049
+ },
1050
+ }
1051
+ : lastRunSucceeded !== null && selectedCount > 0
1052
+ ? {
1053
+ tone: "danger" as const,
1054
+ text: `${formatNumber(lastRunSucceeded)} moved to Trash. ${formatNumber(selectedCount)} couldn't be deleted.`,
1055
+ action: {
1056
+ label: `Retry ${formatNumber(selectedCount)}`,
1057
+ onClick: handleRetryFailed,
1058
+ },
1059
+ }
1060
+ : moveDisabledHint
1061
+ ? { tone: "warning" as const, text: moveDisabledHint }
1062
+ : undefined;
1063
+
796
1064
  // Mobile multi-select bar replaces the pane header during selection mode.
797
1065
  const mobileSelectionBar =
798
1066
  isMultiSelectMode && !isDesktop ? (
799
1067
  <SelectionTopBar
800
- count={selectedCount}
801
- onCancel={handleCancelMultiSelect}
802
- onDelete={handleDelete}
803
- onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
804
- isBusy={isDeleting || isMoving}
805
- notice={
806
- moveDisabledHint
807
- ? { tone: "warning", text: moveDisabledHint }
1068
+ count={mobileCount}
1069
+ onCancel={handleMobileCancel}
1070
+ onDelete={handleMobileDelete}
1071
+ onMarkRead={
1072
+ onMarkAsRead && escalation.phase.kind === "idle"
1073
+ ? handleMarkAsRead
1074
+ : undefined
1075
+ }
1076
+ isBusy={mobileIsBusy}
1077
+ isCounting={escalation.phase.kind === "counting"}
1078
+ statusLabel={mobileStatusLabel}
1079
+ selectAll={mobileSelectAll}
1080
+ progress={
1081
+ escalation.isDeleting && escalation.deleteProgress
1082
+ ? {
1083
+ value: escalation.deleteProgress.done,
1084
+ max: escalation.deleteProgress.total,
1085
+ tone: "danger",
1086
+ }
808
1087
  : undefined
809
1088
  }
1089
+ notice={mobileNotice}
810
1090
  moveSlot={
811
- onMoveMessages && accountId && mailboxId ? (
1091
+ escalation.phase.kind === "idle" &&
1092
+ !escalation.isDeleting &&
1093
+ onMoveMessages &&
1094
+ accountId &&
1095
+ mailboxId ? (
812
1096
  <>
813
1097
  {!moveDisabledHint && (
814
1098
  <button
@@ -847,8 +1131,6 @@ export const MessageList = ({
847
1131
  setFocusedMessageId(messageId);
848
1132
  }, []);
849
1133
 
850
- const isSearching = !!searchQuery?.trim();
851
-
852
1134
  // The virtualized list body: rows + search header + load-more indicator.
853
1135
  // Passed to MessageListPane as `listBody` so the kit provides the chrome
854
1136
  // (pane header, loading / empty / error states, keyboard hints) while we
@@ -880,7 +1162,15 @@ export const MessageList = ({
880
1162
  role="presentation"
881
1163
  data-index={virtualRow.index}
882
1164
  ref={virtualizer.measureElement}
883
- className="absolute left-0 top-0 w-full border-b border-line"
1165
+ className={cn(
1166
+ "absolute left-0 top-0 w-full border-b border-line",
1167
+ // A chunked/escalated delete keeps every targeted row
1168
+ // checked, dimmed and untappable for the whole run (issue
1169
+ // #92) — the number in the bar and the rows underneath
1170
+ // have to agree something is happening, and a row mid
1171
+ // -delete must not be openable.
1172
+ escalation.isDeleting && "pointer-events-none opacity-50",
1173
+ )}
884
1174
  style={{ transform: `translateY(${virtualRow.start}px)` }}
885
1175
  >
886
1176
  <SwipeableMessageRow
@@ -914,8 +1204,24 @@ export const MessageList = ({
914
1204
  </>
915
1205
  );
916
1206
 
1207
+ const pendingDeleteCount = pendingDelete
1208
+ ? pendingDelete.source === "ids"
1209
+ ? pendingDelete.ids.length
1210
+ : pendingDelete.total
1211
+ : 0;
1212
+
917
1213
  return (
918
1214
  <>
1215
+ {completionBanner && !isDesktop && (
1216
+ <Banner
1217
+ tone="success"
1218
+ variant="soft"
1219
+ className="m-2 rounded-md"
1220
+ onDismiss={() => setCompletionBanner(null)}
1221
+ >
1222
+ {completionBanner}
1223
+ </Banner>
1224
+ )}
919
1225
  <MessageListPane
920
1226
  listTitle={listTitle}
921
1227
  listMeta={listMeta}
@@ -942,8 +1248,8 @@ export const MessageList = ({
942
1248
  listBody={listState === "ready" ? virtualBody : undefined}
943
1249
  />
944
1250
  <ConfirmDialog
945
- isOpen={pendingDeleteIds !== null}
946
- title={formatDeleteToTrashTitle(pendingDeleteIds?.length ?? 0)}
1251
+ isOpen={pendingDelete !== null}
1252
+ title={formatDeleteToTrashTitle(pendingDeleteCount)}
947
1253
  description="You can restore them from Trash later."
948
1254
  confirmLabel="Move to Trash"
949
1255
  destructive