@remit/web-client 0.0.86 → 0.0.88

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/auth/BetterAuthShell.tsx +2 -2
  3. package/src/components/compose/AddressField.tsx +60 -77
  4. package/src/components/mail/AutoMovedIndicator.tsx +7 -9
  5. package/src/components/mail/BriefPane.tsx +4 -2
  6. package/src/components/mail/DailyBrief.selection.test.ts +49 -0
  7. package/src/components/mail/DailyBrief.tsx +484 -76
  8. package/src/components/mail/FlaggedList.tsx +3 -2
  9. package/src/components/mail/FlaggedPane.tsx +4 -2
  10. package/src/components/mail/MailListHeader.tsx +157 -38
  11. package/src/components/mail/MailViewChrome.tsx +18 -1
  12. package/src/components/mail/MailboxPane.tsx +55 -20
  13. package/src/components/mail/MessageCard.tsx +0 -1
  14. package/src/components/mail/MessageList.tsx +70 -15
  15. package/src/components/mail/MessageListItem.tsx +4 -21
  16. package/src/components/mail/MessageRow.tsx +13 -31
  17. package/src/components/mail/SwipeableMessageRow.tsx +13 -6
  18. package/src/components/mail/ThreadListInteraction.tsx +51 -6
  19. package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
  20. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
  21. package/src/components/mail/organize/SearchFilterDialog.tsx +5 -6
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
  24. package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
  25. package/src/components/mail/useModifierSelect.render.test.ts +247 -0
  26. package/src/components/mail/useModifierSelect.ts +109 -0
  27. package/src/components/onboarding/OnboardingWizard.tsx +53 -9
  28. package/src/components/settings/AccountFormPanel.tsx +10 -5
  29. package/src/hooks/bulk-chunking.render.test.ts +157 -0
  30. package/src/hooks/useApplyLabel.ts +6 -3
  31. package/src/hooks/useClauseSuggestions.ts +57 -0
  32. package/src/hooks/useDeleteMessages.ts +6 -3
  33. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  34. package/src/hooks/useFollowFocusOpen.ts +81 -0
  35. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  36. package/src/hooks/useInitialSyncProgress.ts +134 -0
  37. package/src/hooks/useListCursor.ts +36 -5
  38. package/src/hooks/useMarkAsRead.ts +6 -3
  39. package/src/hooks/useMoveMessages.ts +6 -3
  40. package/src/hooks/useOrganizeWiden.ts +1 -2
  41. package/src/hooks/useRulePreview.ts +11 -1
  42. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  43. package/src/hooks/useSearchFilterSeed.ts +21 -3
  44. package/src/hooks/useSearchSuggestions.ts +126 -0
  45. package/src/hooks/useSelectedSubjects.ts +0 -0
  46. package/src/hooks/useSemanticSearch.ts +26 -7
  47. package/src/hooks/useThreadActions.ts +11 -2
  48. package/src/lib/brief.test.ts +67 -0
  49. package/src/lib/brief.ts +11 -1
  50. package/src/lib/bulk-actions.ts +29 -0
  51. package/src/lib/drafts.ts +1 -1
  52. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  53. package/src/lib/organize/clause-suggestions.ts +89 -0
  54. package/src/lib/organize/rule-model.test.ts +57 -0
  55. package/src/lib/organize/rule-model.ts +117 -38
  56. package/src/lib/organize/search-to-rule.test.ts +8 -28
  57. package/src/lib/organize/search-to-rule.ts +33 -89
  58. package/src/lib/organize/sender-fallback.test.ts +1 -97
  59. package/src/lib/organize/sender-fallback.ts +8 -71
  60. package/src/lib/search-result.ts +2 -0
  61. package/src/lib/search-suggestions.test.ts +249 -0
  62. package/src/lib/search-suggestions.ts +296 -0
  63. package/src/lib/search-token-index.test.ts +99 -0
  64. package/src/lib/search-token-index.ts +67 -1
  65. package/src/lib/search-tokens.test.ts +236 -0
  66. package/src/lib/search-tokens.ts +303 -36
  67. package/src/lib/thread-cache.ts +1 -1
  68. package/src/lib/thread-search-tokens.test.ts +193 -0
  69. package/src/lib/thread-search-tokens.ts +148 -0
  70. package/src/routes/onboarding.tsx +9 -4
