@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
@@ -35,6 +35,7 @@ import { ConversationView } from "@/components/mail/ConversationView";
35
35
  import { FlaggedList } from "@/components/mail/FlaggedList";
36
36
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
37
37
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
38
+ import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
38
39
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
39
40
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
40
41
  import { useStarredThreads } from "@/hooks/useStarredThreads";
@@ -53,7 +54,7 @@ import { useMailContext } from "@/lib/mail-context";
53
54
  interface FlaggedPaneContextValue {
54
55
  selectedMessageId: string | undefined;
55
56
  selectedThread: RemitImapThreadMessageResponse | undefined;
56
- onSelectMessage: (id: string) => void;
57
+ onSelectMessage: (id: string, options?: OpenMessageOptions) => void;
57
58
  onCloseThread: () => void;
58
59
  /**
59
60
  * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
@@ -99,10 +100,11 @@ function FlaggedPaneProvider({
99
100
  }, [threads, selectedMessageId]);
100
101
 
101
102
  const handleSelectMessage = useCallback(
102
- (id: string) => {
103
+ (id: string, options?: OpenMessageOptions) => {
103
104
  navigate({
104
105
  to: "/mail/flagged",
105
106
  search: (prev) => ({ ...prev, selectedMessageId: id }),
107
+ replace: options?.replace,
106
108
  });
107
109
  },
108
110
  [navigate],
@@ -11,8 +11,19 @@
11
11
  * On phone the magnifier opens a full-screen `MobileSearchView` takeover instead
12
12
  * of expanding over the title; tablet and desktop keep the inline header search
13
13
  * and, once a query is present, swap the list-pane body to the same kit
14
- * `SearchResults` sections under the shared `FilterSheet`. Consumers feed the
15
- * filter chrome and query-narrowed results; both tiers render identical rows.
14
+ * `SearchResults` sections. Consumers feed the filter chrome and query-narrowed
15
+ * results; both tiers render identical rows.
16
+ *
17
+ * The field offers what the search API can be told: token names for a bare word,
18
+ * that token's values once its name is committed. The list sits under the field
19
+ * on both tiers, in flow, so it takes its own space instead of covering the
20
+ * query it completes.
21
+ *
22
+ * An active query owns the pane. The filter chrome is not rendered while one is
23
+ * present — the two narrow the same list by the same intent and would sit in the
24
+ * same place — and its place is taken by `MakeFilterAction`, which the pane mounts
25
+ * itself so it is there for every search on every view, whichever body is showing
26
+ * the results.
16
27
  *
17
28
  * Results split into two sections, one per engine: literal/instant and semantic.
18
29
  * The consumer dedupes them — a thread in both appears only under the literal
@@ -28,28 +39,40 @@
28
39
  * `resultsScopeForRoute` and `lib/spam-offer.ts`.
29
40
  */
30
41
  import {
31
- FilterSheet,
32
42
  type FilterSheetProps,
43
+ isConvertible,
33
44
  MailHeader,
45
+ MakeFilterAction,
34
46
  MobileSearchView,
47
+ type SearchCaretRequest,
48
+ type SearchFieldSuggest,
35
49
  type SearchResult,
36
50
  type SearchResultSection,
37
51
  SearchResults,
38
52
  type SearchScope,
53
+ type Suggestion,
54
+ SuggestList,
39
55
  useAppShellLayout,
56
+ useSuggestList,
40
57
  } from "@remit/ui";
41
58
  import { useNavigate, useRouterState } from "@tanstack/react-router";
42
- import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
59
+ import {
60
+ type ReactNode,
61
+ useCallback,
62
+ useEffect,
63
+ useMemo,
64
+ useRef,
65
+ useState,
66
+ } from "react";
43
67
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
44
68
  import { useSearchScope } from "@/hooks/useSearchScope";
69
+ import { useSearchSuggestions } from "@/hooks/useSearchSuggestions";
45
70
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
46
71
  import { useMailContext } from "@/lib/mail-context";
47
- import {
48
- convertSearchToRule,
49
- isConvertible,
50
- } from "@/lib/organize/search-to-rule";
72
+ import { convertSearchToRule } from "@/lib/organize/search-to-rule";
51
73
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
52
74
  import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
75
+ import { applySearchSuggestion } from "@/lib/search-suggestions";
53
76
  import { showInlineSearchResults } from "@/lib/search-surface";
54
77
  import {
55
78
  parseSearchTokens,
@@ -59,11 +82,22 @@ import {
59
82
  import { spamOfferForResults } from "@/lib/spam-offer";
60
83
  import { SearchFilterDialog } from "./organize/SearchFilterDialog";
61
84
 
62
- interface MailListHeaderProps {
85
+ export interface MailListHeaderProps {
63
86
  title: string;
64
87
  unreadCount: number;
65
88
  /** The list body (filter sheet / sections / virtualized rows). */
66
89
  children: ReactNode;
90
+ /**
91
+ * Replaces the header for as long as a selection is active — the same slot
92
+ * contract the kit `MessageListPane` gives the mailbox list, so every list
93
+ * puts its bulk-action bar in the same place.
94
+ */
95
+ selectionBar?: ReactNode;
96
+ /**
97
+ * Overlay anchored to the bottom of the pane (the mobile selection sheet).
98
+ * The pane is the positioned ancestor the sheet measures against.
99
+ */
100
+ selectionSheet?: ReactNode;
67
101
  /** Pinned below the scrollable list (e.g. the keyboard hint bar). */
68
102
  footer?: ReactNode;
69
103
  /** Filter chrome for the phone search takeover. Omit to drop the filter row. */
@@ -99,6 +133,8 @@ export function MailListHeader({
99
133
  title,
100
134
  unreadCount,
101
135
  children,
136
+ selectionBar,
137
+ selectionSheet,
102
138
  footer,
103
139
  searchFilter,
104
140
  searchResults,
@@ -137,6 +173,68 @@ export function MailListHeader({
137
173
  setSearchOpen(false);
138
174
  }, [searchViewKey]);
139
175
 
176
+ // What the box offers for the term being typed. The field says where the
177
+ // caret is; the caret decides whether the offer is a token name or one
178
+ // token's values, and picking one replaces that term and leaves the caret
179
+ // ready for the next. Typing always wins — the list only ever inserts, and a
180
+ // term it has nothing for leaves the field the plain text box it is.
181
+ //
182
+ // An empty field belongs to the recent searches and the filter row, as it
183
+ // does everywhere else in this pane, so the offer starts with the first
184
+ // character typed. The caret on the whitespace after a finished token is a
185
+ // term of its own, which is where the whole vocabulary is offered again.
186
+ const [caretPosition, setCaretPosition] = useState(0);
187
+ const [fieldFocused, setFieldFocused] = useState(false);
188
+ const [caretRequest, setCaretRequest] = useState<
189
+ SearchCaretRequest | undefined
190
+ >(undefined);
191
+ const suggestions = useSearchSuggestions({
192
+ query: searchInput,
193
+ cursor: caretPosition,
194
+ enabled: fieldFocused && searchInput.trim().length > 0,
195
+ });
196
+ const applySuggestion = useCallback(
197
+ (suggestion: Suggestion) => {
198
+ const applied = applySearchSuggestion(
199
+ searchInput,
200
+ caretPosition,
201
+ suggestion,
202
+ );
203
+ onSearchChange(applied.query);
204
+ setCaretRequest({ cursor: applied.cursor });
205
+ },
206
+ [searchInput, caretPosition, onSearchChange],
207
+ );
208
+ const suggest = useSuggestList({
209
+ count: suggestions.length,
210
+ onAccept: (index) => {
211
+ const suggestion = suggestions[index];
212
+ if (suggestion) applySuggestion(suggestion);
213
+ },
214
+ });
215
+ const searchSuggest: SearchFieldSuggest = {
216
+ comboboxProps: suggest.comboboxProps,
217
+ onKeyDown: suggest.handleKeyDown,
218
+ onCaretChange: setCaretPosition,
219
+ onFocusChange: setFieldFocused,
220
+ ...(caretRequest ? { caret: caretRequest } : {}),
221
+ };
222
+ // Under the field and in flow, on both tiers: a phone's soft keyboard owns
223
+ // the lower half of the screen, and a list floating over the field would
224
+ // cover the query it is completing.
225
+ const suggestList = suggest.open ? (
226
+ <SuggestList
227
+ id={suggest.listId}
228
+ suggestions={suggestions}
229
+ activeIndex={suggest.activeIndex}
230
+ optionId={suggest.optionId}
231
+ onPick={applySuggestion}
232
+ onHighlight={suggest.setActiveIndex}
233
+ label="Search suggestions"
234
+ className="mx-row-inset mt-1 shrink-0"
235
+ />
236
+ ) : null;
237
+
140
238
  const hasQuery = searchInput.trim().length > 0;
141
239
  // Filter tokens (`from:`, `has:attachment`, `account:`, …) parsed live from
142
240
  // the typed query render as removable chips above the sections; removing one
@@ -212,9 +310,19 @@ export function MailListHeader({
212
310
  // an `account:` facet names, else the primary account. The literal filter
213
311
  // cannot reproduce the search's semantic reach, so the conversion states it
214
312
  // whenever the search surfaced a "Related" section — a direct signal, read
215
- // here from the semantic results, never a capability probe. Offered whenever a
216
- // query is active, disabled with a reason when the search has no clause to
217
- // filter on (only non-clause facets, or a bare folder scope).
313
+ // here from the semantic results, never a capability probe. Disabled with a
314
+ // reason when the search has no clause to filter on (only non-clause facets,
315
+ // or a bare folder scope).
316
+ //
317
+ // It belongs to the search, not to any one way of showing it. The affordance
318
+ // therefore sits in the pane, above whichever body is up — the read-only
319
+ // results panel, or a list whose own rows narrow to the committed query — and
320
+ // is mounted on the same condition as the query itself. Rendering it inside the
321
+ // results panel made it a mailbox's flash: the panel shows while the query is
322
+ // being typed and hands back to `MessageList` the moment the query commits to
323
+ // the URL, taking the affordance down with it a few hundred milliseconds after
324
+ // it appeared, and leaving the brief (which keeps the panel for any query) as
325
+ // the only place it survived.
218
326
  const accountToken = parsed.tokens.find((token) => token.type === "account");
219
327
  const targetAccountId = accountToken?.accountId ?? accounts[0]?.accountId;
220
328
  const searchHadSemanticReach = related.length > 0;
@@ -229,6 +337,10 @@ export function MailListHeader({
229
337
  : "Add a sender or words to filter on",
230
338
  }
231
339
  : undefined;
340
+ // A selection replaces the header with its bulk-action bar and owns the pane;
341
+ // the search affordance stands down until the selection clears.
342
+ const makeFilterAction =
343
+ makeFilter && !selectionBar ? <MakeFilterAction {...makeFilter} /> : null;
232
344
  const filterDialog =
233
345
  filterOpen && targetAccountId ? (
234
346
  <SearchFilterDialog
@@ -265,6 +377,8 @@ export function MailListHeader({
265
377
  onSelectResult={handleSelectResult}
266
378
  tokens={tokenChips}
267
379
  scope={resultsScope}
380
+ suggest={searchSuggest}
381
+ suggestList={suggestList}
268
382
  />
269
383
  {filterDialog}
270
384
  </>
@@ -296,41 +410,46 @@ export function MailListHeader({
296
410
  onSelectResult={handleSelectInlineResult}
297
411
  tokens={tokenChips}
298
412
  scope={resultsScope}
299
- makeFilter={makeFilter}
300
413
  />
301
414
  );
302
- const body = !showInlineResults ? (
303
- children
304
- ) : searchFilter ? (
305
- <FilterSheet {...searchFilter}>{results}</FilterSheet>
306
- ) : (
415
+ // The filter chrome is not rendered while a query is active — see `makeFilter`
416
+ // above — so the results panel gets the plain scroll container either way.
417
+ const body = showInlineResults ? (
307
418
  <div className="h-full overflow-y-auto">{results}</div>
419
+ ) : (
420
+ children
308
421
  );
309
422
 
310
423
  return (
311
- <section className="flex h-full w-full flex-col bg-surface">
312
- <MailHeader
313
- title={title}
314
- unreadCount={unreadCount}
315
- // Desktop mounts the app top bar, which owns search for the whole
316
- // shell the list header shows no field there, so the page never
317
- // has two search inputs competing for "/" and for focus. Below
318
- // desktop the header keeps a compact magnifier: on phone it opens
319
- // the full-screen takeover above, on tablet it expands over the
320
- // title. `isSinglePaneTier` is the same predicate the shell gates
321
- // the top bar on, so the two cannot drift into zero or two fields.
322
- isDesktop={false}
323
- showSearch={isSinglePaneTier(tier)}
324
- onMenuClick={() => layout?.openNav()}
325
- searchValue={searchInput}
326
- onSearchChange={onSearchChange}
327
- onSearchClear={onSearchClear}
328
- searchOpen={searchOpen}
329
- onSearchOpenChange={setSearchOpen}
330
- />
424
+ <section className="relative flex h-full w-full flex-col bg-surface">
425
+ {selectionBar ?? (
426
+ <MailHeader
427
+ title={title}
428
+ unreadCount={unreadCount}
429
+ // Desktop mounts the app top bar, which owns search for the whole
430
+ // shell the list header shows no field there, so the page never
431
+ // has two search inputs competing for "/" and for focus. Below
432
+ // desktop the header keeps a compact magnifier: on phone it opens
433
+ // the full-screen takeover above, on tablet it expands over the
434
+ // title. `isSinglePaneTier` is the same predicate the shell gates
435
+ // the top bar on, so the two cannot drift into zero or two fields.
436
+ isDesktop={false}
437
+ showSearch={isSinglePaneTier(tier)}
438
+ onMenuClick={() => layout?.openNav()}
439
+ searchValue={searchInput}
440
+ onSearchChange={onSearchChange}
441
+ onSearchClear={onSearchClear}
442
+ searchOpen={searchOpen}
443
+ onSearchOpenChange={setSearchOpen}
444
+ searchSuggest={searchSuggest}
445
+ />
446
+ )}
447
+ {suggestList}
448
+ {makeFilterAction}
331
449
  <div className="min-h-0 flex-1">{body}</div>
332
450
  {footer}
333
451
  {filterDialog}
452
+ {selectionSheet}
334
453
  </section>
335
454
  );
336
455
  }
@@ -7,6 +7,12 @@
7
7
  * category / attribute / source selection state. The same filter config feeds
8
8
  * the phone search takeover (via `MailListHeader`), so filters carry across.
9
9
  *
10
+ * An active search takes the sheet down. Filtering and searching narrow the same
11
+ * list by the same intent, and the sheet and the search's "Make this a filter"
12
+ * affordance want the same row above the list, so only one of them is up at a
13
+ * time. The selection state is held here and survives, so clearing the query
14
+ * restores the sheet exactly as it was.
15
+ *
10
16
  * The daily brief no longer uses this: it composes `MailListHeader` with the kit
11
17
  * `BriefSections`, which owns its own filter row (so there is exactly one filter
12
18
  * surface and the section headers flatten correctly when filtered).
@@ -18,6 +24,7 @@ import {
18
24
  type SearchResult,
19
25
  } from "@remit/ui";
20
26
  import { type ReactNode, useState } from "react";
27
+ import { useMailContext } from "@/lib/mail-context";
21
28
  import { MailListHeader } from "./MailListHeader";
22
29
 
23
30
  interface MailViewChromeProps {
@@ -74,6 +81,12 @@ export function MailViewChrome({
74
81
  searchResultsInBody,
75
82
  }: MailViewChromeProps) {
76
83
  const [expanded, setExpanded] = useState(false);
84
+ // A query owns the pane: the filter chrome and the search's own affordance
85
+ // narrow the same list from the same place, so the filter sheet stands down
86
+ // for as long as something is being searched. Its state survives — clearing
87
+ // the query brings the sheet back with the same category and toggles.
88
+ const { searchInput } = useMailContext();
89
+ const searching = searchInput.trim().length > 0;
77
90
 
78
91
  const filterConfig: Omit<FilterSheetProps, "children"> = {
79
92
  categories: preset.categories,
@@ -104,7 +117,11 @@ export function MailViewChrome({
104
117
  relatedResultsLabel={relatedResultsLabel}
105
118
  searchResultsInBody={searchResultsInBody}
106
119
  >
107
- <FilterSheet {...filterConfig}>{children}</FilterSheet>
120
+ {searching ? (
121
+ <div className="h-full overflow-y-auto">{children}</div>
122
+ ) : (
123
+ <FilterSheet {...filterConfig}>{children}</FilterSheet>
124
+ )}
108
125
  </MailListHeader>
109
126
  );
110
127
  }
@@ -104,6 +104,7 @@ import {
104
104
  filterReach,
105
105
  hasInboxFilter,
106
106
  type InboxFilterCriteria,
107
+ type InboxFilterParams,
107
108
  inboxFilterParams,
108
109
  sameInboxFilter,
109
110
  } from "@/lib/inbox-filters";
@@ -123,6 +124,10 @@ import {
123
124
  } from "@/lib/search-result";
124
125
  import { parseSearchTokens } from "@/lib/search-tokens";
125
126
  import { useTelemetry } from "@/lib/telemetry-context";
127
+ import {
128
+ applyResidualTokens,
129
+ threadSearchTokens,
130
+ } from "@/lib/thread-search-tokens";
126
131
  import { MailViewChrome } from "./MailViewChrome";
127
132
 
128
133
  /* ------------------------------------------------------------------ */
@@ -260,17 +265,19 @@ function MailboxPaneProvider({
260
265
 
261
266
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
262
267
  const hasSearchQuery = normalizedSearchQuery.length > 0;
263
- // Filter tokens (`from:`, `has:attachment`, `is:unread`) narrow this literal
264
- // search to the params `threadOperationsSearchThreads` supports; `before:`/
265
- // `after:`/`account:` have no equivalent on this endpoint and are left for
266
- // the semantic section only (see `useSemanticSearch`). `in:` never reaches
267
- // here at all: this view is scoped to one mailbox by its route, so
268
- // `useSearchTokenContext` does not resolve the term and it stays free text.
268
+ // Filter tokens narrow this literal search to the params
269
+ // `threadOperationsSearchThreads` supports (`from`, `subject`, `category`,
270
+ // `unread`, `starred`, `attachments`). What the endpoint has no parameter for
271
+ // `before:`/`after:`/`account:`, and any second value for a parameter that
272
+ // takes one comes back as residue and is applied over the returned rows
273
+ // below, because a token neither sent nor applied silently widens the result
274
+ // to mail the user asked to exclude. `in:` never reaches here at all: this
275
+ // view is scoped to one mailbox by its route, so `useSearchTokenContext` does
276
+ // not resolve the term and it stays free text.
269
277
  const { freeText, tokens: searchTokens } = parseSearchTokens(
270
278
  normalizedSearchQuery,
271
279
  tokenContext,
272
280
  );
273
- const fromToken = searchTokens.find((t) => t.type === "from");
274
281
 
275
282
  const [filterCategory, setFilterCategory] = useState("all");
276
283
  const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
@@ -291,21 +298,30 @@ function MailboxPaneProvider({
291
298
  // active chip routes the listing through `searchThreads` — one predicate, one
292
299
  // query key, so the key and the branch below cannot diverge.
293
300
  const hasServerFilter = hasSearchQuery || hasInboxFilter(filterCriteria);
301
+ // The chips are spread last: where a chip and a token set the same parameter
302
+ // the visible control decides, and the token drops to the residue.
303
+ const { params: tokenParams, residual: residualTokens } = threadSearchTokens(
304
+ searchTokens,
305
+ filterParams,
306
+ );
294
307
  const searchThreadsQuery = {
295
308
  order: "desc" as const,
296
309
  // Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
297
310
  // so switching paths without it multiplies the page size by ten.
298
311
  limit: THREADS_PAGE_SIZE,
299
312
  ...(freeText ? { query: freeText } : {}),
300
- ...(fromToken ? { from: fromToken.value } : {}),
301
- ...(searchTokens.some((t) => t.type === "hasAttachment")
302
- ? { attachments: true }
303
- : {}),
304
- ...(searchTokens.some((t) => t.type === "isUnread")
305
- ? { unread: true }
306
- : {}),
313
+ ...tokenParams,
307
314
  ...filterParams,
308
315
  };
316
+ // What the request actually narrows by, whoever set it. `placeholderData`
317
+ // keeps the previous rows only under the same predicate, and a token
318
+ // narrowing the list is as much that predicate as a chip is.
319
+ const activeFilterParams: InboxFilterParams = {
320
+ category: searchThreadsQuery.category,
321
+ unread: searchThreadsQuery.unread,
322
+ starred: searchThreadsQuery.starred,
323
+ attachments: searchThreadsQuery.attachments,
324
+ };
309
325
 
310
326
  const queryKey = hasServerFilter
311
327
  ? threadOperationsSearchThreadsQueryKey({
@@ -354,7 +370,7 @@ function MailboxPaneProvider({
354
370
  // skeleton rather than showing the old predicate's mail under the new
355
371
  // chip for one round trip.
356
372
  placeholderData: (previousData, previousQuery) =>
357
- sameInboxFilter(previousQuery?.queryKey, filterParams)
373
+ sameInboxFilter(previousQuery?.queryKey, activeFilterParams)
358
374
  ? previousData
359
375
  : undefined,
360
376
  });
@@ -395,10 +411,18 @@ function MailboxPaneProvider({
395
411
  // The server answered the active predicate, so these rows are the list: the
396
412
  // dedupe spans pages and the deleted drop repeats the server's own
397
413
  // `excludeDeleted`, and neither result changes when another page loads.
398
- const threads = dropDeletedThreads(
399
- dedupeThreadMessages(
400
- threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
414
+ // Residual tokens are the exception — the request could not carry them, so
415
+ // they are applied here over what came back. That thins a page rather than
416
+ // answering over the whole mailbox, which is why the empty state stops
417
+ // claiming the folder was read and the escalation predicate is withheld.
418
+ const threads = applyResidualTokens(
419
+ dropDeletedThreads(
420
+ dedupeThreadMessages(
421
+ threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
422
+ ),
401
423
  ),
424
+ residualTokens,
425
+ mailboxAccountId,
402
426
  );
403
427
 
404
428
  const onSelectFilterCategory = useCallback((id: string) => {
@@ -424,7 +448,10 @@ function MailboxPaneProvider({
424
448
  const listFilter: MessageListFilter | undefined = filterLabel
425
449
  ? {
426
450
  label: filterLabel,
427
- reach: filterReach(searchThreadsQuery),
451
+ reach:
452
+ residualTokens.length > 0
453
+ ? "loaded-pages"
454
+ : filterReach(searchThreadsQuery),
428
455
  onClear: onClearFilters,
429
456
  }
430
457
  : undefined;
@@ -823,7 +850,15 @@ function MailboxPaneProvider({
823
850
  listFilter,
824
851
  intelligenceOpen,
825
852
  onToggleIntelligence,
826
- searchPredicate: hasSearchQuery ? searchThreadsQuery : undefined,
853
+ // Escalation ("select all N matching") re-issues this predicate server-side
854
+ // and acts on every match, so it is only offered when the predicate IS the
855
+ // search: a residual token narrows the rows on screen but not the request,
856
+ // and a bulk delete over the broader set would reach mail the search
857
+ // excluded.
858
+ searchPredicate:
859
+ hasSearchQuery && residualTokens.length === 0
860
+ ? searchThreadsQuery
861
+ : undefined,
827
862
  onDeleteMessages: handleDeleteMessages,
828
863
  onMoveMessages: handleMoveMessages,
829
864
  isDeleting,
@@ -205,7 +205,6 @@ const ExpandedCard = ({
205
205
  threadId={threadMessage.threadId}
206
206
  mailboxId={threadMessage.mailboxId}
207
207
  autoMoved={threadMessage.autoMoved}
208
- size="md"
209
208
  />
210
209
  ) : null;
211
210