@remit/web-client 0.0.279 → 0.0.281

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.279",
3
+ "version": "0.0.281",
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": {
@@ -25,9 +25,12 @@
25
25
  * over the rows. `FilterPanelProvider` shares that panel's open state between
26
26
  * the caret and the sheet, the same shape `MailViewChrome` gives the mailbox and
27
27
  * Starred views. The list draws those controls but applies none of them: the
28
- * category and every chip a parameter can express travel with the section
29
- * requests, and the two that cannot ("From contacts", "Today") are applied here
30
- * alongside the residual tokens (#314). The phone search takeover reads the same
28
+ * category, the account pill and every chip a parameter can express travel with
29
+ * the section requests, and the two that cannot ("From contacts", "Today") are
30
+ * applied here alongside the residual tokens (#314). The account pill is a
31
+ * parameter rather than a pass over the rows because a count taken over every
32
+ * account is not the size of a list showing one, which is what used to take the
33
+ * number off every header (#1136). The phone search takeover reads the same
31
34
  * selection and the same rows, so a filter set on one surface holds on the
32
35
  * other. Under a query the chips are terms of that query — see
33
36
  * `briefChipFilters` — so what narrows the list is readable and editable in the
@@ -112,8 +115,6 @@ import {
112
115
  BRIEF_CATEGORIES,
113
116
  type BriefCategoryResult,
114
117
  briefSections,
115
- briefSectionTotal,
116
- excludeMutedSenders,
117
118
  matchesSearchTokens,
118
119
  toThreadRowData,
119
120
  } from "@/lib/brief";
@@ -606,8 +607,14 @@ export function DailyBrief({
606
607
  // chip decides which sections are on screen rather than narrowing a shared
607
608
  // one.
608
609
  const { criteria: chipCriteria, residual: residualTokens } = useMemo(
609
- () => briefCriteria(requestCategory, requestFilters, queryTokens),
610
- [requestCategory, requestFilters, queryTokens],
610
+ () =>
611
+ briefCriteria(
612
+ requestCategory,
613
+ requestFilters,
614
+ queryTokens,
615
+ selectedAccountId === "all" ? undefined : selectedAccountId,
616
+ ),
617
+ [requestCategory, requestFilters, queryTokens, selectedAccountId],
611
618
  );
612
619
  const shownCategories = useMemo<RemitImapMessageCategory[]>(
613
620
  () =>
@@ -624,13 +631,13 @@ export function DailyBrief({
624
631
  }),
625
632
  [chipCriteria, sq, requestCategory],
626
633
  );
627
- // The account pills and the tokens no parameter carries narrow the rows after
628
- // they arrive, so while either is active the count is of a wider set than the
629
- // list and the sections show no number at all.
634
+ // The tokens no parameter carries narrow the rows after they arrive, so while
635
+ // one is active the count is of a wider set than the list and the sections
636
+ // show no number at all. The account pills are no longer among them: the
637
+ // request carries the account, so the count narrows with the list (#1136).
630
638
  const counted = briefCountsMatchRows({
631
639
  residual: residualTokens,
632
640
  attributes: requestFilters,
633
- accountScoped: selectedAccountId !== "all",
634
641
  });
635
642
 
636
643
  const {
@@ -666,12 +673,12 @@ export function DailyBrief({
666
673
  refetchSections();
667
674
  }, [underQuery, refetchSearch, refetchSections]);
668
675
 
669
- // What the request could not carry: muted senders, the account pill, the two
670
- // chips no endpoint takes a parameter for ("From contacts", "Today"), and the
671
- // residual tokens (`from:`, `subject:`, `before:`, `after:`, `in:`,
672
- // `account:`) that `listAllThreads` has no parameter for. Everything else was
673
- // answered over the whole scope by the request itself, and the order the rows
674
- // arrived in is kept — narrowing never re-sorts.
676
+ // What the request could not carry: the two chips no endpoint takes a
677
+ // parameter for ("From contacts", "Today"), and the residual tokens
678
+ // (`before:`, `after:`, `in:`, `account:`). Everything else — the account
679
+ // pill, mute, the free text, `from:` and `subject:` — was answered over the
680
+ // whole scope by the request itself, and the order the rows arrived in is
681
+ // kept: narrowing never re-sorts.
675
682
  const clientOnlyFilters = useMemo(
676
683
  () => briefClientOnlyFilters(requestFilters),
677
684
  [requestFilters],
@@ -682,23 +689,21 @@ export function DailyBrief({
682
689
  // `count` is thread-distinct, and a list keying on messageId puts twelve
683
690
  // rows under a header reading one. Collapsed before anything reads a
684
691
  // length, so the rows and the number are the same unit.
685
- dedupeByThread(excludeMutedSenders(rows))
692
+ dedupeByThread(rows)
686
693
  .map(toThreadRowData)
687
694
  .filter(
688
695
  (t) =>
689
- (selectedAccountId === "all" ||
690
- t.accountId === selectedAccountId) &&
691
696
  matchesBriefFilters(t, clientOnlyFilters) &&
692
697
  matchesSearchTokens(t, residualTokens),
693
698
  ),
694
- [selectedAccountId, clientOnlyFilters, residualTokens],
699
+ [clientOnlyFilters, residualTokens],
695
700
  );
696
701
 
697
702
  const briefRows = useMemo<BriefCategoryResult[]>(
698
703
  () =>
699
704
  sectionRows.map((section) => ({
700
705
  category: section.category,
701
- total: briefSectionTotal(section.total, section.rows),
706
+ total: section.total,
702
707
  atCap: section.atCap,
703
708
  loading: section.loading,
704
709
  failed: section.failed,
@@ -18,6 +18,13 @@
18
18
  * pages the user happened to have loaded, so a category whose mail sat below
19
19
  * the newest page showed an empty list, and the count grew with every press of
20
20
  * "load more" while being presented as a total.
21
+ *
22
+ * The free text and the `from:` / `subject:` tokens are parameters too (#1128,
23
+ * #1135), so a search is one listing request rather than a second query merged
24
+ * into it. That is what keeps a search's failure loud, its pages continuable
25
+ * and its loading state its own — a separate search query had none of the
26
+ * three, so an expired session read as "no matches", "Load more" did nothing,
27
+ * and a cached listing flashed the empty state.
21
28
  */
22
29
  import {
23
30
  flaggedFilterConfig,
@@ -32,17 +39,11 @@ import { formatErrorMessage } from "@/components/ui/ErrorState";
32
39
  import { useIsDesktop } from "@/hooks/useMediaQuery";
33
40
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
34
41
  import {
35
- useStarredTextSearch,
36
42
  useStarredThreads,
37
43
  useStarredUnreadCount,
38
44
  } from "@/hooks/useStarredThreads";
39
45
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
40
- import {
41
- matchesBriefSearch,
42
- matchesSearchTokens,
43
- mergeSearchRows,
44
- toThreadRowData,
45
- } from "@/lib/brief";
46
+ import { matchesSearchTokens, toThreadRowData } from "@/lib/brief";
46
47
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
47
48
  import { flaggedCriteria } from "@/lib/flagged-criteria";
48
49
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
@@ -65,9 +66,6 @@ import {
65
66
  useThreadListSelection,
66
67
  } from "./ThreadListInteraction";
67
68
 
68
- /** One page of the server-filtered text search, merged with the listing below. */
69
- const TEXT_SEARCH_PAGE_SIZE = 200;
70
-
71
69
  /**
72
70
  * The wizard this view's verbs walk, and the one its search entry lands on.
73
71
  *
@@ -185,6 +183,10 @@ export function FlaggedList({
185
183
  [criteria, sq],
186
184
  );
187
185
 
186
+ // One request, free text included. The text is a parameter like every other
187
+ // criterion, so a search is this listing narrowed rather than a second query
188
+ // beside it — which is what gives a search the same error state, the same
189
+ // pagination and the same loading state as the unsearched list.
188
190
  const {
189
191
  threads,
190
192
  isLoading,
@@ -194,27 +196,17 @@ export function FlaggedList({
194
196
  fetchNextPage,
195
197
  hasNextPage,
196
198
  isFetchingNextPage,
197
- } = useStarredThreads(criteria);
198
- const textMatches = useStarredTextSearch(textCriteria, TEXT_SEARCH_PAGE_SIZE);
199
+ } = useStarredThreads(textCriteria);
199
200
 
200
- const rows = useMemo<ThreadRowData[]>(() => {
201
- const listed = dedupeByThread(threads).map(toThreadRowData);
202
- // No free text: the server-filtered listing as it comes.
203
- const matched = sq
204
- ? mergeSearchRows(
205
- // The snippet half, over the rows already loaded. It only ever adds
206
- // to the server's set — it is never what decides membership.
207
- listed.filter((t) => matchesBriefSearch(t, sq)),
208
- dedupeByThread(textMatches).map(toThreadRowData),
209
- )
210
- : listed;
211
- // What is left is what no parameter can carry: `before:`, `after:`, `in:`,
212
- // `account:`, and a token the chips overruled. The collapse runs last so
213
- // the two halves of a text search cannot land one conversation twice.
214
- return dedupeByThread(matched).filter((t) =>
215
- matchesSearchTokens(t, residualTokens),
216
- );
217
- }, [threads, textMatches, sq, residualTokens]);
201
+ const rows = useMemo<ThreadRowData[]>(
202
+ () =>
203
+ // What is left is what no parameter can carry: `before:`, `after:`,
204
+ // `in:`, `account:`, and a token the chips overruled.
205
+ dedupeByThread(threads)
206
+ .map(toThreadRowData)
207
+ .filter((t) => matchesSearchTokens(t, residualTokens)),
208
+ [threads, residualTokens],
209
+ );
218
210
 
219
211
  const openRow = useCallback(
220
212
  (id: string, options?: OpenMessageOptions) => {
@@ -250,9 +242,9 @@ export function FlaggedList({
250
242
  const unreadCount = useStarredUnreadCount(textCriteria) ?? null;
251
243
 
252
244
  // An empty list has to say how much was looked at, and the answer comes off
253
- // the request. Every chip and every carried token is a column on the row, so
254
- // the server answered over the whole collection; a residual token, or the
255
- // snippet half of a free-text search, only ever saw the pages loaded so far.
245
+ // the request. Every chip, the free text and every carried token is a field
246
+ // on the row, so the server answered each over the whole collection; only a
247
+ // residual token — a date, a mailbox, an account — saw the loaded pages.
256
248
  //
257
249
  // `is:starred` is dropped: this view is starred mail, so the token restates
258
250
  // the collection rather than narrowing it.
@@ -263,7 +255,7 @@ export function FlaggedList({
263
255
  const listFilter: MessageListFilter | undefined = listNarrowing({
264
256
  chips: { category: selectedCategory, attributes: activeFilters },
265
257
  tokens: narrowingTokens,
266
- reach: residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
258
+ reach: residualTokens.length > 0 ? "loaded-pages" : "whole-folder",
267
259
  onClear: clearNarrowing,
268
260
  });
269
261
 
@@ -546,33 +546,38 @@ describe("the brief's sections come from the server (#312)", () => {
546
546
  );
547
547
  });
548
548
 
549
- // A total is withheld whenever something narrows the rows after they arrive —
550
- // here a muted sender. The number goes; the way out of the section must not,
551
- // or the rest of the category is unreachable.
552
- it("keeps the way to the whole category when the total is withheld", async () => {
553
- const dom = await mountWith((call) => {
554
- const params = new URL(call.url, "http://localhost").searchParams;
555
- if (params.get("count") === "true") return undefined;
556
- if (!params.getAll("category").includes("marketing")) return undefined;
557
- const page = (ROWS.marketing ?? []).slice(0, SECTION_ROW_CAP);
558
- return {
559
- items: page.map((row, index) =>
560
- index === 0 ? { ...row, muted: true } : row,
561
- ),
562
- };
563
- });
564
- await settled(dom, renderedSubjects("marketing", SECTION_ROW_CAP)[1]);
549
+ // #1137: mute used to be applied after the rows arrived, so a header counted
550
+ // mail the list dropped and "Show all" opened rows the brief would not
551
+ // render. The brief asks the server to leave muted senders out instead, of
552
+ // the rows and of the count alike.
553
+ it("asks every section and every count to leave muted senders out", async () => {
554
+ const mounted = await mount();
555
+ await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
565
556
 
566
- const marketing = dom.byText("button", "Marketing");
567
- assert.doesNotMatch(
557
+ const asked = threadRequests();
558
+ assert.ok(asked.length > 0, "the brief issued no request");
559
+ for (const call of asked) {
560
+ assert.equal(
561
+ paramsOf(call).get("muted"),
562
+ "false",
563
+ "a brief request counted or listed muted senders",
564
+ );
565
+ }
566
+ });
567
+
568
+ // The number is the server's, and it now answers the same predicate the list
569
+ // does, so it is stated rather than withheld.
570
+ it("states the section's size with mute already taken off it", async () => {
571
+ const mounted = await mount();
572
+ await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
573
+
574
+ const marketing = mounted.byText("button", "Marketing");
575
+ assert.match(
568
576
  marketing.textContent ?? "",
569
577
  new RegExp(String(countOf("marketing"))),
570
- "a count taken with a muted sender in it was stated as the section's size",
571
- );
572
- assert.ok(
573
- dom.text().includes("Show all"),
574
- "withholding the number stranded the reader in the section",
578
+ "the section withheld a number the request had narrowed",
575
579
  );
580
+ assert.ok(mounted.text().includes("Show all"));
576
581
  });
577
582
 
578
583
  // The requests are built from committed state. Typing is not committing: a
@@ -0,0 +1,272 @@
1
+ /**
2
+ * A search of the Starred collection is the listing narrowed, not a second
3
+ * query beside it.
4
+ *
5
+ * It used to be a second query: one uncontinuable page, with no error state and
6
+ * no loading state of its own. Three things followed, and none of them is
7
+ * visible from a unit test of the criteria — only from the screen.
8
+ *
9
+ * A failed search rendered as "no matches". The generated client throws on a
10
+ * 401, the hook swallowed it (`data?.items ?? []`), and an expired session was
11
+ * indistinguishable from a collection holding nothing — the reading the
12
+ * no-silent-auth-failure rule exists to forbid.
13
+ *
14
+ * "Load more" was dead under a query: the button read `hasNextPage` off the
15
+ * listing query while the rows came from the search one, so it either did not
16
+ * appear or fetched a page nothing rendered.
17
+ *
18
+ * And the empty state flashed: `listState` read the listing's loading flag, so
19
+ * a cached listing beside an in-flight search said "no matches" before the
20
+ * search had answered.
21
+ */
22
+
23
+ import assert from "node:assert/strict";
24
+ import { afterEach, describe, it } from "node:test";
25
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
26
+ import {
27
+ type AnyRouter,
28
+ createMemoryHistory,
29
+ createRootRoute,
30
+ createRoute,
31
+ createRouter,
32
+ Outlet,
33
+ RouterProvider,
34
+ } from "@tanstack/react-router";
35
+ import { createElement } from "react";
36
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
37
+ import { MailContext, type MailContextValue } from "@/lib/mail-context";
38
+ import { MailFreshnessProvider } from "@/lib/mail-freshness";
39
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
40
+ import { useOpenThreadPath } from "@/routing";
41
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
42
+ import { makeThreadMessage } from "@/test-support/fixtures";
43
+ import {
44
+ type HttpCall,
45
+ type HttpMock,
46
+ httpError,
47
+ mockFetch,
48
+ } from "@/test-support/http";
49
+ import { FlaggedPane } from "./FlaggedPane";
50
+
51
+ const QUERY = "concierge";
52
+ const DESKTOP_WIDTH = 1440;
53
+
54
+ let harness: DomHarness | undefined;
55
+ let http: HttpMock | undefined;
56
+
57
+ afterEach(() => {
58
+ harness?.close();
59
+ harness = undefined;
60
+ http?.restore();
61
+ http = undefined;
62
+ });
63
+
64
+ // The router reads `self` at construction; the shared jsdom globals stop at
65
+ // `window`.
66
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
67
+
68
+ const match = (index: number): RemitImapThreadMessageResponse =>
69
+ makeThreadMessage({
70
+ messageId: `match-${index}`,
71
+ threadId: `thread-match-${index}`,
72
+ subject: `Parcel ${index}`,
73
+ snippet: "Your parcel was left with the concierge",
74
+ hasStars: true,
75
+ sentDate: 1_767_225_600_000 - index * 1_000,
76
+ });
77
+
78
+ /** A full page, so the server hands back a continuation token with it. */
79
+ const FIRST_PAGE = Array.from({ length: 50 }, (_, index) => match(index));
80
+ const SECOND_PAGE = [match(50)];
81
+
82
+ const mailContext = (query: string): MailContextValue => ({
83
+ accounts: [],
84
+ mailboxNameIndex: new Map(),
85
+ accountNameIndex: new Map(),
86
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
87
+ searchQuery: query,
88
+ searchInput: query,
89
+ searchViewKey: "list",
90
+ onSearchChange: () => undefined,
91
+ onSearchClear: () => undefined,
92
+ onSearchClearQuery: () => undefined,
93
+ intelligenceOpen: false,
94
+ onToggleIntelligence: () => undefined,
95
+ onRaiseIntelligence: () => undefined,
96
+ });
97
+
98
+ const testRouter = (query: string): AnyRouter => {
99
+ const rootRoute = createRootRoute({
100
+ component: () =>
101
+ createElement(MailFreshnessProvider, {
102
+ accountIds: [],
103
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
104
+ children: createElement(
105
+ MailContext.Provider,
106
+ { value: mailContext(query) },
107
+ createElement(ComposeProvider, null, createElement(Outlet)),
108
+ ),
109
+ }),
110
+ });
111
+ const mailRoute = createRoute({
112
+ getParentRoute: () => rootRoute,
113
+ path: "/mail",
114
+ validateSearch: (search: Record<string, unknown>) => search,
115
+ component: Outlet,
116
+ });
117
+ const flaggedRoute = createRoute({
118
+ getParentRoute: () => mailRoute,
119
+ path: "/flagged",
120
+ component: () =>
121
+ createElement(FlaggedPane, {
122
+ thread: useOpenThreadPath(),
123
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
124
+ children: createElement(FlaggedPane.List),
125
+ }),
126
+ });
127
+ const routeTree = rootRoute.addChildren([
128
+ mailRoute.addChildren([flaggedRoute]),
129
+ ]);
130
+ return createRouter({
131
+ routeTree,
132
+ history: createMemoryHistory({ initialEntries: ["/mail/flagged"] }),
133
+ }) as unknown as AnyRouter;
134
+ };
135
+
136
+ const threadCalls = (): HttpCall[] =>
137
+ (http?.calls ?? []).filter(
138
+ (call) => new URL(call.url, "http://localhost").pathname === "/threads",
139
+ );
140
+
141
+ const paramsOf = (call: HttpCall): URLSearchParams =>
142
+ new URL(call.url, "http://localhost").searchParams;
143
+
144
+ const mount = async (
145
+ answer: (call: HttpCall) => unknown,
146
+ query = QUERY,
147
+ ): Promise<DomHarness> => {
148
+ http = mockFetch((call) => {
149
+ if (call.path.endsWith("/config")) return { accounts: [] };
150
+ if (call.path !== "/threads") return { items: [] };
151
+ return answer(call);
152
+ });
153
+
154
+ const router = testRouter(query);
155
+ await router.load();
156
+ const mounted = createDomHarness({ viewportWidth: DESKTOP_WIDTH });
157
+ harness = mounted;
158
+ mounted.renderApp(createElement(RouterProvider, { router }));
159
+ await mounted.flush();
160
+ await mounted.wait(20);
161
+ await mounted.flush();
162
+ return mounted;
163
+ };
164
+
165
+ /** The rows request: the count reads `results=false` and carries no rows. */
166
+ const isRowRequest = (call: HttpCall): boolean =>
167
+ paramsOf(call).get("results") !== "false";
168
+
169
+ describe("a Starred search reaches the whole collection (#1128, #1135)", () => {
170
+ // The failure the no-silent-auth-failure rule names: a 401 that renders as
171
+ // an empty result is indistinguishable from a collection holding nothing, so
172
+ // the reader concludes their mail is gone rather than their session.
173
+ it("says a failed search failed, never that nothing matched", async () => {
174
+ // Only the search fails. The pane's own unnarrowed listing answers, which
175
+ // is what let the failure hide: the view read its error state off that
176
+ // healthy query while rendering rows from the failed one.
177
+ const mounted = await mount((call) =>
178
+ isRowRequest(call) && paramsOf(call).get("query")
179
+ ? httpError(401, "Session expired")
180
+ : { items: [] },
181
+ );
182
+
183
+ await mounted.waitFor(
184
+ () => mounted.text().includes("Couldn't load messages"),
185
+ "the failed search to state itself",
186
+ );
187
+
188
+ const shown = mounted.text();
189
+ assert.ok(
190
+ !/No .*matching/i.test(shown) && !/No messages in Starred/.test(shown),
191
+ `a failed search rendered as an empty collection: ${shown.slice(0, 300)}`,
192
+ );
193
+ assert.ok(
194
+ [...mounted.queryAll("button")].some(
195
+ (button) => (button.textContent ?? "").trim() === "Retry",
196
+ ),
197
+ "the failure offered no way to retry",
198
+ );
199
+ });
200
+
201
+ // The dead button: the rows came from a query with no continuation, so the
202
+ // control that asks for more of them had nothing to ask.
203
+ it("pages a search past its first page", async () => {
204
+ const mounted = await mount((call) => {
205
+ if (!isRowRequest(call)) return { count: FIRST_PAGE.length + 1 };
206
+ return paramsOf(call).get("continuationToken")
207
+ ? { items: SECOND_PAGE }
208
+ : { items: FIRST_PAGE, continuationToken: "page-2" };
209
+ });
210
+
211
+ await mounted.waitFor(
212
+ () => mounted.text().includes("Parcel 0"),
213
+ "the first page of matches",
214
+ );
215
+
216
+ const loadMore = [...mounted.queryAll("button")].find((button) =>
217
+ (button.textContent ?? "").includes("Load more"),
218
+ );
219
+ assert.ok(loadMore, "a search with more matches offered no way to them");
220
+
221
+ mounted.click(loadMore);
222
+ await mounted.waitFor(
223
+ () => mounted.text().includes("Parcel 50"),
224
+ "the second page of matches",
225
+ );
226
+
227
+ const paged = threadCalls().filter(
228
+ (call) => isRowRequest(call) && paramsOf(call).get("continuationToken"),
229
+ );
230
+ assert.equal(paged.length, 1, "the second page was never requested");
231
+ assert.equal(
232
+ paramsOf(paged[0]).get("query"),
233
+ QUERY,
234
+ "the next page dropped the search text",
235
+ );
236
+ });
237
+
238
+ // One request answers the rows under a query, so the free text travels with
239
+ // every other criterion rather than in a second query beside them. (The pane
240
+ // keeps its own unnarrowed listing for selection resolution; that one carries
241
+ // no text and is not this.)
242
+ it("asks for the matches once, on the listing request", async () => {
243
+ const mounted = await mount(() => ({ items: FIRST_PAGE.slice(0, 2) }));
244
+ await mounted.waitFor(
245
+ () => mounted.text().includes("Parcel 0"),
246
+ "the matches to render",
247
+ );
248
+
249
+ const searched = threadCalls().filter(
250
+ (call) => isRowRequest(call) && paramsOf(call).get("query") === QUERY,
251
+ );
252
+ assert.equal(searched.length, 1, "the search fanned out into two requests");
253
+ assert.equal(paramsOf(searched[0]).get("starred"), "true");
254
+ });
255
+
256
+ // The reach sentence is read off the request. A narrowing token beside the
257
+ // free text is what puts the sentence on screen, and both are parameters
258
+ // now, so the honest claim is the whole collection — which is only true
259
+ // because the search pages to its end rather than stopping at one window.
260
+ it("claims the whole collection was checked when nothing matched", async () => {
261
+ const mounted = await mount(
262
+ (call) => (isRowRequest(call) ? { items: [] } : { count: 0 }),
263
+ `is:unread ${QUERY}`,
264
+ );
265
+
266
+ await mounted.waitFor(
267
+ () => mounted.text().includes("No "),
268
+ "the empty search to settle",
269
+ );
270
+ assert.match(mounted.text(), /Every message in this folder was checked\./);
271
+ });
272
+ });
@@ -47,7 +47,7 @@ import type { ResultCount } from "@remit/ui";
47
47
  import { useQueries, useQuery } from "@tanstack/react-query";
48
48
  import { useCallback, useMemo } from "react";
49
49
  import type { BriefSectionParams } from "@/lib/brief-criteria";
50
- import { type InboxFilterParams, sameInboxFilter } from "@/lib/inbox-filters";
50
+ import { type ListFilterParams, sameInboxFilter } from "@/lib/inbox-filters";
51
51
  import { toResultCount } from "@/lib/result-count";
52
52
 
53
53
  /** A minute: a brief reopened straight away costs nothing. */
@@ -66,7 +66,7 @@ const UNCOUNTED: ResultCount = { kind: "unknown" };
66
66
  * is what `sameInboxFilter` was written to stop (design D18).
67
67
  */
68
68
  const keepUnderSameFilter =
69
- (filter: InboxFilterParams) =>
69
+ (filter: ListFilterParams) =>
70
70
  (
71
71
  previous: RemitImapThreadSearchResponse | undefined,
72
72
  previousQuery: { queryKey: readonly unknown[] } | undefined,
@@ -75,7 +75,10 @@ const keepUnderSameFilter =
75
75
 
76
76
  /** The criteria every section request carries, its own category aside. */
77
77
  export interface BriefSectionsCriteria extends BriefSectionParams {
78
- /** Committed free text, matched against subject and From across folders. */
78
+ /**
79
+ * Committed free text, matched against subject, From and the body preview
80
+ * across folders.
81
+ */
79
82
  query?: string;
80
83
  }
81
84
 
@@ -111,13 +114,19 @@ const sectionCountQuery = (
111
114
  const sectionFilter = (
112
115
  criteria: BriefSectionsCriteria,
113
116
  category: RemitImapMessageCategory,
114
- ): InboxFilterParams => ({
117
+ ): ListFilterParams => ({
115
118
  category: [category],
116
119
  ...(criteria.unread !== undefined ? { unread: criteria.unread } : {}),
117
120
  ...(criteria.starred !== undefined ? { starred: criteria.starred } : {}),
118
121
  ...(criteria.attachments !== undefined
119
122
  ? { attachments: criteria.attachments }
120
123
  : {}),
124
+ // The account pill is a scope, not text: switching it must take the previous
125
+ // account's rows off screen rather than render them for one round trip.
126
+ ...(criteria.accountId !== undefined
127
+ ? { accountId: criteria.accountId }
128
+ : {}),
129
+ ...(criteria.muted !== undefined ? { muted: criteria.muted } : {}),
121
130
  });
122
131
 
123
132
  export interface BriefSectionRows {