@remit/web-client 0.0.92 → 0.0.94

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 (43) 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 +57 -112
  4. package/src/components/mail/FlaggedList.tsx +42 -0
  5. package/src/components/mail/MailListHeader.tsx +41 -39
  6. package/src/components/mail/MessageList.selection.test.ts +10 -5
  7. package/src/components/mail/MessageList.tsx +78 -82
  8. package/src/components/mail/SelectionWizardHost.tsx +721 -64
  9. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  10. package/src/components/mail/make-filter-entry.test.ts +63 -0
  11. package/src/components/settings/FilterEditor.tsx +1 -1
  12. package/src/hooks/useCreateMailbox.ts +16 -4
  13. package/src/hooks/useFilters.ts +30 -1
  14. package/src/hooks/useMatchSample.ts +63 -0
  15. package/src/hooks/useRulePreview.ts +24 -5
  16. package/src/lib/list-header-chrome.ts +10 -0
  17. package/src/lib/mail-context.ts +0 -9
  18. package/src/lib/organize/organize-model.test.ts +110 -0
  19. package/src/lib/organize/organize-model.ts +69 -0
  20. package/src/lib/organize/rule-model.ts +2 -4
  21. package/src/lib/wizard-history.test.ts +31 -0
  22. package/src/lib/wizard-history.ts +65 -2
  23. package/src/routes/mail.tsx +3 -8
  24. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  25. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  26. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  27. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  28. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  29. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  30. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  31. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  32. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  33. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
  34. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  35. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  36. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  37. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  38. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  39. package/src/hooks/useRuleEditorState.ts +0 -182
  40. package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
  41. package/src/hooks/useSearchFilterSeed.ts +0 -79
  42. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  43. 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.92",
3
+ "version": "0.0.94",
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,49 +410,33 @@ 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
-
418
+ // Mounted as the pane's overlay rather than beside the header: the brief hands
419
+ // its body over to the search results panel while a query is being typed, and
420
+ // the wizard is not part of the body it hands over. The overlay slot also puts
421
+ // it inside the list header's chrome, which is where the converted query it
422
+ // opens on comes from.
475
423
  return (
476
- <>
477
- <MailListHeader
478
- {...header}
479
- selectionBar={selectionBar}
480
- paneOverlay={organizeFlow}
481
- >
482
- {children}
483
- </MailListHeader>
484
- {organizeOpen && scope.accountId && (
485
- <OrganizeDialog
486
- open={organizeOpen}
424
+ <MailListHeader
425
+ {...header}
426
+ selectionBar={selectionBar}
427
+ paneOverlay={
428
+ <SelectionWizardHost
429
+ verb={wizardVerb}
487
430
  accountId={scope.accountId}
488
- selectedMessageIds={selectedMessageIds}
489
- selectedSenders={selectedSenders}
490
- onClose={() => setOrganizeOpen(false)}
431
+ mailboxId={scope.mailboxId}
432
+ selection={wizardSelection}
433
+ crossAccount={scope.moveDisabledHint !== undefined}
434
+ onFinished={exitSelection}
491
435
  />
492
- )}
493
- </>
436
+ }
437
+ >
438
+ {children}
439
+ </MailListHeader>
494
440
  );
495
441
  }
496
442
 
@@ -850,7 +796,6 @@ export function DailyBrief({
850
796
  onSelectSearchResult,
851
797
  }}
852
798
  rows={filteredRows}
853
- isDesktop={isDesktop}
854
799
  onMarkMessagesRead={onMarkMessagesRead}
855
800
  >
856
801
  <div className="flex h-full flex-col">
@@ -38,10 +38,15 @@ import { dedupeByThread } from "@/lib/starred-rows";
38
38
  import { MailViewChrome } from "./MailViewChrome";
39
39
  import type { MessageListCommands } from "./MessageList";
40
40
  import { MessageRow } from "./MessageRow";
41
+ import {
42
+ SelectionWizardHost,
43
+ type WizardSelectionMessage,
44
+ } from "./SelectionWizardHost";
41
45
  import {
42
46
  type OpenMessageOptions,
43
47
  ThreadListInteraction,
44
48
  ThreadListSelectionBar,
49
+ useThreadListSelection,
45
50
  } from "./ThreadListInteraction";
46
51
 
47
52
  const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
@@ -49,6 +54,42 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
49
54
  attachment: (t) => t.hasAttachment === true,
50
55
  };
51
56
 
