@remit/web-client 0.0.94 → 0.0.96

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.94",
3
+ "version": "0.0.96",
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,6 +47,5 @@ describe("BriefPane phone selection surface (#203)", () => {
47
47
  const next = source.indexOf("\nfunction ", start + 1);
48
48
  const body = source.slice(start, next === -1 ? undefined : next);
49
49
  assert.match(body, /onDeleteMessages=\{onDeleteMessages\}/);
50
- assert.match(body, /onMarkMessagesRead=\{onMarkMessagesRead\}/);
51
50
  });
52
51
  });
@@ -68,7 +68,6 @@ interface BriefPaneContextValue {
68
68
  /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
69
69
  triage: TriageContext;
70
70
  onDeleteMessages: (messageIds: string[]) => void;
71
- onMarkMessagesRead: (messageIds: string[]) => void;
72
71
  nextMessageId: string | undefined;
73
72
  previousMessageId: string | undefined;
74
73
  }
@@ -206,10 +205,6 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
206
205
  mailboxId: selectedThread?.mailboxId ?? "",
207
206
  messages: briefThreads,
208
207
  });
209
- const handleMarkMessagesRead = useCallback(
210
- (messageIds: string[]) => toggleReadFor(messageIds, true),
211
- [toggleReadFor],
212
- );
213
208
 
214
209
  const focusedThreadId = triage.focusedMessageId;
215
210
  const focusedThread = useMemo(
@@ -228,20 +223,18 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
228
223
  reply: () => actions.requestCompose("reply"),
229
224
  replyAll: () => actions.requestCompose("reply_all"),
230
225
  forward: () => actions.requestCompose("forward"),
226
+ // The list takes any verb aimed at its selection and opens the wizard
227
+ // on it, so a shortcut can never reach a bulk action the bar would have
228
+ // reviewed. What comes back here is a verb aimed at the bare cursor.
231
229
  delete: () => {
232
- if (triage.listCommandsRef.current?.requestDelete()) return;
230
+ if (triage.listCommandsRef.current?.requestVerb("delete")) return;
233
231
  triageActions.deleteThread();
234
232
  },
235
233
  toggleStar: triageActions.toggleStar,
236
234
  toggleRead: () => {
237
- const ids =
238
- triage.selectedIds.length > 0
239
- ? triage.selectedIds
240
- : triageTarget
241
- ? [triageTarget.messageId]
242
- : [];
243
- if (ids.length === 0) return;
244
- toggleReadFor(ids, !(triageTarget?.isRead ?? false));
235
+ if (triage.listCommandsRef.current?.requestVerb("markRead")) return;
236
+ if (!triageTarget) return;
237
+ toggleReadFor([triageTarget.messageId], !triageTarget.isRead);
245
238
  },
246
239
  goFlagged: () => navigate({ to: "/mail/flagged" }),
247
240
  goSettings: () => navigate({ to: "/settings" }),
@@ -258,7 +251,6 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
258
251
  actions,
259
252
  triage,
260
253
  onDeleteMessages: deleteMessages,
261
- onMarkMessagesRead: handleMarkMessagesRead,
262
254
  nextMessageId,
263
255
  previousMessageId,
264
256
  };
@@ -280,7 +272,6 @@ function BriefList() {
280
272
  onSelectSearchResult,
281
273
  triage,
282
274
  onDeleteMessages,
283
- onMarkMessagesRead,
284
275
  } = useBriefPane();
285
276
  const { accounts } = useMailContext();
286
277
 
@@ -293,7 +284,6 @@ function BriefList() {
293
284
  commandsRef={triage.listCommandsRef}
294
285
  onTriageContextChange={triage.onTriageContextChange}
295
286
  onDeleteMessages={onDeleteMessages}
296
- onMarkMessagesRead={onMarkMessagesRead}
297
287
  />
298
288
  );
299
289
  }
@@ -47,11 +47,29 @@ describe("the brief's select-all", () => {
47
47
  );
48
48
  });
49
49
 