@@ -4,16 +4,31 @@
4
4
  * Renders one section per message category (Personal / Transactional /
5
5
  * Newsletter / Marketing / Social / Automated) from the GET /threads endpoint.
6
6
  * Starred mail is not a section — Flagged is a virtual mailbox in the nav. The
7
- * brief defaults to the cross-account aggregate; the kit `BriefSections` owns
8
- * the filter row (categories + attribute chips + the account source group) and
9
- * the flatten-when-filtered behavior, while `MailListHeader` provides the title,
10
- * unread count, and search. Account switching also lives in the nav sidebar —
11
- * the account source group only appears when more than one account feeds the
12
- * brief.
7
+ * brief defaults to the cross-account aggregate, and `MailListHeader` provides
8
+ * the title, unread count, and search.
9
+ *
10
+ * The list's filter control (categories + attribute chips + the account source
11
+ * group) is hidden the brief is being tried without it. The kit
12
+ * `BriefSections` renders that control above the list body, so the body is
13
+ * rendered here by `BriefListBody` instead; every input the control consumed is
14
+ * still computed below, so bringing it back is swapping `BriefListBody` for
15
+ * `<BriefSections … />`. Account switching also lives in the nav sidebar, and
16
+ * the phone search takeover keeps its own copy of the filter sheet.
17
+ *
18
+ * Multi-select is the mailbox list's, not a copy of it: the same
19
+ * `ThreadListInteraction` cursor and `useSelection` state, the same
20
+ * `SelectionToolbar` replacing the pane header on desktop, and the same
21
+ * peeking `SelectionSheet` on touch. A shift-range spans the rendered rows in
22
+ * document order, so it crosses category sections exactly as the eye reads
23
+ * them and never picks up a row hidden behind "Show N more" or a collapsed
24
+ * header. Folder-scoped verbs (Move, Apply label, Organize) resolve their
25
+ * account and source folder from the selection — see
26
+ * `resolveBriefSelectionScope`.
13
27
  *
14
28
  * Loading: skeleton rows on first paint, patch-in-place on refetch.
15
29
  * Error: per-section; the brief still renders other sections.
16
- * Empty: "You're caught up" message.
30
+ * Empty: "You're caught up", but only once the server confirms no sync is
31
+ * running — while one is, the same empty list says it is still syncing.
17
32
  */
18
33
  import {
19
34
  mailboxOperationsListMailboxesOptions,
@@ -22,20 +37,36 @@ import {
22
37
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
23
38
  import {
24
39
  type BriefCategoryFilter,
25
- BriefSections,
40
+ BriefEmpty,
41
+ BriefSection,
26
42
  briefFilterConfig,
27
43
  type FilterSheetProps,
28
44
  type FilterSheetSource,
29
45
  KeyboardHintBar,
46
+ SELECTION_SHEET_TEASER_HEIGHT,
30
47
  type SearchResult,
48
+ SelectionSheet,
31
49
  type ThreadRowData,
32
50
  type ThreadSection,
33
51
  } from "@remit/ui";
34
52
  import { useQueries, useQuery } from "@tanstack/react-query";
35
53
  import { useNavigate } from "@tanstack/react-router";
36
- import { AlertCircle, RefreshCw, Sparkles } from "lucide-react";
37
- import { type RefObject, useCallback, useMemo, useState } from "react";
54
+ import { AlertCircle, RefreshCw } from "lucide-react";
55
+ import {
56
+ type ReactNode,
57
+ type RefObject,
58
+ useCallback,
59
+ useEffect,
60
+ useMemo,
61
+ useState,
62
+ } from "react";
63
+ import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
64
+ import {
65
+ isSyncingPhase,
66
+ useInitialSyncProgress,
67
+ } from "@/hooks/useInitialSyncProgress";
38
68
  import { useIsDesktop } from "@/hooks/useMediaQuery";
69
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
39
70
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
40
71
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
41
72
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
@@ -52,12 +83,18 @@ import { isServerError } from "@/lib/error-classifier";
52
83
  import { useMailContext } from "@/lib/mail-context";
53
84
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
54
85
  import { parseSearchTokens } from "@/lib/search-tokens";
55
- import { MailListHeader } from "./MailListHeader";
86
+ import { shouldShowSelectionSheet } from "@/lib/selection-sheet-mode";
87
+ import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
56
88
  import type { MessageListCommands } from "./MessageList";
57
89
  import { MessageRow } from "./MessageRow";
90
+ import { MoveToTrigger } from "./MoveToTrigger";
91
+ import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
92
+ import { OrganizeDialog } from "./organize/OrganizeDialog";
93
+ import { SelectionToolbar } from "./SelectionToolbar";
58
94
  import {
95
+ type OpenMessageOptions,
59
96
  ThreadListInteraction,
60
- ThreadListSelectionBar,
97
+ useThreadListSelection,
61
98
  } from "./ThreadListInteraction";
62
99
 
63
100
  /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
@@ -127,6 +164,355 @@ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
127
164
  );
128
165
  };
