@remit/web-client 0.0.90 → 0.0.92

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.
@@ -5,8 +5,7 @@ import {
5
5
  type MessageListFilter,
6
6
  MessageListLoadingMore,
7
7
  MessageListPane,
8
- SELECTION_SHEET_TEASER_HEIGHT,
9
- SelectionSheet,
8
+ SelectionTopBar,
10
9
  } from "@remit/ui";
11
10
  import { useBlocker, useNavigate } from "@tanstack/react-router";
12
11
  import { useVirtualizer } from "@tanstack/react-virtual";
@@ -22,6 +21,7 @@ import {
22
21
  useEscalatedActions,
23
22
  } from "@/hooks/useEscalatedActions";
24
23
  import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
24
+ import { useLabelList } from "@/hooks/useLabels";
25
25
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
26
26
  import { useIsDesktop } from "@/hooks/useMediaQuery";
27
27
  import {
@@ -46,19 +46,17 @@ import {
46
46
  } from "@/lib/escalation-label";
47
47
  import { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
48
48
  import { tabStopId } from "@/lib/list-focus";
49
+ import { useListHeaderChrome } from "@/lib/list-header-chrome";
49
50
  import {
50
51
  deriveIsMultiSelectMode,
51
52
  shouldExitSelectionOnNavigate,
52
53
  } from "@/lib/selection-mode";
53
- import {
54
- resolveSelectionSheetMode,
55
- shouldShowSelectionSheet,
56
- } from "@/lib/selection-sheet-mode";
57
54
  import { cn } from "@/lib/utils";
55
+ import { useWizardStepValue } from "@/lib/wizard-history";
56
+ import { LabelApplyTrigger } from "./LabelApplyTrigger";
58
57
  import { MoveToTrigger } from "./MoveToTrigger";
59
58
  import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
60
59
  import { OrganizeDialog } from "./organize/OrganizeDialog";
61
- import { SelectionToolbar } from "./SelectionToolbar";
62
60
  import { SwipeableMessageRow } from "./SwipeableMessageRow";
63
61
 
64
62
  /**
@@ -235,6 +233,7 @@ export const MessageList = ({
235
233
  const parentRef = useRef<HTMLDivElement>(null);
236
234
  const navigate = useNavigate();
237
235
  const isDesktop = useIsDesktop();
236
+ const wizardStep = useWizardStepValue();
238
237
  const [organizeOpen, setOrganizeOpen] = useState(false);
239
238
  // Mobile only: which selection-sheet entry opened the guided organize flow
240
239
  // (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
@@ -243,6 +242,8 @@ export const MessageList = ({
243
242
  "select-similar" | "something-else" | null
244
243
  >(null);
245
244
  const isSearching = !!searchQuery?.trim();
245
+ const listHeaderChrome = useListHeaderChrome();
246
+ const { labels } = useLabelList(accountId);
246
247
 
247
248
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
248
249
  // from the open thread (`selectedMessageId` in the URL). j/k move this
@@ -1061,12 +1062,14 @@ export const MessageList = ({
1061
1062
  // — is never blocked, and the blocker is off entirely with nothing selected.
1062
1063
  useBlocker({
1063
1064
  shouldBlockFn: ({ action }) => {
1064
- if (!shouldExitSelectionOnNavigate(action, hasSelection)) return false;
1065
+ if (!shouldExitSelectionOnNavigate(action, hasSelection, wizardStep)) {
1066
+ return false;
1067
+ }
1065
1068
  exitSelection();
1066
1069
  return true;
1067
1070
  },
1068
1071
  enableBeforeUnload: false,
1069
- disabled: !hasSelection,
1072
+ disabled: !hasSelection || wizardStep !== undefined,
1070
1073
  });
1071
1074
 
1072
1075
  // Load more when scrolling near the bottom
@@ -1282,37 +1285,15 @@ export const MessageList = ({
1282
1285
  }
1283
1286
  : undefined;
1284
1287
 
1285
- // The mobile sheet has no inline hint slot, so the cross-account move
1286
- // restriction rides in as the lowest-priority notice.
1287
- const mobileNotice =
1288
+ // The bar shows one notice at a time, so the cross-account move restriction
1289
+ // rides in behind the escalation states.
1290
+ const selectionNotice =
1288
1291
  escalationNotice ??
1289
1292
  (moveDisabledHint
1290
1293
  ? { tone: "warning" as const, text: moveDisabledHint }
1291
1294
  : undefined);
1292
1295
 
1293
- // Mobile: which content the peeking sheet routes to, from the escalation
1294
- // machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
1295
- // predicate still paging) replace the idle quick actions with status and
1296
- // progress; `escalated` keeps the verbs over the whole predicate.
1297
- const mobileMode = resolveSelectionSheetMode({
1298
- isRunning: escalation.isRunning,
1299
- isCounting: escalation.phase.kind === "counting",
1300
- isEscalated: escalation.phase.kind === "escalated",
1301
- });
1302
-
1303
- // The sheet is the sole mobile selection surface. It rises at 2+ selected
1304
- // (the prototype threshold) or in any non-idle escalation state — a single
1305
- // selected row enters selection mode without raising it, matching today.
1306
- const showMobileSheet =
1307
- !isDesktop &&
1308
- isMultiSelectMode &&
1309
- shouldShowSelectionSheet(
1310
- selectionCount,
1311
- mobileMode,
1312
- mobileNotice !== undefined,
1313
- );
1314
-
1315
- const mobileMoveSlot =
1296
+ const selectionMoveSlot =
1316
1297
  (onMoveMessages || escalation.phase.kind === "escalated") &&
1317
1298
  accountId &&
1318
1299
  mailboxId ? (
@@ -1325,9 +1306,8 @@ export const MessageList = ({
1325
1306
  />
1326
1307
  ) : undefined;
1327
1308
 
1328
- // The guided organize flow (issue #211) overlays the same positioned pane
1329
- // ancestor as the sheet, so it is rendered from the sheet slot. The sheet
1330
- // stays mounted underneath as the flow's dimmed backdrop.
1309
+ // The guided organize flow (issue #211) covers the pane, which is its
1310
+ // positioned ancestor.
1331
1311
  const mobileOrganizeFlow =
1332
1312
  mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
1333
1313
  <MobileOrganizeFlow
@@ -1343,72 +1323,66 @@ export const MessageList = ({
1343
1323
  />
1344
1324
  ) : undefined;
1345
1325
 
1346
- const mobileSelectionSheet = showMobileSheet ? (
1347
- <SelectionSheet
1326
+ // Organize acts on the materialized selection within one account, so it is
1327
+ // withdrawn the moment the selection escalates to a predicate or a run
1328
+ // takes over — both of which name themselves through `statusLabel`.
1329
+ const canOrganize =
1330
+ !!accountId && !!mailboxId && !moveDisabledHint && !selectionStatusLabel;
1331
+
1332
+ // One selection surface at every width, always mounted: it is the list
1333
+ // header. With nothing ticked it names the mailbox and carries the header's
1334
+ // own chrome; from the first ticked row the count and the verbs take that
1335
+ // title's place, and the escalation states (issue #212) — the offer,
1336
+ // counting, the escalated predicate, a chunked run's progress — ride on the
1337
+ // same bar rather than a second one.
1338
+ const activeSelectionBar = (
1339
+ <SelectionTopBar
1340
+ title={listHeaderChrome.title || listTitle}
1341
+ navSlot={listHeaderChrome.navSlot}
1342
+ titleMeta={listHeaderChrome.titleMeta}
1343
+ searchSlot={listHeaderChrome.searchSlot}
1344
+ searchField={listHeaderChrome.searchField}
1345
+ idleSlot={listHeaderChrome.makeFilterSlot}
1348
1346
  count={selectionCount}
1349
- mode={mobileMode}
1350
1347
  onCancel={handleSelectionCancel}
1351
1348
  onDelete={handleSelectionDelete}
1349
+ onOrganize={
1350
+ canOrganize
1351
+ ? () =>
1352
+ isDesktop
1353
+ ? setOrganizeOpen(true)
1354
+ : setMobileOrganizeEntry("select-similar")
1355
+ : undefined
1356
+ }
1352
1357
  onJunk={
1353
1358
  junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
1354
1359
  ? handleJunk
1355
1360
  : undefined
1356
1361
  }
1357
1362
  onMarkRead={handleMarkAsRead}
1358
- onSelectSimilar={
1359
- accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
1360
- }
1361
1363
  onSomethingElse={
1362
- accountId ? () => setMobileOrganizeEntry("something-else") : undefined
1364
+ canOrganize && !isDesktop
1365
+ ? () => setMobileOrganizeEntry("something-else")
1366
+ : undefined
1367
+ }
1368
+ moveSlot={selectionMoveSlot}
1369
+ overflowSlot={
1370
+ accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
1371
+ <LabelApplyTrigger
1372
+ accountId={accountId}
1373
+ mailboxId={mailboxId}
1374
+ messageIds={Array.from(selectedIds)}
1375
+ />
1376
+ ) : undefined
1363
1377
  }
1364
- moveSlot={mobileMoveSlot}
1365
1378
  isBusy={selectionIsBusy}
1379
+ isCounting={escalation.phase.kind === "counting"}
1366
1380
  selectAll={selectionSelectAll}
1367
1381
  statusLabel={selectionStatusLabel}
1368
1382
  progress={selectionProgress}
1369
- notice={mobileNotice}
1383
+ notice={selectionNotice}
1370
1384
  />
1371
- ) : undefined;
1372
-
1373
- // Both mobile overlays share the pane's positioned ancestor via the sheet
1374
- // slot: the peeking sheet, and the guided organize flow above it.
1375
- const mobileSelectionSurface =
1376
- mobileSelectionSheet || mobileOrganizeFlow ? (
1377
- <>
1378
- {mobileSelectionSheet}
1379
- {mobileOrganizeFlow}
1380
- </>
1381
- ) : undefined;
1382
-
1383
- // Desktop selection toolbar replaces the pane header when any rows are
1384
- // selected. It gains the same search-escalation states the mobile sheet
1385
- // carries (issue #212) — the offer, counting, the escalated predicate and a
1386
- // chunked run's progress — from the shared derivations above; the
1387
- // cross-account move hint stays inline (the toolbar has a slot for it).
1388
- const desktopSelectionBar =
1389
- hasSelection && isDesktop ? (
1390
- <SelectionToolbar
1391
- selectedCount={selectionCount}
1392
- onDelete={handleSelectionDelete}
1393
- onClearSelection={handleSelectionCancel}
1394
- onMarkAsRead={handleMarkAsRead}
1395
- onMove={onMoveMessages ? handleMoveSelected : undefined}
1396
- onOrganize={() => setOrganizeOpen(true)}
1397
- isDeleting={isDeleting}
1398
- isMoving={isMoving}
1399
- accountId={accountId}
1400
- currentMailboxId={mailboxId}
1401
- selectedMessageIds={Array.from(selectedIds)}
1402
- moveDisabledHint={moveDisabledHint}
1403
- selectAll={escalationEnabled ? selectionSelectAll : undefined}
1404
- statusLabel={selectionStatusLabel}
1405
- isCounting={escalation.phase.kind === "counting"}
1406
- progress={selectionProgress}
1407
- notice={escalationNotice}
1408
- />
1409
- ) : undefined;
1410
-
1411
- const activeSelectionBar = desktopSelectionBar;
1385
+ );
1412
1386
 
1413
1387
  // Roving tabindex: exactly one row is in the tab order, so Tab moves focus
1414
1388
  // into the list at the cursor and Shift+Tab moves back out to the side panel
@@ -1440,13 +1414,6 @@ export const MessageList = ({
1440
1414
  aria-multiselectable
1441
1415
  aria-label={listTitle}
1442
1416
  className="flex-1 overflow-y-auto"
1443
- // Pad the list so its last rows clear the peeking teaser (~56px)
1444
- // rather than hiding behind it.
1445
- style={
1446
- showMobileSheet
1447
- ? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
1448
- : undefined
1449
- }
1450
1417
  >
1451
1418
  {/* Virtualizer scaffolding — presentational so the listbox sees the
1452
1419
  rows as its options rather than these positioning wrappers. */}
@@ -1531,7 +1498,9 @@ export const MessageList = ({
1531
1498
  listMeta={listMeta}
1532
1499
  sections={sections}
1533
1500
  flatList
1534
- listState={listState}
1501
+ // The results panel is a body, not a list state: it stands in for the
1502
+ // rows and must not fall behind a skeleton while the query re-keys.
1503
+ listState={listHeaderChrome.searchResults ? "ready" : listState}
1535
1504
  searchQuery={isSearching ? searchQuery : undefined}
1536
1505
  listFilter={listFilter}
1537
1506
  listScopeLabel={listScopeLabel}
@@ -1551,8 +1520,11 @@ export const MessageList = ({
1551
1520
  isDesktop={isDesktop}
1552
1521
  hideHeader={hideHeader}
1553
1522
  selectionBar={activeSelectionBar}
1554
- selectionSheet={mobileSelectionSurface}
1555
- listBody={listState === "ready" ? virtualBody : undefined}
1523
+ paneOverlay={mobileOrganizeFlow}
1524
+ listBody={
1525
+ listHeaderChrome.searchResults ??
1526
+ (listState === "ready" ? virtualBody : undefined)
1527
+ }
1556
1528
  />
1557
1529
  <ConfirmDialog
1558
1530
  isOpen={pendingDelete !== null}
@@ -180,7 +180,7 @@ export const MoveToTrigger = ({
180
180
  title={disabledHint}
181
181
  className={variant === "icon-only" ? ICON_TRIGGER : COMPACT_TRIGGER}
182
182
  >
183
- <FolderInput className="size-4" />
183
+ <FolderInput className={variant === "icon-only" ? "size-5" : "size-4"} />
184
184
  {variant === "compact" && <span className="hidden sm:inline">Move</span>}
185
185
  </button>
186
186
  );
@@ -0,0 +1,191 @@
1
+ import {
2
+ type ClauseDraft,
3
+ type ClauseEditState,
4
+ type MatchCount,
5
+ type MatchMode,
6
+ type RunState,
7
+ SelectionWizard,
8
+ type StepId,
9
+ stepBlockedReason,
10
+ stepIndex,
11
+ stepsFor,
12
+ type Verb,
13
+ type WizardDraft,
14
+ type WizardMessage,
15
+ } from "@remit/ui";
16
+ import { useCallback, useState } from "react";
17
+ import { useWizardStep } from "@/lib/wizard-history";
18
+
19
+ const OPENING_STEP: StepId = "match";
20
+ const UNCOUNTED: MatchCount = { status: "uncounted" };
21
+ const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "all" };
22
+
23
+ /** How the commit ended, once something commits it (#483). */
24
+ export interface WizardOutcome {
25
+ state: RunState;
26
+ matched: number;
27
+ applied: number;
28
+ failures: readonly WizardMessage[];
29
+ }
30
+
31
+ /** Nothing has been sent anywhere, so nothing can have been done. */
32
+ const NOTHING_COMMITTED: WizardOutcome = {
33
+ state: "commitFailed",
34
+ matched: 0,
35
+ applied: 0,
36
+ failures: [],
37
+ };
38
+
39
+ export interface SelectionWizardHostProps {
40
+ verb: Verb;
41
+ selectedCount: number;
42
+ outcome?: WizardOutcome;
43
+ }
44
+
45
+ export function SelectionWizardHost({
46
+ verb,
47
+ selectedCount,
48
+ outcome = NOTHING_COMMITTED,
49
+ }: SelectionWizardHostProps) {
50
+ const { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
51
+ const [mode, setMode] = useState<MatchMode>("selected");
52
+ const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
53
+ const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
54
+ undefined,
55
+ );
56
+ const [nudgedStep, setNudgedStep] = useState<StepId | undefined>(undefined);
57
+
58
+ const startAddClause = useCallback(() => {
59
+ setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
60
+ }, []);
61
+
62
+ const startEditClause = useCallback(
63
+ (clauseId: string) => {
64
+ const clause = draft.clauses.find((held) => held.id === clauseId);
65
+ if (!clause) return;
66
+ setClauseEdit({
67
+ mode: "edit",
68
+ clauseId,
69
+ draft: { field: clause.field, value: clause.value },
70
+ });
71
+ },
72
+ [draft.clauses],
73
+ );
74
+
75
+ const removeClause = useCallback((clauseId: string) => {
76
+ setDraft((held) => ({
77
+ ...held,
78
+ clauses: held.clauses.filter((clause) => clause.id !== clauseId),
79
+ }));
80
+ }, []);
81
+
82
+ const changeClauseDraft = useCallback((next: ClauseDraft) => {
83
+ setClauseEdit((edit) => edit && { ...edit, draft: next });
84
+ }, []);
85
+
86
+ const submitClause = useCallback(() => {
87
+ setClauseEdit((edit) => {
88
+ if (!edit) return undefined;
89
+ setDraft((held) => ({
90
+ ...held,
91
+ clauses: edit.clauseId
92
+ ? held.clauses.map((clause) =>
93
+ clause.id === edit.clauseId
94
+ ? { ...clause, ...edit.draft }
95
+ : clause,
96
+ )
97
+ : [...held.clauses, { id: crypto.randomUUID(), ...edit.draft }],
98
+ }));
99
+ return undefined;
100
+ });
101
+ }, []);
102
+
103
+ if (!step) return null;
104
+
105
+ const steps = stepsFor({ verb, mode, scope: draft.scope });
106
+ // The step the screens are on, which is the held one only while the answers
107
+ // still hold it. Reading the URL's step here and the resolved one on screen
108
+ // is how a footer comes to name a screen nobody is looking at.
109
+ const current = steps[stepIndex(steps, step)];
110
+ const blockedReason = stepBlockedReason(current, draft, UNCOUNTED);
111
+ const sample = {
112
+ messages: [],
113
+ count: UNCOUNTED,
114
+ label: "Messages this covers",
115
+ };
116
+
117
+ const advance = () => {
118
+ if (blockedReason) {
119
+ setNudgedStep(current);
120
+ return;
121
+ }
122
+ const next = steps[stepIndex(steps, current) + 1];
123
+ if (next) goToStep(next);
124
+ };
125
+
126
+ return (
127
+ <SelectionWizard
128
+ verb={verb}
129
+ steps={steps}
130
+ step={current}
131
+ blockedReason={blockedReason}
132
+ nudged={nudgedStep === current}
133
+ onBack={goBack}
134
+ onExit={() => closeWizard(steps, current)}
135
+ onContinue={advance}
136
+ onCommit={() => goToStep("run")}
137
+ match={{
138
+ selectedCount,
139
+ mode,
140
+ onModeChange: setMode,
141
+ onSemanticFallback: () => setMode("properties"),
142
+ sample,
143
+ }}
144
+ properties={{
145
+ clauses: draft.clauses,
146
+ matchOperator: draft.matchOperator,
147
+ onMatchOperatorChange: (matchOperator) =>
148
+ setDraft((held) => ({ ...held, matchOperator })),
149
+ clauseEdit,
150
+ onStartAddClause: startAddClause,
151
+ onStartEditClause: startEditClause,
152
+ onRemoveClause: removeClause,
153
+ onChangeDraft: changeClauseDraft,
154
+ onSubmitClause: submitClause,
155
+ onCancelClause: () => setClauseEdit(undefined),
156
+ sample,
157
+ }}
158
+ rule={{
159
+ draft,
160
+ onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
161
+ onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
162
+ }}
163
+ name={{
164
+ name: draft.name ?? "",
165
+ onNameChange: (name) => setDraft((held) => ({ ...held, name })),
166
+ nudged: nudgedStep === current,
167
+ }}
168
+ review={{
169
+ verb,
170
+ mode,
171
+ selectedCount,
172
+ clauses: draft.clauses,
173
+ matchOperator: draft.matchOperator,
174
+ scope: draft.scope,
175
+ until: draft.until,
176
+ ruleName: draft.name,
177
+ sample,
178
+ }}
179
+ run={{
180
+ state: outcome.state,
181
+ verb,
182
+ scope: draft.scope,
183
+ matched: outcome.matched,
184
+ applied: outcome.applied,
185
+ failures: outcome.failures,
186
+ onRetry: goBack,
187
+ onDismiss: () => closeWizard(steps, current),
188
+ }}
189
+ />
190
+ );
191
+ }
@@ -69,6 +69,7 @@ function mountList(options: {
69
69
  initialIds: string[];
70
70
  onDeleteMessages?: (ids: string[]) => void;
71
71
  }) {
72
+ const onDeleteMessages = options.onDeleteMessages ?? (() => undefined);
72
73
  const commandsRef = createRef<MessageListCommands | null>();
73
74
  let setIds: ((ids: string[]) => void) | undefined;
74
75
  const Harness = () => {
@@ -79,7 +80,7 @@ function mountList(options: {
79
80
  {
80
81
  selectedMessageId: undefined,
81
82
  onOpen: () => undefined,
82
- onDeleteMessages: options.onDeleteMessages,
83
+ onDeleteMessages,
83
84
  commandsRef,
84
85
  },
85
86
  ...rowElements(ids),
@@ -222,7 +223,12 @@ function mountSelectableList(initialIds: string[]) {
222
223
  setIds = set;
223
224
  return createElement(
224
225
  ThreadListInteraction,
225
- { selectedMessageId: undefined, onOpen: () => undefined, commandsRef },
226
+ {
227
+ selectedMessageId: undefined,
228
+ onOpen: () => undefined,
229
+ onDeleteMessages: () => undefined,
230
+ commandsRef,
231
+ },
226
232
  ...rowElements(ids),
227
233
  createElement(Probe, { key: "probe" }),
228
234
  );
@@ -16,6 +16,7 @@
16
16
  * are not rendered: focus stops moving, the highlight disappears, and the next
17
17
  * verb acts on a message the user cannot see.
18
18
  */
19
+ import { SelectionTopBar } from "@remit/ui";
19
20
  import {
20
21
  createContext,
21
22
  type ReactNode,
@@ -34,9 +35,10 @@ import { useIsDesktop } from "@/hooks/useMediaQuery";
34
35
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
35
36
  import { formatDeleteToTrashTitle } from "@/lib/format";
36
37
  import { tabStopId } from "@/lib/list-focus";
38
+ import { useListHeaderChrome } from "@/lib/list-header-chrome";
39
+ import { useMailContext } from "@/lib/mail-context";
37
40
  import type { MessageListCommands } from "./MessageList";
38
41
  import type { MessageRowSelection } from "./MessageRow";
39
- import { SelectionToolbar } from "./SelectionToolbar";
40
42
 
41
43
  interface ThreadRowInteraction {
42
44
  focused: boolean;
@@ -142,7 +144,7 @@ interface ThreadListInteractionProps {
142
144
  /** Opens a row — the same navigation a click performs. */
143
145
  onOpen: (messageId: string, options?: OpenMessageOptions) => void;
144
146
  /** Deletes a set of messages. Absent disables the delete key for this list. */
145
- onDeleteMessages?: (messageIds: string[]) => void;
147
+ onDeleteMessages: (messageIds: string[]) => void;
146
148
  isDeleting?: boolean;
147
149
  commandsRef?: RefObject<MessageListCommands | null>;
148
150
  onTriageContextChange?: (context: TriageContextUpdate) => void;
@@ -185,6 +187,14 @@ export function ThreadListInteraction({
185
187
  if (orderedIds.length > 0) toggleAll(orderedIds);
186
188
  }, [orderedIds, toggleAll]);
187
189
 
190
+ // The selection wizard is mounted on the route, above this provider, and
191
+ // acts on these rows — so it reads the count from here rather than keeping
192
+ // one of its own (#477).
193
+ const { onSelectedCountChange } = useMailContext();
194
+ useEffect(() => {
195
+ onSelectedCountChange(selectedCount);
196
+ }, [selectedCount, onSelectedCountChange]);
197
+
188
198
  // A row that leaves the list — a chip filter, a collapsed section, a
189
199
  // completed delete — cannot stay selected. Survivors keep their selection.
190
200
  const { intersectWith } = selection;
@@ -241,14 +251,11 @@ export function ThreadListInteraction({
241
251
  // the same contract the mailbox list's delete has.
242
252
  const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
243
253
 
244
- const requestDeleteIds = useCallback(
245
- (ids: string[]): boolean => {
246
- if (!onDeleteMessages || ids.length === 0) return false;
247
- setPendingDelete(ids);
248
- return true;
249
- },
250
- [onDeleteMessages],
251
- );
254
+ const requestDeleteIds = useCallback((ids: string[]): boolean => {
255
+ if (ids.length === 0) return false;
256
+ setPendingDelete(ids);
257
+ return true;
258
+ }, []);
252
259
 
253
260
  const requestDeleteSelection = useCallback(() => {
254
261
  requestDeleteIds(Array.from(selectedIds));
@@ -272,7 +279,7 @@ export function ThreadListInteraction({
272
279
 
273
280
  const confirmDelete = useCallback(() => {
274
281
  if (pendingDelete === null) return;
275
- onDeleteMessages?.(pendingDelete);
282
+ onDeleteMessages(pendingDelete);
276
283
  setPendingDelete(null);
277
284
  exitSelection();
278
285
  }, [pendingDelete, onDeleteMessages, exitSelection]);
@@ -416,25 +423,35 @@ export function ThreadListInteraction({
416
423
  }
417
424
 
418
425
  interface ThreadListSelectionBarProps {
426
+ /** The view's own name, used until the enclosing header supplies one. */
427
+ title: string;
419
428
  onMarkAsRead?: (messageIds: string[]) => void;
420
429
  isDeleting?: boolean;
421
430
  }
422
431
 
423
432
  /**
424
- * Reduced selection bar for Flagged, mounted inside `ThreadListInteraction`.
433
+ * The starred list's header, which is also its selection bar the same
434
+ * surface the mailbox list and the brief raise.
425
435
  *
426
- * Move is not offered here: Flagged spans accounts and mailboxes, and a move
427
- * picker needs one account and one source folder to be honest about where the
428
- * messages go. Delete and mark-read carry no such scope. The brief resolves
429
- * that scope from the selection instead and mounts the mailbox list's full
430
- * `SelectionToolbar` — see `DailyBrief`.
436
+ * Move is not offered here: starred mail spans accounts and mailboxes, and a
437
+ * move picker needs one account and one source folder to be honest about where
438
+ * the messages go. Delete and mark-read carry no such scope.
431
439
  */
432
440
  export function ThreadListSelectionBar({
441
+ title,
433
442
  onMarkAsRead,
434
443
  isDeleting,
435
444
  }: ThreadListSelectionBarProps) {
436
- const { selectedIds, selectedCount, exitSelection, requestDeleteSelection } =
437
- useThreadListSelection();
445
+ const chrome = useListHeaderChrome();
446
+ const {
447
+ selectedIds,
448
+ selectedCount,
449
+ exitSelection,
450
+ requestDeleteSelection,
451
+ orderedIds,
452
+ allSelected,
453
+ toggleAllLoaded,
454
+ } = useThreadListSelection();
438
455
 
439
456
  const handleMarkAsRead = useCallback(() => {
440
457
  onMarkAsRead?.(Array.from(selectedIds));
@@ -442,12 +459,27 @@ export function ThreadListSelectionBar({
442
459
  }, [onMarkAsRead, selectedIds, exitSelection]);
443
460
 
444
461
  return (
445
- <SelectionToolbar
446
- selectedCount={selectedCount}
462
+ <SelectionTopBar
463
+ title={chrome.title || title}
464
+ navSlot={chrome.navSlot}
465
+ titleMeta={chrome.titleMeta}
466
+ searchSlot={chrome.searchSlot}
467
+ searchField={chrome.searchField}
468
+ idleSlot={chrome.makeFilterSlot}
469
+ count={selectedCount}
470
+ onCancel={exitSelection}
447
471
  onDelete={requestDeleteSelection}
448
- onClearSelection={exitSelection}
449
- onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
450
- isDeleting={isDeleting}
472
+ onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
473
+ isBusy={isDeleting}
474
+ selectAll={
475
+ orderedIds.length > 0
476
+ ? {
477
+ checked: allSelected,
478
+ indeterminate: selectedCount > 0 && !allSelected,
479
+ onChange: toggleAllLoaded,
480
+ }
481
+ : undefined
482
+ }
451
483
  />
452
484
  );
453
485
  }
@@ -69,6 +69,9 @@ function Probe({
69
69
  /** The account id in `/accounts/{id}/sync/status`. */
70
70
  const accountOf = (path: string): string => path.split("/")[2] ?? "";
71
71
 
72
+ const progress = (dom: DomHarness): InitialSyncProgress =>
73
+ JSON.parse(dom.text());
74
+
72
75
  const mount = async (
73
76
  accountIds: string[],
74
77
  answer: (accountId: string) => unknown,
@@ -77,14 +80,17 @@ const mount = async (
77
80
  http = mockFetch((call) => answer(accountOf(call.path)));
78
81
  harness = createDomHarness();
79
82
  harness.renderApp(createElement(Probe, { accountIds, enabled }));
80
- await harness.flush();
81
- await harness.flush();
83
+ // Settle on the hook's own answer rather than on a fixed number of turns:
84
+ // a query that lands on a timer instead of a microtask needs real time to
85
+ // pass, and a loaded machine is where that difference shows up.
86
+ for (let turn = 0; turn < 20; turn += 1) {
87
+ await harness.flush();
88
+ if (progress(harness).resolved) break;
89
+ await harness.wait(1);
90
+ }
82
91
  return harness;
83
92
  };
84
93
 
85
- const progress = (dom: DomHarness): InitialSyncProgress =>
86
- JSON.parse(dom.text());
87
-
88
94
  describe("isSyncingPhase", () => {
89
95
  it("is true only for the phases a running sync round writes", () => {
90
96
  assert.equal(isSyncingPhase("discovering_mailboxes"), true);