50
- it("sends every verb on the bar into the wizard (#477 1.4)", () => {
50
+ it("carries every verb the bar can offer (#477 1.4)", () => {
51
+ // That each of them opens the wizard is `selection-verbs.test.ts`, over
52
+ // every surface at once. What this pins is that none of them was dropped
53
+ // from the bar the brief raises.
51
54
  const body = chromeBody();
52
- for (const verb of ["delete", "move", "junk", "markRead", "organize"]) {
53
- assert.match(body, new RegExp(`startWizard\\("${verb}"\\)`));
55
+ for (const prop of [
56
+ "onDelete",
57
+ "onMove",
58
+ "onOrganize",
59
+ "onJunk",
60
+ "onMarkRead",
61
+ ]) {
62
+ assert.match(body, new RegExp(`${prop}=`));
54
63
  }
55
64
  assert.doesNotMatch(body, /onSomethingElse/);
56
65
  });
66
+
67
+ it("drives the wizard and the keyboard layer from one control", () => {
68
+ // Two copies of "which verb is the wizard on" is how the brief came to
69
+ // offer a verb the mailbox list did not, and how its keyboard came to act
70
+ // behind a wizard the bar had opened.
71
+ assert.match(source, /const wizard = useSelectionWizard\(\);/);
72
+ assert.match(source, /onSelectionVerb={wizard.start}/);
73
+ assert.match(source, /wizardOpen={wizard.isOpen}/);
74
+ });
57
75
  });
@@ -46,7 +46,6 @@ import {
46
46
  SelectionTopBar,
47
47
  type ThreadRowData,
48
48
  type ThreadSection,
49
- type Verb,
50
49
  } from "@remit/ui";
51
50
  import { useQueries, useQuery } from "@tanstack/react-query";
52
51
  import { useNavigate } from "@tanstack/react-router";
@@ -82,7 +81,10 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
82
81
  import { useMailContext } from "@/lib/mail-context";
83
82
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
84
83
  import { parseSearchTokens } from "@/lib/search-tokens";
85
- import { useOpenWizard } from "@/lib/wizard-history";
84
+ import {
85
+ type SelectionWizardControl,
86
+ useSelectionWizard,
87
+ } from "@/lib/wizard-history";
86
88
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
87
89
  import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
88
90
  import type { MessageListCommands } from "./MessageList";
@@ -294,7 +296,8 @@ interface BriefSelectionChromeProps {
294
296
  >;
295
297
  /** The rows the list is showing, for resolving the selection's scope. */
296
298
  rows: readonly ThreadRowData[];
297
- onMarkMessagesRead?: (messageIds: string[]) => void;
299
+ /** The wizard every verb on this bar opens, shared with the keyboard layer. */
300
+ wizard: SelectionWizardControl;
298
301
  children: ReactNode;
299
302
  }
300
303
 
@@ -307,7 +310,7 @@ interface BriefSelectionChromeProps {
307
310
  function BriefSelectionChrome({
308
311
  header,
309
312
  rows,
310
- onMarkMessagesRead,
313
+ wizard,
311
314
  children,
312
315
  }: BriefSelectionChromeProps) {
313
316
  const {
@@ -326,18 +329,6 @@ function BriefSelectionChrome({
326
329
  const { junkMailboxId } = useJunkMailbox(scope.accountId);
327
330
  const { labels } = useLabelList(scope.accountId);
328
331
 
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
- );
340
-
341
332
  const selectedMessageIds = useMemo(
342
333
  () => Array.from(selectedIds),
343
334
  [selectedIds],
@@ -391,13 +382,11 @@ function BriefSelectionChrome({
391
382
  idleSlot={chrome.makeFilterSlot}
392
383
  count={selectedCount}
393
384
  onCancel={exitSelection}
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
400
- }
385
+ onDelete={() => wizard.start("delete")}
386
+ onMove={() => wizard.start("move")}
387
+ onOrganize={() => wizard.start("organize")}
388
+ onJunk={canJunk ? () => wizard.start("junk") : undefined}
389
+ onMarkRead={() => wizard.start("markRead")}
401
390
  overflowSlot={
402
391
  scope.accountId &&
403
392
  scope.mailboxId &&
@@ -426,7 +415,7 @@ function BriefSelectionChrome({
426
415
  selectionBar={selectionBar}
427
416
  paneOverlay={
428
417
  <SelectionWizardHost
429
- verb={wizardVerb}
418
+ verb={wizard.verb}
430
419
  accountId={scope.accountId}
431
420
  mailboxId={scope.mailboxId}
432
421
  selection={wizardSelection}
@@ -459,7 +448,6 @@ interface DailyBriefProps {
459
448
  /** Cursor / selection / display order, reported up to the triage layer. */
460
449
  onTriageContextChange?: (context: TriageContextUpdate) => void;
461
450
  onDeleteMessages: (messageIds: string[]) => void;
462
- onMarkMessagesRead?: (messageIds: string[]) => void;
463
451
  }
464
452
 
465
453
  export function DailyBrief({
@@ -470,11 +458,11 @@ export function DailyBrief({
470
458
  commandsRef,
471
459
  onTriageContextChange,
472
460
  onDeleteMessages,
473
- onMarkMessagesRead,
474
461
  }: DailyBriefProps) {
475
462
  const { searchQuery, resultFolderIndex } = useMailContext();
476
463
  const tokenContext = useSearchTokenContext();
477
464
  const isDesktop = useIsDesktop();
465
+ const wizard = useSelectionWizard();
478
466
 
479
467
  const nonMuted = useMemo(
480
468
  () => sortAccountsByCreatedAt(accounts.filter((a) => !a.muted?.value)),
@@ -780,10 +768,13 @@ export function DailyBrief({
780
768
  selectedMessageId={selectedMessageId}
781
769
  onOpen={(id, options) => onSelectMessage?.(id, options)}
782
770
  onDeleteMessages={onDeleteMessages}
771
+ onSelectionVerb={wizard.start}
772
+ wizardOpen={wizard.isOpen}
783
773
  commandsRef={commandsRef}
784
774
  onTriageContextChange={onTriageContextChange}
785
775
  >
786
776
  <BriefSelectionChrome
777
+ wizard={wizard}
787
778
  header={{
788
779
  title: "Daily brief",
789
780
  unreadCount: totalUnseen,
@@ -796,7 +787,6 @@ export function DailyBrief({
796
787
  onSelectSearchResult,
797
788
  }}
798
789
  rows={filteredRows}
799
- onMarkMessagesRead={onMarkMessagesRead}
800
790
  >
801
791
  <div className="flex h-full flex-col">
802
792
  {failedAccounts.map((account) => (
@@ -17,6 +17,7 @@ import {
17
17
  flaggedFilterConfig,
18
18
  MessageListPane,
19
19
  type ThreadRowData,
20
+ type Verb,
20
21
  } from "@remit/ui";
21
22
  import { type RefObject, useCallback, useMemo, useState } from "react";
22
23
  import { formatErrorMessage } from "@/components/ui/ErrorState";
@@ -35,6 +36,7 @@ import { useMailContext } from "@/lib/mail-context";
35
36
  import { rowToSearchResult } from "@/lib/search-result";
36
37
  import { parseSearchTokens } from "@/lib/search-tokens";
37
38
  import { dedupeByThread } from "@/lib/starred-rows";
39
+ import { useSelectionWizard } from "@/lib/wizard-history";
38
40
  import { MailViewChrome } from "./MailViewChrome";
39
41
  import type { MessageListCommands } from "./MessageList";
40
42
  import { MessageRow } from "./MessageRow";
@@ -55,17 +57,24 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
55
57
  };
56
58
 
57
59
  /**
58
- * The wizard, on the one surface whose bar borrows it rather than raising it.
60
+ * The wizard this view's verbs walk, and the one its search entry lands on.
59
61
  *
60
- * The list header offers "make this a filter" wherever a search is active, and
61
- * the step that affordance pushes has to be answered on the view that offered
62
- * it an affordance whose press lands on nothing is the dead button clause 1.7
63
- * exists to prevent. Starred spans accounts and mailboxes, so a rule made from
64
- * the ticked rows has no single account to belong to; a rule made from the
65
- * query belongs to the account the query names, which the host reads for
66
- * itself.
62
+ * The bar's Delete and Mark read open it on the ticked rows, so a bulk action
63
+ * here is reviewed exactly as it is on the mailbox list. The list header also
64
+ * offers "make this a filter" wherever a search is active, and the step that
65
+ * affordance pushes has to be answered on the view that offered it an
66
+ * affordance whose press lands on nothing is the dead button clause 1.7 exists
67
+ * to prevent. Starred spans accounts and mailboxes, so a rule made from the
68
+ * ticked rows has no single account to belong to; a rule made from the query
69
+ * belongs to the account the query names, which the host reads for itself.
67
70
  */
68
- function StarredWizardHost({ rows }: { rows: readonly ThreadRowData[] }) {
71
+ function StarredWizardHost({
72
+ rows,
73
+ verb,
74
+ }: {
75
+ rows: readonly ThreadRowData[];
76
+ verb: Verb;
77
+ }) {
69
78
  const { selectedIds, exitSelection } = useThreadListSelection();
70
79
  const selection = useMemo<WizardSelectionMessage[]>(
71
80
  () =>
@@ -82,7 +91,7 @@ function StarredWizardHost({ rows }: { rows: readonly ThreadRowData[] }) {
82
91
  );
83
92
  return (
84
93
  <SelectionWizardHost
85
- verb="organize"
94
+ verb={verb}
86
95
  selection={selection}
87
96
  crossAccount
88
97
  onFinished={exitSelection}
@@ -98,7 +107,6 @@ interface FlaggedListProps {
98
107
  /** Cursor / selection / display order, reported up to the triage layer. */
99
108
  onTriageContextChange?: (context: TriageContextUpdate) => void;
100
109
  onDeleteMessages: (messageIds: string[]) => void;
101
- onMarkMessagesRead?: (messageIds: string[]) => void;
102
110
  }
103
111
 
104
112
  export function FlaggedList({
@@ -107,11 +115,11 @@ export function FlaggedList({
107
115
  commandsRef,
108
116
  onTriageContextChange,
109
117
  onDeleteMessages,
110
- onMarkMessagesRead,
111
118
  }: FlaggedListProps) {
112
119
  const { searchQuery, resultFolderIndex } = useMailContext();
113
120
  const tokenContext = useSearchTokenContext();
114
121
  const isDesktop = useIsDesktop();
122
+ const wizard = useSelectionWizard();
115
123
 
116
124
  const [selectedCategory, setSelectedCategory] = useState("all");
117
125
  const [activeFilters, setActiveFilters] = useState<ReadonlySet<string>>(
@@ -234,6 +242,8 @@ export function FlaggedList({
234
242
  selectedMessageId={selectedMessageId}
235
243
  onOpen={(id, options) => onSelectMessage?.(id, options)}
236
244
  onDeleteMessages={onDeleteMessages}
245
+ onSelectionVerb={wizard.start}
246
+ wizardOpen={wizard.isOpen}
237
247
  commandsRef={commandsRef}
238
248
  onTriageContextChange={onTriageContextChange}
239
249
  >
@@ -251,18 +261,13 @@ export function FlaggedList({
251
261
  onSelectThread={onSelectMessage}
252
262
  onSelectBriefCategory={() => undefined}
253
263
  isDesktop={isDesktop}
254
- selectionBar={
255
- <ThreadListSelectionBar
256
- title="Starred"
257
- onMarkAsRead={onMarkMessagesRead}
258
- />
259
- }
264
+ selectionBar={<ThreadListSelectionBar title="Starred" />}
260
265
  listBody={
261
266
  chrome.searchResults ??
262
267
  (listState === "ready" ? listBody : undefined)
263
268
  }
264
269
  />
265
- <StarredWizardHost rows={rows} />
270
+ <StarredWizardHost rows={rows} verb={wizard.verb} />
266
271
  </ThreadListInteraction>
267
272
  </MailViewChrome>
268
273
  );
@@ -64,7 +64,6 @@ interface FlaggedPaneContextValue {
64
64
  /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
65
65
  triage: TriageContext;
66
66
  onDeleteMessages: (messageIds: string[]) => void;
67
- onMarkMessagesRead: (messageIds: string[]) => void;
68
67
  nextMessageId: string | undefined;
69
68
  previousMessageId: string | undefined;
70
69
  }
@@ -145,10 +144,6 @@ function FlaggedPaneProvider({
145
144
  mailboxId: selectedThread?.mailboxId ?? "",
146
145
  messages: threads,
147
146
  });
148
- const handleMarkMessagesRead = useCallback(
149
- (messageIds: string[]) => toggleReadFor(messageIds, true),
150
- [toggleReadFor],
151
- );
152
147
 
153
148
  const focusedThreadId = triage.focusedMessageId;
154
149
  const focusedThread = useMemo(
@@ -167,20 +162,18 @@ function FlaggedPaneProvider({
167
162
  reply: () => actions.requestCompose("reply"),
168
163
  replyAll: () => actions.requestCompose("reply_all"),
169
164
  forward: () => actions.requestCompose("forward"),
165
+ // The list takes any verb aimed at its selection and opens the wizard
166
+ // on it, so a shortcut can never reach a bulk action the bar would have
167
+ // reviewed. What comes back here is a verb aimed at the bare cursor.
170
168
  delete: () => {
171
- if (triage.listCommandsRef.current?.requestDelete()) return;
169
+ if (triage.listCommandsRef.current?.requestVerb("delete")) return;
172
170
  triageActions.deleteThread();
173
171
  },
174
172
  toggleStar: triageActions.toggleStar,
175
173
  toggleRead: () => {
176
- const ids =
177
- triage.selectedIds.length > 0
178
- ? triage.selectedIds
179
- : triageTarget
180
- ? [triageTarget.messageId]
181
- : [];
182
- if (ids.length === 0) return;
183
- toggleReadFor(ids, !(triageTarget?.isRead ?? false));
174
+ if (triage.listCommandsRef.current?.requestVerb("markRead")) return;
175
+ if (!triageTarget) return;
176
+ toggleReadFor([triageTarget.messageId], !triageTarget.isRead);
184
177
  },
185
178
  goBrief: () => navigate({ to: "/mail" }),
186
179
  goSettings: () => navigate({ to: "/settings" }),
@@ -195,7 +188,6 @@ function FlaggedPaneProvider({
195
188
  actions,
196
189
  triage,
197
190
  onDeleteMessages: deleteMessages,
198
- onMarkMessagesRead: handleMarkMessagesRead,
199
191
  nextMessageId,
200
192
  previousMessageId,
201
193
  };
@@ -211,13 +203,8 @@ function FlaggedPaneProvider({
211
203
 
212
204
  /** Flat starred list. Mount in the `list` slot of `AppShellSlotted`. */
213
205
  function FlaggedListSlot() {
214
- const {
215
- selectedMessageId,
216
- onSelectMessage,
217
- triage,
218
- onDeleteMessages,
219
- onMarkMessagesRead,
220
- } = useFlaggedPane();
206
+ const { selectedMessageId, onSelectMessage, triage, onDeleteMessages } =
207
+ useFlaggedPane();
221
208
  return (
222
209
  <FlaggedList
223
210
  selectedMessageId={selectedMessageId}
@@ -225,7 +212,6 @@ function FlaggedListSlot() {
225
212
  commandsRef={triage.listCommandsRef}
226
213
  onTriageContextChange={triage.onTriageContextChange}
227
214
  onDeleteMessages={onDeleteMessages}
228
- onMarkMessagesRead={onMarkMessagesRead}
229
215
  />
230
216
  );
231
217
  }
@@ -695,21 +695,24 @@ function MailboxPaneProvider({
695
695
  if (selectedThread) setToolbarComposeRequest("forward");
696
696
  }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
697
697
 
698
- const triageTargetMessageIds = useCallback((): string[] => {
699
- if (triageSelectedIds.length > 0) return triageSelectedIds;
700
- return messageIdsForFocusedThread(focusedThread);
701
- }, [triageSelectedIds, messageIdsForFocusedThread, focusedThread]);
702
-
703
- // Prefer the list's delete: it confirms the move to Trash and then places the
704
- // cursor on a surviving row. Falls back to the reading pane's message when no
705
- // list is mounted or nothing in it is targeted.
698
+ const triageTargetMessageIds = useCallback(
699
+ (): string[] => messageIdsForFocusedThread(focusedThread),
700
+ [messageIdsForFocusedThread, focusedThread],
701
+ );
702
+
703
+ // Every verb the list can take, it takes: over a selection it opens the
704
+ // wizard, which is where a bulk action is reviewed before it reaches the mail
705
+ // server (#477 1.4, #508). What falls through is aimed at the bare cursor, or
706
+ // at the reading pane when no list is mounted — one message, not a bulk
707
+ // action, and the pane acts on it directly.
706
708
  const triageDeleteAction = useCallback(() => {
707
- if (listCommandsRef.current?.requestDelete()) return;
709
+ if (listCommandsRef.current?.requestVerb("delete")) return;
708
710
  const ids = triageTargetMessageIds();
709
711
  if (ids.length > 0) triageDelete(ids);
710
712
  }, [listCommandsRef, triageTargetMessageIds, triageDelete]);
711
713
 
712
714
  const triageMarkJunk = useCallback(() => {
715
+ if (listCommandsRef.current?.requestVerb("junk")) return;
713
716
  if (!junkMailboxId) return;
714
717
  const ids = triageTargetMessageIds();
715
718
  if (ids.length === 0) return;
@@ -720,6 +723,7 @@ function MailboxPaneProvider({
720
723
  });
721
724
  triageMove(ids, junkMailboxId);
722
725
  }, [
726
+ listCommandsRef,
723
727
  junkMailboxId,
724
728
  triageTargetMessageIds,
725
729
  triageMove,
@@ -727,6 +731,10 @@ function MailboxPaneProvider({
727
731
  focusedThread,
728
732
  ]);
729
733
 
734
+ // Star is the one verb that acts on a selection from here. It is not on the
735
+ // selection bar and not one of the five the wizard walks: it sets a flag on
736
+ // mail that is already in front of the user and unsets it the same way, so
737
+ // there is nothing for a review screen to name and nothing to undo.
730
738
  const triageStar = useCallback(() => {
731
739
  if (triageSelectedIds.length > 0) {
732
740
  const nextStarred = !(focusedThread?.hasStars ?? false);
@@ -743,11 +751,17 @@ function MailboxPaneProvider({
743
751
  }, [triageSelectedIds, threads, focusedThread, focusedToggleStar]);
744
752
 
745
753
  const triageToggleRead = useCallback(() => {
754
+ if (listCommandsRef.current?.requestVerb("markRead")) return;
746
755
  const ids = triageTargetMessageIds();
747
756
  if (ids.length === 0) return;
748
757
  const nextRead = !(focusedThread?.isRead ?? false);
749
758
  triageToggleReadFor(ids, nextRead);
750
- }, [triageTargetMessageIds, focusedThread, triageToggleReadFor]);
759
+ }, [
760
+ listCommandsRef,
761
+ triageTargetMessageIds,
762
+ focusedThread,
763
+ triageToggleReadFor,
764
+ ]);
751
765
 
752
766
  const triageMute = useCallback(() => {
753
767
  if (!focusedAddressId) return;
@@ -1013,7 +1027,6 @@ function MailboxList() {
1013
1027
  searchQuery={searchQuery}
1014
1028
  searchPredicate={searchPredicate}
1015
1029
  onDeleteMessages={onDeleteMessages}
1016
- onMoveMessages={onMoveMessages}
1017
1030
  isDeleting={isDeleting}
1018
1031
  isMoving={isMoving}
1019
1032
  onLoadMore={onLoadMore}
@@ -59,45 +59,43 @@ describe("MessageList selection mode", () => {
59
59
  });
60
60
 
61
61
  /**
62
- * An escalated selection is a predicate, so it has no id list to hand the
63
- * optimistic move/mark-read mutations before #114 the bar simply dropped
64
- * both, leaving "select all 1,284 matching npm" able to delete and nothing
65
- * else. All three actions now page the predicate through the same run.
62
+ * An escalated selection is a predicate, and it walks the same wizard every
63
+ * other selection walks (#508). The list hands the wizard the predicate — the
64
+ * words it names it with, the count it was escalated to, and the chunked runner
65
+ * that re-resolves it and keeps no second confirmation of its own.
66
66
  */
67
67
  describe("MessageList escalated actions", () => {
68
- it("routes move and mark-read through the predicate run when escalated", () => {
68
+ it("hands the escalated predicate to the wizard rather than running it here", () => {
69
69
  assert.match(
70
70
  source,
71
- /escalation\.phase\.kind === "escalated"\)\s*\{\s*void runEscalatedAction\(MARK_READ_ACTION\);/,
71
+ /const escalatedSelection: EscalatedSelection \| undefined =\s*escalation\.phase\.kind === "escalated"/,
72
72
  );
73
73
  assert.match(
74
74
  source,
75
- /escalation\.phase\.kind === "escalated"\)\s*\{\s*void runEscalatedAction\(\{ kind: "move", destinationMailboxId \}\);/,
75
+ /scope: describeSearchScope\(searchPredicate \?\? \{\}\)/,
76
76
  );
77
- });
78
-
79
- it("keeps mark-read and the move slot on an escalated selection", () => {
80
- // The bar always carries the mark-read verb (it drops it itself while
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*\}/);
84
- // The move slot is offered for a bounded selection or an escalated one —
85
- // #114's rule that an escalated selection is never delete-only.
77
+ assert.match(source, /total: escalation\.phase\.total/);
86
78
  assert.match(
87
79
  source,
88
- /onMoveMessages \|\| escalation\.phase\.kind === "escalated"/,
80
+ /run: \(action: EscalatedAction\) => escalation\.runAction\(action\)/,
89
81
  );
82
+ const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
83
+ assert.match(host, /escalated=\{escalatedSelection\}/);
90
84
  });
91
85
 
92
- it("words progress and completion per action instead of per delete", () => {
86
+ it("keeps no predicate confirmation of its own", () => {
87
+ // The review screen names what the predicate covers; a second dialog
88
+ // asking the same question is the drift the wizard exists to end.
89
+ assert.doesNotMatch(source, /source: "predicate"/);
90
+ assert.doesNotMatch(source, /requestEscalatedDelete/);
91
+ assert.doesNotMatch(source, /pendingDeleteIsEstimate/);
92
+ });
93
+
94
+ it("words the bar's progress per action instead of per delete", () => {
93
95
  assert.match(
94
96
  source,
95
97
  /bulkActionProgressLabel\(\s*escalation\.runningAction\.kind,/,
96
98
  );
97
- assert.match(
98
- source,
99
- /bulkActionCompletionText\(action\.kind, outcome\.done\)/,
100
- );
101
99
  });
102
100
  });
103
101
 
@@ -143,29 +141,45 @@ describe("MessageList escalation reaches desktop (#212)", () => {
143
141
  assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
144
142
  });
145
143
 
146
- it("sends every verb on the bar into the wizard (#477 1.4)", () => {
144
+ it("carries every verb the bar can offer (#477 1.4)", () => {
145
+ // That each of them opens the wizard is `selection-verbs.test.ts`, over
146
+ // every surface at once. What this pins is that none of them was dropped
147
+ // from the one bar the mailbox raises.
147
148
  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}"\\)`));
149
+ for (const prop of [
150
+ "onDelete",
151
+ "onMove",
152
+ "onOrganize",
153
+ "onJunk",
154
+ "onMarkRead",
155
+ ]) {
156
+ assert.match(bar, new RegExp(`${prop}=`));
150
157
  }
151
158
  // The one selection surface has no second organize entry beside them.
152
159
  assert.doesNotMatch(source, /onSomethingElse/);
153
160
  });
154
161
 
162
+ it("keeps Organize reachable over a predicate rather than dropping it", () => {
163
+ // A verb that vanishes when a selection escalates leaves the user no route
164
+ // at all: the make-filter affordance it defers to is hidden while rows are
165
+ // ticked (#477 1.7).
166
+ assert.match(source, /if \(escalatedSelection\) \{\s*startFromSearch\(\);/);
167
+ });
168
+
155
169
  it("offers the label picker only when the account has labels", () => {
156
170
  assert.match(source, /labels\.length > 0 \? \(/);
157
171
  });
158
172
  });
159
173
 
160
174
  /**
161
- * A bounded confirm-delete used to open the surviving neighbour by writing
175
+ * A confirmed delete used to open the surviving neighbour by writing
162
176
  * `selectedMessageId` into the URL. On desktop that fills the reading pane
163
177
  * beside the list; on a single-pane mobile layout the same navigation replaced
164
- * the list with a full-screen message, so a bulk delete read as "jumped into a
165
- * random message" rather than "the rows are gone" (#202). Mobile now stays on
166
- * the list and raises the same completion banner a chunked run does.
178
+ * the list with a full-screen message, so the delete read as "jumped into a
179
+ * random message" rather than "the row is gone" (#202). Mobile now stays on the
180
+ * list and raises a completion banner instead.
167
181
  */
168
- describe("MessageList bounded delete stays on the list on mobile (#202)", () => {
182
+ describe("MessageList confirmed delete stays on the list on mobile (#202)", () => {
169
183
  it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
170
184
  assert.match(
171
185
  source,