@remit/web-client 0.0.93 → 0.0.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/BriefPane.selection.test.ts +0 -1
  3. package/src/components/mail/BriefPane.tsx +7 -17
  4. package/src/components/mail/DailyBrief.selection.test.ts +21 -3
  5. package/src/components/mail/DailyBrief.tsx +37 -39
  6. package/src/components/mail/FlaggedList.tsx +55 -8
  7. package/src/components/mail/FlaggedPane.tsx +9 -23
  8. package/src/components/mail/MailListHeader.tsx +41 -39
  9. package/src/components/mail/MailboxPane.tsx +24 -11
  10. package/src/components/mail/MessageList.selection.test.ts +44 -30
  11. package/src/components/mail/MessageList.tsx +117 -339
  12. package/src/components/mail/SelectionWizardHost.tsx +265 -48
  13. package/src/components/mail/ThreadListInteraction.test.ts +74 -4
  14. package/src/components/mail/ThreadListInteraction.tsx +58 -50
  15. package/src/components/mail/make-filter-entry.test.ts +63 -0
  16. package/src/components/mail/selection-verbs.test.ts +278 -0
  17. package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
  18. package/src/hooks/useEscalatedActions.ts +2 -2
  19. package/src/hooks/useMatchSample.ts +44 -3
  20. package/src/hooks/useRulePreview.ts +1 -2
  21. package/src/lib/bulk-action-copy.test.ts +0 -19
  22. package/src/lib/bulk-action-copy.ts +0 -8
  23. package/src/lib/bulk-actions.test.ts +0 -60
  24. package/src/lib/bulk-actions.ts +0 -26
  25. package/src/lib/format.test.ts +0 -27
  26. package/src/lib/format.ts +4 -14
  27. package/src/lib/list-header-chrome.ts +10 -0
  28. package/src/lib/organize/organize-model.ts +8 -2
  29. package/src/lib/organize/rule-model.ts +0 -4
  30. package/src/lib/wizard-history.test.ts +31 -0
  31. package/src/lib/wizard-history.ts +97 -9
  32. package/src/routes/mail.tsx +3 -1
  33. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  34. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  35. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  36. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -140
  37. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  38. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.93",
3
+ "version": "0.0.95",
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 &&
@@ -415,20 +404,28 @@ function BriefSelectionChrome({
415
404
  />
416
405
  );
417
406
 
407
+ // Mounted as the pane's overlay rather than beside the header: the brief hands
408
+ // its body over to the search results panel while a query is being typed, and
409
+ // the wizard is not part of the body it hands over. The overlay slot also puts
410
+ // it inside the list header's chrome, which is where the converted query it
411
+ // opens on comes from.
418
412
  return (
419
- <>
420
- <MailListHeader {...header} selectionBar={selectionBar}>
421
- {children}
422
- </MailListHeader>
423
- <SelectionWizardHost
424
- verb={wizardVerb}
425
- accountId={scope.accountId}
426
- mailboxId={scope.mailboxId}
427
- selection={wizardSelection}
428
- crossAccount={scope.moveDisabledHint !== undefined}
429
- onFinished={exitSelection}
430
- />
431
- </>
413
+ <MailListHeader
414
+ {...header}
415
+ selectionBar={selectionBar}
416
+ paneOverlay={
417
+ <SelectionWizardHost
418
+ verb={wizard.verb}
419
+ accountId={scope.accountId}
420
+ mailboxId={scope.mailboxId}
421
+ selection={wizardSelection}
422
+ crossAccount={scope.moveDisabledHint !== undefined}
423
+ onFinished={exitSelection}
424
+ />
425
+ }
426
+ >
427
+ {children}
428
+ </MailListHeader>
432
429
  );
433
430
  }
434
431
 
@@ -451,7 +448,6 @@ interface DailyBriefProps {
451
448
  /** Cursor / selection / display order, reported up to the triage layer. */
452
449
  onTriageContextChange?: (context: TriageContextUpdate) => void;
453
450
  onDeleteMessages: (messageIds: string[]) => void;
454
- onMarkMessagesRead?: (messageIds: string[]) => void;
455
451
  }
456
452
 