57
+ /**
58
+ * The wizard, on the one surface whose bar borrows it rather than raising it.
59
+ *
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.
67
+ */
68
+ function StarredWizardHost({ rows }: { rows: readonly ThreadRowData[] }) {
69
+ const { selectedIds, exitSelection } = useThreadListSelection();
70
+ const selection = useMemo<WizardSelectionMessage[]>(
71
+ () =>
72
+ rows
73
+ .filter((row) => selectedIds.has(row.id))
74
+ .map((row) => ({
75
+ id: row.id,
76
+ sender: row.fromName,
77
+ email: row.fromEmail,
78
+ subject: row.subject,
79
+ date: row.timeLabel,
80
+ })),
81
+ [rows, selectedIds],
82
+ );
83
+ return (
84
+ <SelectionWizardHost
85
+ verb="organize"
86
+ selection={selection}
87
+ crossAccount
88
+ onFinished={exitSelection}
89
+ />
90
+ );
91
+ }
92
+
52
93
  interface FlaggedListProps {
53
94
  selectedMessageId?: string;
54
95
  onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
@@ -221,6 +262,7 @@ export function FlaggedList({
221
262
  (listState === "ready" ? listBody : undefined)
222
263
  }
223
264
  />
265
+ <StarredWizardHost rows={rows} />
224
266
  </ThreadListInteraction>
225
267
  </MailViewChrome>
226
268
  );
@@ -86,7 +86,7 @@ import {
86
86
  searchTokenLabel,
87
87
  } from "@/lib/search-tokens";
88
88
  import { spamOfferForResults } from "@/lib/spam-offer";
89
- import { SearchFilterDialog } from "./organize/SearchFilterDialog";
89
+ import { useOpenWizard } from "@/lib/wizard-history";
90
90
 
91
91
  export interface MailListHeaderProps {
92
92
  title: string;
@@ -102,8 +102,11 @@ export interface MailListHeaderProps {
102
102
  */
103
103
  selectionBar?: (chrome: ListHeaderChrome) => ReactNode;
104
104
  /**
105
- * An overlay covering the pane, above the list (the guided organize flow).
106
- * The pane is the positioned ancestor it measures against.
105
+ * An overlay covering the pane, above the list (the selection wizard). Kept
106
+ * out of the body and rendered on both the ordinary layout and the phone
107
+ * search takeover, so a surface that covers the screen is not taken down by
108
+ * whatever the body swapped to underneath it. Inside the chrome provider, so
109
+ * an overlay a view mounts from above still reads the search state.
107
110
  */
108
111
  paneOverlay?: ReactNode;
109
112
  /** Pinned below the scrollable list (e.g. the keyboard hint bar). */
@@ -169,7 +172,7 @@ export function MailListHeader({
169
172
  const tier = useLayoutTier();
170
173
  const [searchOpen, setSearchOpen] = useState(false);
171
174
  const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
172
- const [filterOpen, setFilterOpen] = useState(false);
175
+ const openWizard = useOpenWizard();
173
176
 
174
177
  // Leaving the view ends the search: the shell drops the query, and the chrome
175
178
  // it opened — the phone takeover, the expanded tablet field — closes with it
@@ -320,14 +323,15 @@ export function MailListHeader({
320
323
  }
321
324
  : routeScope;
322
325
 
323
- // Make-this-a-filter (RFC 038 D5): convert the current search to a pre-filled
324
- // rule and open the shared chip editor. The filter is created for the account
325
- // an `account:` facet names, else the primary account. The literal filter
326
- // cannot reproduce the search's semantic reach, so the conversion states it
327
- // whenever the search surfaced a "Related" section a direct signal, read
328
- // here from the semantic results, never a capability probe. Disabled with a
329
- // reason when the search has no clause to filter on (only non-clause facets,
330
- // or a bare folder scope).
326
+ // Make-this-a-filter (#477 clause 1.8): the search is the wizard's second
327
+ // entry. It opens on the properties step with the clauses `convertSearchToRule`
328
+ // derives from the query, nothing ticked, and the notice for what the query
329
+ // could not be turned into. The conversion is computed once, here, and handed
330
+ // to the wizard through the chromethe reason the affordance gives and the
331
+ // clauses the wizard seeds from are the same answer. The literal filter cannot
332
+ // reproduce the search's semantic reach, so the conversion states it whenever
333
+ // the search surfaced a "Related" section — a direct signal, read here from
334
+ // the semantic results, never a capability probe.
331
335
  //
332
336
  // It belongs to the search, not to any one way of showing it. The affordance
333
337
  // therefore sits in the pane, above whichever body is up — the read-only
@@ -338,35 +342,32 @@ export function MailListHeader({
338
342
  // the URL, taking the affordance down with it a few hundred milliseconds after
339
343
  // it appeared, and leaving the brief (which keeps the panel for any query) as
340
344
  // the only place it survived.
341
- const accountToken = parsed.tokens.find((token) => token.type === "account");
342
- const targetAccountId = accountToken?.accountId ?? accounts[0]?.accountId;
343
345
  const searchHadSemanticReach = related.length > 0;
344
- const makeFilter =
345
- hasQuery && targetAccountId
346
- ? {
347
- onClick: () => setFilterOpen(true),
348
- disabledReason: isConvertible(
349
- convertSearchToRule(parsed, { searchHadSemanticReach }),
350
- )
351
- ? undefined
352
- : "Add a sender or words to filter on",
353
- }
354
- : undefined;
346
+ const conversion = useMemo(
347
+ () => convertSearchToRule(parsed, { searchHadSemanticReach }),
348
+ [parsed, searchHadSemanticReach],
349
+ );
350
+ const searchConversion = hasQuery ? conversion : undefined;
351
+ const makeFilter = hasQuery
352
+ ? {
353
+ // The wizard is a full-screen surface, so the phone takeover it was
354
+ // pressed from stands down rather than sitting under it — and the list
355
+ // underneath, which hosts the wizard, is mounted again by the time the
356
+ // step lands.
357
+ onClick: () => {
358
+ setSearchOpen(false);
359
+ openWizard("properties", "search");
360
+ },
361
+ blockedReason: isConvertible(conversion)
362
+ ? undefined
363
+ : "Add a sender or words to filter on",
364
+ }
365
+ : undefined;
355
366
  // Handed to the bar rather than rendered here: the bar knows whether rows
356
367
  // are ticked, and a selection's own verbs own the surface while they are up.
357
368
  const makeFilterAction = makeFilter ? (
358
369
  <MakeFilterAction {...makeFilter} />
359
370
  ) : null;
360
- const filterDialog =
361
- filterOpen && targetAccountId ? (
362
- <SearchFilterDialog
363
- open={filterOpen}
364
- accountId={targetAccountId}
365
- parsed={parsed}
366
- searchHadSemanticReach={searchHadSemanticReach}
367
- onClose={() => setFilterOpen(false)}
368
- />
369
- ) : null;
370
371
 
371
372
  // Tablet + desktop keep the inline toolbar search; while a query is being
372
373
  // typed the list-pane body swaps to the same sectioned results the phone
@@ -421,6 +422,7 @@ export function MailListHeader({
421
422
  title,
422
423
  searchResults: chromeResults,
423
424
  makeFilterSlot: makeFilterAction,
425
+ searchConversion,
424
426
  navSlot: layout && !layout.showNavPane && (
425
427
  <Button
426
428
  variant="ghost"
@@ -484,6 +486,7 @@ export function MailListHeader({
484
486
  searchSuggest,
485
487
  chromeResults,
486
488
  makeFilterAction,
489
+ searchConversion,
487
490
  ],
488
491
  );
489
492
 
@@ -494,7 +497,7 @@ export function MailListHeader({
494
497
  onSelectSearchResult?.(result);
495
498
  };
496
499
  return (
497
- <>
500
+ <ListHeaderChromeContext.Provider value={chrome}>
498
501
  <MobileSearchView
499
502
  value={searchInput}
500
503
  onChange={onSearchChange}
@@ -515,8 +518,8 @@ export function MailListHeader({
515
518
  suggest={searchSuggest}
516
519
  suggestList={suggestList}
517
520
  />
518
- {filterDialog}
519
- </>
521
+ {paneOverlay}
522
+ </ListHeaderChromeContext.Provider>
520
523
  );
521
524
  }
522
525
 
@@ -527,7 +530,6 @@ export function MailListHeader({
527
530
  {suggestList}
528
531
  <div className="min-h-0 flex-1">{body}</div>
529
532
  {footer}
530
- {filterDialog}
531
533
  {paneOverlay}
532
534
  </section>
533
535
  </ListHeaderChromeContext.Provider>
@@ -78,8 +78,9 @@ describe("MessageList escalated actions", () => {
78
78
 
79
79
  it("keeps mark-read and the move slot on an escalated selection", () => {
80
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.
82
- 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*\}/);
83
84
  // The move slot is offered for a bounded selection or an escalated one —
84
85
  // #114's rule that an escalated selection is never delete-only.
85
86
  assert.match(
@@ -142,9 +143,13 @@ describe("MessageList escalation reaches desktop (#212)", () => {
142
143
  assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
143
144
  });
144
145
 
145
- it("keeps the free-text organize entry reachable, in the overflow", () => {
146
- assert.match(source, /onSomethingElse=\{/);
147
- 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/);
148
153
  });
149
154
 
150
155
  it("offers the label picker only when the account has labels", () => {