129
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // List body (the kit `BriefSections` body without its filter control)
169
+ // ---------------------------------------------------------------------------
170
+
171
+ interface BriefListBodyProps {
172
+ sections: ThreadSection[];
173
+ /** Category scope. "all" keeps the per-category sections; anything else flattens. */
174
+ briefCategory: BriefCategoryFilter;
175
+ selectedThreadId?: string;
176
+ onSelectThread?: (id: string) => void;
177
+ }
178
+
179
+ /**
180
+ * The brief's list body: one capped section per category at the "all" scope, a
181
+ * headerless flat list once narrowed to a single category (the section headers
182
+ * are redundant there).
183
+ *
184
+ * This is the kit `BriefSections` body minus the filter control it renders above
185
+ * it. The category scope still arrives from the brief — the phone search
186
+ * takeover's filter sheet sets it — so the flatten path stays reachable; the
187
+ * attribute chips are part of the hidden control and narrow nothing here.
188
+ */
189
+ function BriefListBody({
190
+ sections,
191
+ briefCategory,
192
+ selectedThreadId,
193
+ onSelectThread,
194
+ }: BriefListBodyProps) {
195
+ const showSections = briefCategory === "all";
196
+ const flatRows = sections
197
+ .flatMap((section) => section.threads)
198
+ .filter((thread) => thread.category === briefCategory);
199
+ const empty = showSections ? sections.length === 0 : flatRows.length === 0;
200
+
201
+ return (
202
+ <div className="h-full overflow-y-auto">
203
+ {showSections ? (
204
+ sections.map((section) => (
205
+ <BriefSection
206
+ key={section.id}
207
+ section={section}
208
+ Row={MessageRow}
209
+ selectedThreadId={selectedThreadId}
210
+ onSelectThread={onSelectThread}
211
+ />
212
+ ))
213
+ ) : (
214
+ <div className="divide-y divide-line">
215
+ {flatRows.map((thread) => (
216
+ <MessageRow
217
+ key={thread.id}
218
+ thread={thread}
219
+ active={thread.id === selectedThreadId}
220
+ onClick={() => onSelectThread?.(thread.id)}
221
+ />
222
+ ))}
223
+ </div>
224
+ )}
225
+ {empty && (
226
+ <div className="px-row-inset py-6 text-center text-2xs text-fg-subtle">
227
+ No threads match these filters.
228
+ </div>
229
+ )}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Selection surface (the mailbox list's, mounted on the brief)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export interface BriefSelectionScope {
239
+ /** Owning account, when every selected row shares one. */
240
+ accountId?: string;
241
+ /** Source folder, when every selected row shares one. */
242
+ mailboxId?: string;
243
+ /** Why folder-scoped verbs are withheld, in the toolbar's own words. */
244
+ moveDisabledHint?: string;
245
+ }
246
+
247
+ /**
248
+ * The account and source folder a brief selection resolves to.
249
+ *
250
+ * The mailbox list gets these from the route and scopes Move, Apply label and
251
+ * Organize to them. The brief spans accounts and folders, so it resolves them
252
+ * from the selection itself and withholds those verbs — with the reason on
253
+ * screen — whenever the selection spans more than one. Delete and mark-read
254
+ * carry no such scope and are always offered.
255
+ *
256
+ * Pure so the guard is unit-testable without a DOM.
257
+ */
258
+ export const resolveBriefSelectionScope = (
259
+ rows: readonly ThreadRowData[],
260
+ selectedIds: ReadonlySet<string>,
261
+ ): BriefSelectionScope => {
262
+ if (selectedIds.size === 0) return {};
263
+ const accountIds = new Set<string>();
264
+ const mailboxIds = new Set<string>();
265
+ for (const row of rows) {
266
+ if (!selectedIds.has(row.id)) continue;
267
+ if (row.accountId) accountIds.add(row.accountId);
268
+ if (row.mailboxId) mailboxIds.add(row.mailboxId);
269
+ }
270
+ if (accountIds.size > 1) {
271
+ return {
272
+ moveDisabledHint:
273
+ "Move only works within one account — clear selection or pick messages from a single account",
274
+ };
275
+ }
276
+ const accountId = accountIds.size === 1 ? [...accountIds][0] : undefined;
277
+ if (mailboxIds.size > 1) {
278
+ return {
279
+ accountId,
280
+ moveDisabledHint: `Move only works within one folder — this selection spans ${mailboxIds.size} folders`,
281
+ };
282
+ }
283
+ return {
284
+ accountId,
285
+ mailboxId: mailboxIds.size === 1 ? [...mailboxIds][0] : undefined,
286
+ };
287
+ };
288
+
289
+ interface BriefSelectionChromeProps {
290
+ /** Everything the header needs when no selection is active. */
291
+ header: Omit<
292
+ MailListHeaderProps,
293
+ "children" | "selectionBar" | "selectionSheet"
294
+ >;
295
+ /** The rows the list is showing, for resolving the selection's scope. */
296
+ rows: readonly ThreadRowData[];
297
+ isDesktop: boolean;
298
+ onMarkMessagesRead?: (messageIds: string[]) => void;
299
+ children: ReactNode;
300
+ }
301
+
302
+ /**
303
+ * The brief's selection chrome: the same two surfaces the mailbox list raises,
304
+ * in the same places. Desktop replaces the pane header with `SelectionToolbar`;
305
+ * touch leaves the header alone and raises the peeking `SelectionSheet` at two
306
+ * or more selected. The selection itself comes from the enclosing
307
+ * `ThreadListInteraction`, so the brief and the mailbox list run one model.
308
+ */
309
+ function BriefSelectionChrome({
310
+ header,
311
+ rows,
312
+ isDesktop,
313
+ onMarkMessagesRead,
314
+ children,
315
+ }: BriefSelectionChromeProps) {
316
+ const {
317
+ selectedIds,
318
+ selectedCount,
319
+ exitSelection,
320
+ requestDeleteSelection,
321
+ orderedIds,
322
+ allSelected,
323
+ toggleAllLoaded,
324
+ } = useThreadListSelection();
325
+
326
+ const scope = useMemo(
327
+ () => resolveBriefSelectionScope(rows, selectedIds),
328
+ [rows, selectedIds],
329
+ );
330
+ const { junkMailboxId } = useJunkMailbox(scope.accountId);
331
+ const { moveMessages, isPending: isMoving } = useMoveMessages({
332
+ mailboxId: scope.mailboxId ?? "",
333
+ accountId: scope.accountId,
334
+ });
335
+
336
+ const [organizeOpen, setOrganizeOpen] = useState(false);
337
+ const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
338
+ "select-similar" | "something-else" | null
339
+ >(null);
340
+
341
+ // An emptied selection takes the flows it opened with it, so a later
342
+ // re-selection can't reopen one on a stale entry.
343
+ useEffect(() => {
344
+ if (selectedCount > 0) return;
345
+ setOrganizeOpen(false);
346
+ setMobileOrganizeEntry(null);
347
+ }, [selectedCount]);
348
+
349
+ const selectedMessageIds = useMemo(
350
+ () => Array.from(selectedIds),
351
+ [selectedIds],
352
+ );
353
+ const selectedSenders = useMemo(() => {
354
+ const emails: string[] = [];
355
+ for (const row of rows) {
356
+ if (selectedIds.has(row.id) && row.fromEmail) emails.push(row.fromEmail);
357
+ }
358
+ return emails;
359
+ }, [rows, selectedIds]);
360
+
361
+ const handleMarkAsRead = useCallback(() => {
362
+ onMarkMessagesRead?.(selectedMessageIds);
363
+ exitSelection();
364
+ }, [onMarkMessagesRead, selectedMessageIds, exitSelection]);
365
+
366
+ const handleMove = useCallback(
367
+ (destinationMailboxId: string) => {
368
+ if (selectedMessageIds.length === 0) return;
369
+ moveMessages(selectedMessageIds, destinationMailboxId);
370
+ exitSelection();
371
+ },
372
+ [moveMessages, selectedMessageIds, exitSelection],
373
+ );
374
+
375
+ const handleJunk = useCallback(() => {
376
+ if (junkMailboxId) handleMove(junkMailboxId);
377
+ }, [junkMailboxId, handleMove]);
378
+
379
+ const scoped = !!scope.accountId && !!scope.mailboxId;
380
+ const canJunk =
381
+ scoped && !!junkMailboxId && junkMailboxId !== scope.mailboxId;
382
+
383
+ // One select-all for both surfaces: the desktop toolbar and the touch sheet
384
+ // offer the same control over the same rendered rows, so the verb a phone
385
+ // can reach is the verb a desktop can reach.
386
+ const selectAll = useMemo(
387
+ () =>
388
+ orderedIds.length > 0
389
+ ? {
390
+ checked: allSelected,
391
+ indeterminate: selectedCount > 0 && !allSelected,
392
+ onChange: toggleAllLoaded,
393
+ }
394
+ : undefined,
395
+ [orderedIds.length, allSelected, selectedCount, toggleAllLoaded],
396
+ );
397
+
398
+ const selectionBar =
399
+ isDesktop && selectedCount > 0 ? (
400
+ <SelectionToolbar
401
+ selectedCount={selectedCount}
402
+ onDelete={requestDeleteSelection}
403
+ onClearSelection={exitSelection}
404
+ onMarkAsRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
405
+ onMove={scoped ? handleMove : undefined}
406
+ onOrganize={() => setOrganizeOpen(true)}
407
+ isMoving={isMoving}
408
+ accountId={scope.accountId}
409
+ currentMailboxId={scope.mailboxId}
410
+ selectedMessageIds={selectedMessageIds}
411
+ moveDisabledHint={scope.moveDisabledHint}
412
+ selectAll={selectAll}
413
+ />
414
+ ) : undefined;
415
+
416
+ // The mobile sheet has no inline hint slot, so the scope restriction rides
417
+ // in as a notice — the same fold-in the mailbox list does.
418
+ const notice = scope.moveDisabledHint
419
+ ? { tone: "warning" as const, text: scope.moveDisabledHint }
420
+ : undefined;
421
+ const showSheet =
422
+ !isDesktop &&
423
+ selectedCount > 0 &&
424
+ shouldShowSelectionSheet(selectedCount, "idle", notice !== undefined);
425
+
426
+ const organizeFlow =
427
+ mobileOrganizeEntry &&
428
+ scope.accountId &&
429
+ !isDesktop &&
430
+ selectedCount > 0 ? (
431
+ <MobileOrganizeFlow
432
+ entry={mobileOrganizeEntry}
433
+ accountId={scope.accountId}
434
+ selectedMessageIds={selectedMessageIds}
435
+ selectedSenders={selectedSenders}
436
+ junkMailboxId={junkMailboxId}
437
+ onClose={() => {
438
+ setMobileOrganizeEntry(null);
439
+ exitSelection();
440
+ }}
441
+ />
442
+ ) : undefined;
443
+
444
+ const selectionSheet =
445
+ showSheet || organizeFlow ? (
446
+ <>
447
+ {showSheet && (
448
+ <SelectionSheet
449
+ count={selectedCount}
450
+ onCancel={exitSelection}
451
+ onDelete={requestDeleteSelection}
452
+ onJunk={canJunk ? handleJunk : undefined}
453
+ onMarkRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
454
+ onSelectSimilar={
455
+ scoped
456
+ ? () => setMobileOrganizeEntry("select-similar")
457
+ : undefined
458
+ }
459
+ onSomethingElse={
460
+ scoped
461
+ ? () => setMobileOrganizeEntry("something-else")
462
+ : undefined
463
+ }
464
+ moveSlot={
465
+ scoped && scope.accountId && scope.mailboxId ? (
466
+ <MoveToTrigger
467
+ accountId={scope.accountId}
468
+ currentMailboxId={scope.mailboxId}
469
+ onMove={isMoving ? () => {} : handleMove}
470
+ label="Move selected messages"
471
+ />
472
+ ) : undefined
473
+ }
474
+ isBusy={isMoving}
475
+ selectAll={selectAll}
476
+ notice={notice}
477
+ />
478
+ )}
479
+ {organizeFlow}
480
+ </>
481
+ ) : undefined;
482
+
483
+ return (
484
+ <>
485
+ <MailListHeader
486
+ {...header}
487
+ selectionBar={selectionBar}
488
+ selectionSheet={selectionSheet}
489
+ >
490
+ {/* Shrink the body by the teaser's height rather than letting the
491
+ last rows hide behind it. */}
492
+ <div
493
+ className="h-full"
494
+ style={
495
+ showSheet
496
+ ? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
497
+ : undefined
498
+ }
499
+ >
500
+ {children}
501
+ </div>
502
+ </MailListHeader>
503
+ {organizeOpen && scope.accountId && (
504
+ <OrganizeDialog
505
+ open={organizeOpen}
506
+ accountId={scope.accountId}
507
+ selectedMessageIds={selectedMessageIds}
508
+ selectedSenders={selectedSenders}
509
+ onClose={() => setOrganizeOpen(false)}
510
+ />
511
+ )}
512
+ </>
513
+ );
514
+ }
515
+
130
516
  // ---------------------------------------------------------------------------
131
517
  // Main component
132
518
  // ---------------------------------------------------------------------------
@@ -135,7 +521,7 @@ interface DailyBriefProps {
135
521
  accounts: RemitImapAccountResponse[];
136
522
  selectedMessageId?: string;
137
523
  /** Opens an in-list brief row (resolved by messageId against the loaded list). */
138
- onSelectMessage?: (id: string) => void;
524
+ onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
139
525
  /**
140
526
  * Opens a search result. A semantic "Related" hit carries its thread + mailbox
141
527
  * so it opens even when its message isn't in the loaded brief list.
@@ -168,19 +554,18 @@ export function DailyBrief({
168
554
  [accounts],
169
555
  );
170
556
 
171
- // "all" = the cross-account aggregate (the brief's default). Account
172
- // switching also lives in the nav sidebar; this source group is a convenience
173
- // shown only when more than one account feeds the brief. The category axis and
174
- // attribute chips are owned by the kit `BriefSections` filter row; the brief
175
- // only controls the category (so it can drive the flatten-when-filtered path)
176
- // and the account source.
557
+ // "all" = the cross-account aggregate (the brief's default), and "all"
558
+ // categories = the full set of sections. Both stay at their default while the
559
+ // list's filter control is hidden; the phone search takeover still drives them
560
+ // (the category also drives the flatten-when-filtered path), and account
561
+ // switching lives in the nav sidebar.
177
562
  const [selectedAccountId, setSelectedAccountId] = useState("all");
178
563
  const [selectedCategory, setSelectedCategory] =
179
564
  useState<BriefCategoryFilter>("all");
180
565
 
181
- // Attribute chips for the phone search takeover. The brief list's own chips
182
- // live inside the kit `BriefSections`; the takeover is a separate surface, so
183
- // it carries its own additive set (category + account are shared above).
566
+ // Attribute chips for the phone search takeover a separate surface from the
567
+ // list, so it carries its own additive set (category + account are shared
568
+ // above) and keeps its filter sheet while the list's control is hidden.
184
569
  const [searchAttributes, setSearchAttributes] = useState<ReadonlySet<string>>(
185
570
  new Set(),
186
571
  );
@@ -272,9 +657,8 @@ export function DailyBrief({
272
657
  // Convert API rows to ThreadRowData, narrowing only by the selected account
273
658
  // and the free-text search plus any filter tokens (`from:`, `has:attachment`,
274
659
  // `is:unread`, `before:`/`after:`, `in:`, `account:`) parsed out of the
275
- // query. The category axis and the attribute chips are the kit
276
- // `BriefSections` filter row's job, so the full per-category sections are
277
- // handed to it; it groups, narrows, and flattens.
660
+ // query. The category axis is applied further down, on the grouped sections,
661
+ // so the list body can flatten them when narrowed to one category.
278
662
  const filteredRows = useMemo<ThreadRowData[]>(() => {
279
663
  const briefRows = excludeMutedSenders(threadsData?.items ?? []).map(
280
664
  toThreadRowData,
@@ -395,15 +779,38 @@ export function DailyBrief({
395
779
 
396
780
  // The brief is genuinely empty (caught up) only when nothing is narrowing the
397
781
  // view: no account source and no search. When a source/search yields nothing,
398
- // the BriefSections filter row stays so the user can clear it.
782
+ // the list says so instead, so the narrowing is still visible as the reason.
399
783
  const caughtUp =
400
784
  sections.length === 0 && selectedAccountId === "all" && sq.length === 0;
401
785
 
402
- const stateBody = isLoading ? (
786
+ // "Caught up" is a claim about the user's mail, so it may only be made once
787
+ // the server has confirmed no sync is running (#452). The config snapshot
788
+ // that carries these accounts is fetched once (staleTime Infinity), so its
789
+ // syncPhase is a seed rather than a reading: it says a sync WAS running when
790
+ // the accounts loaded, which is enough to start the live poll before the list
791
+ // paints — the post-onboarding case that produced the false claim.
792
+ const briefAccountIds = useMemo(
793
+ () => nonMuted.map((a) => a.accountId),
794
+ [nonMuted],
795
+ );
796
+ const seededSyncing = useMemo(
797
+ () => nonMuted.some((a) => isSyncingPhase(a.syncPhase)),
798
+ [nonMuted],
799
+ );
800
+ const syncProgress = useInitialSyncProgress(
801
+ briefAccountIds,
802
+ caughtUp || seededSyncing,
803
+ );
804
+
805
+ const briefSkeleton = (
403
806
  <div className="h-full overflow-y-auto">
404
807
  <SectionSkeleton />
405
808
  <SectionSkeleton />
406
809
  </div>
810
+ );
811
+
812
+ const stateBody = isLoading ? (
813
+ briefSkeleton
407
814
  ) : isError ? (
408
815
  <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-sm text-fg-muted">
409
816
  <AlertCircle className="size-8 text-danger" />
@@ -418,63 +825,64 @@ export function DailyBrief({
418
825
  </button>
419
826
  </div>
420
827
  ) : caughtUp ? (
421
- <div className="flex h-full flex-col items-center justify-center gap-2 py-12 text-center px-4">
422
- <Sparkles className="size-8 text-fg-subtle" />
423
- <p className="text-sm font-medium text-fg">You're caught up</p>
424
- <p className="text-xs text-fg-subtle">Nothing needs attention.</p>
425
- </div>
828
+ syncProgress.resolved ? (
829
+ <BriefEmpty
830
+ sync={
831
+ syncProgress.syncing
832
+ ? { synced: syncProgress.synced, total: syncProgress.total }
833
+ : undefined
834
+ }
835
+ />
836
+ ) : (
837
+ briefSkeleton
838
+ )
426
839
  ) : (
840
+ <BriefListBody
841
+ sections={sections}
842
+ briefCategory={selectedCategory}
843
+ selectedThreadId={selectedMessageId}
844
+ onSelectThread={onSelectMessage}
845
+ />
846
+ );
847
+
848
+ // The cursor and selection wrap the whole pane, not just the list body: the
849
+ // selection toolbar takes the header's place while rows are selected, so it
850
+ // has to sit inside the same provider the rows do.
851
+ return (
427
852
  <ThreadListInteraction
428
853
  selectedMessageId={selectedMessageId}
429
- onOpen={(id) => onSelectMessage?.(id)}
854
+ onOpen={(id, options) => onSelectMessage?.(id, options)}
430
855
  onDeleteMessages={onDeleteMessages}
431
856
  commandsRef={commandsRef}
432
857
  onTriageContextChange={onTriageContextChange}
433
858
  >
434
- <div className="flex h-full min-h-0 flex-col">
435
- {onDeleteMessages ? (
436
- <ThreadListSelectionBar onMarkAsRead={onMarkMessagesRead} />
437
- ) : null}
438
- <div className="min-h-0 flex-1">
439
- <BriefSections
440
- sections={sections}
441
- Row={MessageRow}
442
- manageFocus={false}
443
- briefCategory={selectedCategory}
444
- onSelectBriefCategory={setSelectedCategory}
445
- sources={accountSources}
446
- sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
447
- onSelectSource={setSelectedAccountId}
448
- selectedThreadId={selectedMessageId}
449
- onSelectThread={onSelectMessage}
450
- />
859
+ <BriefSelectionChrome
860
+ header={{
861
+ title: "Daily brief",
862
+ unreadCount: totalUnseen,
863
+ footer: isDesktop ? <KeyboardHintBar /> : undefined,
864
+ searchFilter: searchFilterConfig,
865
+ searchResults,
866
+ searchLoading: isLoading || searchFetching,
867
+ relatedResults,
868
+ relatedLoading,
869
+ onSelectSearchResult,
870
+ }}
871
+ rows={filteredRows}
872
+ isDesktop={isDesktop}
873
+ onMarkMessagesRead={onMarkMessagesRead}
874
+ >
875
+ <div className="flex h-full flex-col">
876
+ {failedAccounts.map((account) => (
877
+ <ErrorBanner
878
+ key={account.accountId}
879
+ accountEmail={account.email}
880
+ accountId={account.accountId}
881
+ />
882
+ ))}
883
+ <div className="min-h-0 flex-1">{stateBody}</div>
451
884
  </div>
452
- </div>
885
+ </BriefSelectionChrome>
453
886
  </ThreadListInteraction>
454
887
  );
455
-
456
- return (
457
- <MailListHeader
458
- title="Daily brief"
459
- unreadCount={totalUnseen}
460
- footer={isDesktop ? <KeyboardHintBar /> : undefined}
461
- searchFilter={searchFilterConfig}
462
- searchResults={searchResults}
463
- searchLoading={isLoading || searchFetching}
464
- relatedResults={relatedResults}
465
- relatedLoading={relatedLoading}
466
- onSelectSearchResult={onSelectSearchResult}
467
- >
468
- <div className="flex h-full flex-col">
469
- {failedAccounts.map((account) => (
470
- <ErrorBanner
471
- key={account.accountId}
472
- accountEmail={account.email}
473
- accountId={account.accountId}
474
- />
475
- ))}
476
- <div className="min-h-0 flex-1">{stateBody}</div>
477
- </div>
478
- </MailListHeader>
479
- );
480
888
  }
@@ -38,6 +38,7 @@ import { MailViewChrome } from "./MailViewChrome";
38
38
  import type { MessageListCommands } from "./MessageList";
39
39
  import { MessageRow } from "./MessageRow";
40
40
  import {
41
+ type OpenMessageOptions,
41
42
  ThreadListInteraction,
42
43
  ThreadListSelectionBar,
43
44
  } from "./ThreadListInteraction";
@@ -49,7 +50,7 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
49
50
 
50
51
  interface FlaggedListProps {
51
52
  selectedMessageId?: string;
52
- onSelectMessage?: (id: string) => void;
53
+ onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
53
54
  /** Where the list publishes the commands the keyboard layer drives. */
54
55
  commandsRef?: RefObject<MessageListCommands | null>;
55
56
  /** Cursor / selection / display order, reported up to the triage layer. */
@@ -148,7 +149,7 @@ export function FlaggedList({
148
149
  const listBody = (
149
150
  <ThreadListInteraction
150
151
  selectedMessageId={selectedMessageId}
151
- onOpen={(id) => onSelectMessage?.(id)}
152
+ onOpen={(id, options) => onSelectMessage?.(id, options)}
152
153
  onDeleteMessages={onDeleteMessages}
153
154
  commandsRef={commandsRef}
154
155
  onTriageContextChange={onTriageContextChange}