457
453
  export function DailyBrief({
@@ -462,11 +458,11 @@ export function DailyBrief({
462
458
  commandsRef,
463
459
  onTriageContextChange,
464
460
  onDeleteMessages,
465
- onMarkMessagesRead,
466
461
  }: DailyBriefProps) {
467
462
  const { searchQuery, resultFolderIndex } = useMailContext();
468
463
  const tokenContext = useSearchTokenContext();
469
464
  const isDesktop = useIsDesktop();
465
+ const wizard = useSelectionWizard();
470
466
 
471
467
  const nonMuted = useMemo(
472
468
  () => sortAccountsByCreatedAt(accounts.filter((a) => !a.muted?.value)),
@@ -772,10 +768,13 @@ export function DailyBrief({
772
768
  selectedMessageId={selectedMessageId}
773
769
  onOpen={(id, options) => onSelectMessage?.(id, options)}
774
770
  onDeleteMessages={onDeleteMessages}
771
+ onSelectionVerb={wizard.start}
772
+ wizardOpen={wizard.isOpen}
775
773
  commandsRef={commandsRef}
776
774
  onTriageContextChange={onTriageContextChange}
777
775
  >
778
776
  <BriefSelectionChrome
777
+ wizard={wizard}
779
778
  header={{
780
779
  title: "Daily brief",
781
780
  unreadCount: totalUnseen,
@@ -788,7 +787,6 @@ export function DailyBrief({
788
787
  onSelectSearchResult,
789
788
  }}
790
789
  rows={filteredRows}
791
- onMarkMessagesRead={onMarkMessagesRead}
792
790
  >
793
791
  <div className="flex h-full flex-col">
794
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,13 +36,19 @@ 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";
43
+ import {
44
+ SelectionWizardHost,
45
+ type WizardSelectionMessage,
46
+ } from "./SelectionWizardHost";
41
47
  import {
42
48
  type OpenMessageOptions,
43
49
  ThreadListInteraction,
44
50
  ThreadListSelectionBar,
51
+ useThreadListSelection,
45
52
  } from "./ThreadListInteraction";
46
53
 
47
54
  const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
@@ -49,6 +56,49 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
49
56
  attachment: (t) => t.hasAttachment === true,
50
57
  };
51
58
 
59
+ /**
60
+ * The wizard this view's verbs walk, and the one its search entry lands on.
61
+ *
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.
70
+ */
71
+ function StarredWizardHost({
72
+ rows,
73
+ verb,
74
+ }: {
75
+ rows: readonly ThreadRowData[];
76
+ verb: Verb;
77
+ }) {
78
+ const { selectedIds, exitSelection } = useThreadListSelection();
79
+ const selection = useMemo<WizardSelectionMessage[]>(
80
+ () =>
81
+ rows
82
+ .filter((row) => selectedIds.has(row.id))
83
+ .map((row) => ({
84
+ id: row.id,
85
+ sender: row.fromName,
86
+ email: row.fromEmail,
87
+ subject: row.subject,
88
+ date: row.timeLabel,
89
+ })),
90
+ [rows, selectedIds],
91
+ );
92
+ return (
93
+ <SelectionWizardHost
94
+ verb={verb}
95
+ selection={selection}
96
+ crossAccount
97
+ onFinished={exitSelection}
98
+ />
99
+ );
100
+ }
101
+
52
102
  interface FlaggedListProps {
53
103
  selectedMessageId?: string;
54
104
  onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
@@ -57,7 +107,6 @@ interface FlaggedListProps {
57
107
  /** Cursor / selection / display order, reported up to the triage layer. */
58
108
  onTriageContextChange?: (context: TriageContextUpdate) => void;
59
109
  onDeleteMessages: (messageIds: string[]) => void;
60
- onMarkMessagesRead?: (messageIds: string[]) => void;
61
110
  }
62
111
 
63
112
  export function FlaggedList({
@@ -66,11 +115,11 @@ export function FlaggedList({
66
115
  commandsRef,
67
116
  onTriageContextChange,
68
117
  onDeleteMessages,
69
- onMarkMessagesRead,
70
118
  }: FlaggedListProps) {
71
119
  const { searchQuery, resultFolderIndex } = useMailContext();
72
120
  const tokenContext = useSearchTokenContext();
73
121
  const isDesktop = useIsDesktop();
122
+ const wizard = useSelectionWizard();
74
123
 
75
124
  const [selectedCategory, setSelectedCategory] = useState("all");
76
125
  const [activeFilters, setActiveFilters] = useState<ReadonlySet<string>>(
@@ -193,6 +242,8 @@ export function FlaggedList({
193
242
  selectedMessageId={selectedMessageId}
194
243
  onOpen={(id, options) => onSelectMessage?.(id, options)}
195
244
  onDeleteMessages={onDeleteMessages}
245
+ onSelectionVerb={wizard.start}
246
+ wizardOpen={wizard.isOpen}
196
247
  commandsRef={commandsRef}
197
248
  onTriageContextChange={onTriageContextChange}
198
249
  >
@@ -210,17 +261,13 @@ export function FlaggedList({
210
261
  onSelectThread={onSelectMessage}
211
262
  onSelectBriefCategory={() => undefined}
212
263
  isDesktop={isDesktop}
213
- selectionBar={
214
- <ThreadListSelectionBar
215
- title="Starred"
216
- onMarkAsRead={onMarkMessagesRead}
217
- />
218
- }
264
+ selectionBar={<ThreadListSelectionBar title="Starred" />}
219
265
  listBody={
220
266
  chrome.searchResults ??
221
267
  (listState === "ready" ? listBody : undefined)
222
268
  }
223
269
  />
270
+ <StarredWizardHost rows={rows} verb={wizard.verb} />
224
271
  </ThreadListInteraction>
225
272
  </MailViewChrome>
226
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
  }
@@ -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>