@remit/web-client 0.0.278 → 0.0.280

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.
@@ -2,9 +2,10 @@
2
2
  * The widen step's two-phase preview. On a semantic-capable deployment it runs
3
3
  * the anchor preview once and stops. On a deployment that reports
4
4
  * `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
5
- * re-previews with sender-derived literal clauses — one `From` clause per
6
- * distinct sender, combined with `Or`, no anchor — and reports the literal match
7
- * count, so every commit scope acts on the sender-matched set.
5
+ * re-previews with what the whole selection agrees on (#458) — one `From` or
6
+ * `FromDomain` clause when senders agree, the shared subject when they don't,
7
+ * combined with `Or`, no anchor — and reports the literal match count, so
8
+ * every commit scope acts on the same set the fallback derived.
8
9
  */
9
10
 
10
11
  import assert from "node:assert/strict";
@@ -29,12 +30,16 @@ type Responder = Parameters<typeof mockFetch>[0];
29
30
 
30
31
  const PREVIEW_PATH = "/organize/preview";
31
32
 
32
- const mount = (senders: string[], responder: Responder) => {
33
+ const mount = (
34
+ senders: string[],
35
+ responder: Responder,
36
+ subjects: string[] = [],
37
+ ) => {
33
38
  http = mockFetch(responder);
34
39
  harness = createDomHarness();
35
40
  const holder: { current: Widen | undefined } = { current: undefined };
36
41
  function Probe() {
37
- const widen = useOrganizeWiden("acc-1", "msg-1", senders);
42
+ const widen = useOrganizeWiden("acc-1", "msg-1", senders, subjects);
38
43
  holder.current = widen;
39
44
  const { preview } = widen;
40
45
  useEffect(() => {
@@ -130,6 +135,39 @@ describe("useOrganizeWiden — no vector pipeline, senders present", () => {
130
135
  });
131
136
  });
132
137
 
138
+ describe("useOrganizeWiden — no vector pipeline, mixed senders sharing a subject", () => {
139
+ it("re-previews with the shared subject rather than one From clause per sender (#458)", async () => {
140
+ const holder = mount(
141
+ ["billing@acme.test", "accounts@globex.test"],
142
+ (call) =>
143
+ call.body?.anchorMessageId
144
+ ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
145
+ : {
146
+ matchedCount: 64,
147
+ messageIds: ["m"],
148
+ semanticUnavailable: false,
149
+ },
150
+ ["Invoice 1841", "Invoice 1902"],
151
+ );
152
+ await settle(2);
153
+
154
+ const calls = http?.to(PREVIEW_PATH) ?? [];
155
+ assert.equal(calls.length, 2);
156
+ const second = calls[1];
157
+ assert.equal(second.body?.anchorMessageId, undefined);
158
+ assert.equal(second.body?.matchOperator, "Or");
159
+ assert.deepEqual(second.body?.literalClauses, [
160
+ { field: "Subject", value: "Invoice" },
161
+ ]);
162
+
163
+ const widen = holder.current;
164
+ assert.deepEqual(widen?.matchPredicate, {
165
+ matchOperator: "Or",
166
+ literalClauses: [{ field: "Subject", value: "Invoice" }],
167
+ });
168
+ });
169
+ });
170
+
133
171
  describe("useOrganizeWiden — no vector pipeline, no senders", () => {
134
172
  it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
135
173
  const holder = mount([], () => ({
@@ -148,4 +186,32 @@ describe("useOrganizeWiden — no vector pipeline, no senders", () => {
148
186
  assert.deepEqual(widen?.senders, []);
149
187
  assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
150
188
  });
189
+
190
+ it("still re-previews on a shared subject alone (#458)", async () => {
191
+ const holder = mount(
192
+ [],
193
+ (call) =>
194
+ call.body?.anchorMessageId
195
+ ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
196
+ : {
197
+ matchedCount: 12,
198
+ messageIds: ["m"],
199
+ semanticUnavailable: false,
200
+ },
201
+ ["Invoice 1841", "Invoice 1902"],
202
+ );
203
+ await settle(2);
204
+
205
+ const calls = http?.to(PREVIEW_PATH) ?? [];
206
+ assert.equal(calls.length, 2);
207
+ const second = calls[1];
208
+ assert.equal(second.body?.anchorMessageId, undefined);
209
+ assert.deepEqual(second.body?.literalClauses, [
210
+ { field: "Subject", value: "Invoice" },
211
+ ]);
212
+
213
+ const widen = holder.current;
214
+ assert.equal(widen?.semanticUnavailable, true);
215
+ assert.equal(widen?.senderFallback, true);
216
+ });
151
217
  });
@@ -1,5 +1,5 @@
1
1
  import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { deriveSenderClauses, distinctSenders } from "@remit/ui";
2
+ import { derivePropertyClauses, distinctSenders } from "@remit/ui";
3
3
  import { useMutation } from "@tanstack/react-query";
4
4
  import { useCallback, useEffect, useMemo, useState } from "react";
5
5
  import { buildOrganizeInput } from "@/lib/organize/organize-model";
@@ -9,16 +9,17 @@ import {
9
9
  } from "@/lib/organize/sender-fallback";
10
10
 
11
11
  /**
12
- * "Select similar messages" (the widen step) with a sender-derived fallback for
13
- * deployments without the vector pipeline.
12
+ * "Select similar messages" (the widen step) with a selection-agreement
13
+ * fallback for deployments without the vector pipeline.
14
14
  *
15
15
  * The first preview runs the semantic anchor (POST /organize/preview). When the
16
16
  * server reports `semanticUnavailable` — no vector pipeline on this deployment
17
17
  * (semantic-capability.ts) — the widen re-previews with the literal vocabulary
18
- * that matches vector-free: one `From` clause per distinct sender in the
19
- * selection, combined with `Or`, no anchor. The counts shown, the one-time
20
- * back-apply, and any standing filter then all carry that same literal
21
- * predicate, so the previewed set equals the set every commit scope acts on.
18
+ * that matches vector-free: what the whole selection agrees on (#458) — one
19
+ * sender/domain clause, or the subject the selection shares when senders don't
20
+ * — combined with `Or`, no anchor. The counts shown, the one-time back-apply,
21
+ * and any standing filter then all carry that same literal predicate, so the
22
+ * previewed set equals the set every commit scope acts on.
22
23
  *
23
24
  * A semantic-capable deployment never fires the second preview and keeps exactly
24
25
  * the anchor behaviour.
@@ -27,12 +28,17 @@ export const useOrganizeWiden = (
27
28
  accountId: string | undefined,
28
29
  anchorMessageId: string | undefined,
29
30
  senders: readonly string[],
31
+ subjects: readonly string[],
30
32
  ) => {
31
33
  const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
32
34
  const { mutate, reset: mutationReset } = mutation;
33
35
  const [fellBack, setFellBack] = useState(false);
34
36
 
35
37
  const distinct = useMemo(() => distinctSenders(senders), [senders]);
38
+ const agreementClauses = useMemo(
39
+ () => derivePropertyClauses(distinct, subjects),
40
+ [distinct, subjects],
41
+ );
36
42
 
37
43
  const preview = useCallback(() => {
38
44
  if (!accountId || !anchorMessageId) return;
@@ -53,21 +59,24 @@ export const useOrganizeWiden = (
53
59
  }, [mutationReset]);
54
60
 
55
61
  const data = mutation.data;
56
- // The first preview came back capability-absent and there are senders to
57
- // match on: re-preview with the literal clauses before showing any count, so
58
- // the count the user sees is the sender-match count, never a flash of the
59
- // empty semantic result.
62
+ // The first preview came back capability-absent and the selection agrees on
63
+ // something to match on — a sender, a domain, or just a shared subject, not
64
+ // senders alone (#458): re-preview with the literal clauses before showing
65
+ // any count, so the count the user sees is the agreement's match count,
66
+ // never a flash of the empty semantic result.
60
67
  const willFallBack =
61
- data?.semanticUnavailable === true && !fellBack && distinct.length > 0;
68
+ data?.semanticUnavailable === true &&
69
+ !fellBack &&
70
+ agreementClauses.length > 0;
62
71
 
63
72
  useEffect(() => {
64
73
  if (!willFallBack || !accountId) return;
65
74
  setFellBack(true);
66
75
  mutate({
67
76
  path: { accountId },
68
- body: buildOrganizeInput(buildSenderFallbackDraft(distinct)),
77
+ body: buildOrganizeInput(buildSenderFallbackDraft(distinct, subjects)),
69
78
  });
70
- }, [willFallBack, accountId, distinct, mutate]);
79
+ }, [willFallBack, accountId, distinct, subjects, mutate]);
71
80
 
72
81
  // The literal re-preview has no anchor, so its response reports
73
82
  // `semanticUnavailable: false`; `fellBack` is what remembers the capability
@@ -76,7 +85,7 @@ export const useOrganizeWiden = (
76
85
  const isPending = mutation.isPending || willFallBack;
77
86
 
78
87
  const matchPredicate: OrganizeMatchPredicate = fellBack
79
- ? { matchOperator: "Or", literalClauses: deriveSenderClauses(distinct) }
88
+ ? { matchOperator: "Or", literalClauses: agreementClauses }
80
89
  : {
81
90
  ...(anchorMessageId ? { anchorMessageId } : {}),
82
91
  matchOperator: "And",
@@ -14,20 +14,28 @@
14
14
  * below the newest page rendered an empty list however much of it the
15
15
  * collection held (#308); the server answers the predicate over the whole
16
16
  * collection instead, and a page comes back full of matches.
17
+ *
18
+ * Free text is one of those parameters, so a search is this same paginated
19
+ * query rather than a second, single-page one beside it. The separate one had
20
+ * no error state of its own — an expired session read as "nothing matched" —
21
+ * no continuation, so "Load more" was a dead button under a query, and its own
22
+ * loading state, so a cached listing flashed the empty state while it was in
23
+ * flight.
17
24
  */
18
25
  import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
19
26
  import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
20
27
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
21
28
  import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
22
29
  import { useMemo } from "react";
23
- import type { InboxFilterParams } from "@/lib/inbox-filters";
30
+ import type { ThreadSearchTokenParams } from "@/lib/thread-search-tokens";
24
31
 
25
32
  /** The criteria the Flagged view narrows its listing by, all server-applied. */
26
- export interface StarredCriteria extends InboxFilterParams {
27
- /** Free text, matched against subject and From over the whole collection. */
33
+ export interface StarredCriteria extends ThreadSearchTokenParams {
34
+ /**
35
+ * Free text, matched against subject, From and the body preview over the
36
+ * whole collection.
37
+ */
28
38
  query?: string;
29
- /** Page size, set only by the single-page text search. */
30
- limit?: number;
31
39
  }
32
40
 
33
41
  // `starred` and `order` are the view itself, not a criterion, so they are set
@@ -101,37 +109,6 @@ export function useStarredThreads(
101
109
  };
102
110
  }
103
111
 
104
- /**
105
- * The server's own text match over the whole starred collection, as one page.
106
- *
107
- * The free-text filter has two halves that no single request covers: the server
108
- * matches subject and From over every starred message, and the client can also
109
- * match a snippet, but only on rows it has already loaded. The server's set is
110
- * the filter — it is the half that sees the mail below the newest page — and
111
- * the snippet pass complements it (#308). Same shape as the daily brief's
112
- * unscoped search, which merges the same two halves.
113
- */
114
- export function useStarredTextSearch(
115
- criteria: StarredCriteria,
116
- limit: number,
117
- ): RemitImapThreadMessageResponse[] {
118
- const text = criteria.query ?? "";
119
- const query = { ...starredQuery(criteria), limit };
120
- const { data } = useQuery({
121
- queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
122
- queryFn: async () => {
123
- const { data: page } = await unifiedThreadOperationsListAllThreads({
124
- query,
125
- throwOnError: true,
126
- });
127
- return page;
128
- },
129
- enabled: text.length > 0,
130
- staleTime: 30_000,
131
- });
132
- return useMemo(() => data?.items ?? [], [data]);
133
- }
134
-
135
112
  /**
136
113
  * How many of the matching starred messages are unread, counted by the server.
137
114
  *
@@ -21,8 +21,13 @@ const tokensOf = (query: string): SearchToken[] =>
21
21
  mailboxesByName: new Map([["sent", "mb2"]]),
22
22
  }).tokens;
23
23
 
24
- const criteriaOf = (query: string, attributes: string[] = []) =>
25
- briefCriteria("all", new Set(attributes), tokensOf(query)).criteria;
24
+ const criteriaOf = (
25
+ query: string,
26
+ attributes: string[] = [],
27
+ accountId?: string,
28
+ ) =>
29
+ briefCriteria("all", new Set(attributes), tokensOf(query), accountId)
30
+ .criteria;
26
31
 
27
32
  const residualTypes = (query: string, attributes: string[] = []) =>
28
33
  briefCriteria("all", new Set(attributes), tokensOf(query)).residual.map(
@@ -30,13 +35,16 @@ const residualTypes = (query: string, attributes: string[] = []) =>
30
35
  );
31
36
 
32
37
  describe("briefCriteria", () => {
33
- test("an unnarrowed brief asks for nothing in particular", () => {
34
- assert.deepEqual(criteriaOf(""), {});
38
+ // The brief renders no muted sender's mail, so every request it makes says
39
+ // so — the alternative is a header counting mail the list drops (#1137).
40
+ test("an unnarrowed brief still refuses muted senders", () => {
41
+ assert.deepEqual(criteriaOf(""), { muted: false });
35
42
  assert.deepEqual(residualTypes(""), []);
36
43
  });
37
44
 
38
45
  test("the chips travel as parameters", () => {
39
46
  assert.deepEqual(criteriaOf("", ["unread", "attachment"]), {
47
+ muted: false,
40
48
  unread: true,
41
49
  attachments: true,
42
50
  });
@@ -44,6 +52,7 @@ describe("briefCriteria", () => {
44
52
 
45
53
  test("a typed token asks for exactly what its chip asks for", () => {
46
54
  assert.deepEqual(criteriaOf("is:unread has:attachment"), {
55
+ muted: false,
47
56
  unread: true,
48
57
  attachments: true,
49
58
  });
@@ -51,20 +60,36 @@ describe("briefCriteria", () => {
51
60
  });
52
61
 
53
62
  test("is:read narrows the request rather than the rows", () => {
54
- assert.deepEqual(criteriaOf("is:read"), { unread: false });
63
+ assert.deepEqual(criteriaOf("is:read"), { muted: false, unread: false });
55
64
  assert.deepEqual(residualTypes("is:read"), []);
56
65
  });
57
66
 
58
67
  test("is:starred travels too", () => {
59
- assert.deepEqual(criteriaOf("is:starred"), { starred: true });
68
+ assert.deepEqual(criteriaOf("is:starred"), { muted: false, starred: true });
60
69
  assert.deepEqual(residualTypes("is:starred"), []);
61
70
  });
62
71
 
72
+ // #1136: the pill used to narrow the rows after they arrived, so a count
73
+ // taken over every account was not the size of the list showing one.
74
+ test("the account pill travels as a parameter", () => {
75
+ assert.deepEqual(criteriaOf("", [], "acc_1"), {
76
+ muted: false,
77
+ accountId: "acc_1",
78
+ });
79
+ });
80
+
81
+ test("the cross-account brief names no account", () => {
82
+ assert.equal(Object.hasOwn(criteriaOf(""), "accountId"), false);
83
+ });
84
+
63
85
  // The section supplies its own category, so the shared criteria must not
64
86
  // carry one — two categories on one request is a request for neither.
65
87
  test("the category never travels in the shared criteria", () => {
66
- assert.deepEqual(criteriaOf("category:personal"), {});
67
- assert.deepEqual(criteriaOf("", ["unread"]), { unread: true });
88
+ assert.deepEqual(criteriaOf("category:personal"), { muted: false });
89
+ assert.deepEqual(criteriaOf("", ["unread"]), {
90
+ muted: false,
91
+ unread: true,
92
+ });
68
93
  assert.equal(
69
94
  Object.hasOwn(
70
95
  briefCriteria("personal", new Set(), []).criteria,
@@ -74,13 +99,32 @@ describe("briefCriteria", () => {
74
99
  );
75
100
  });
76
101
 
77
- // `listAllThreads` has one text parameter matching subject and From at once,
78
- // so neither token can be asked for on its own and both stay residue.
79
- test("from: and subject: have no parameter here and come back as residue", () => {
80
- assert.deepEqual(criteriaOf("from:alice"), {});
81
- assert.deepEqual(residualTypes("from:alice"), ["from"]);
82
- assert.deepEqual(residualTypes("subject:invoice"), ["subject"]);
83
- assert.deepEqual(BRIEF_TOKEN_PARAMS.includes("from"), false);
102
+ // #1128: the free-text parameter matches subject and From at once, so
103
+ // neither token could be asked for on its own and both were applied over the
104
+ // rows a section had fetched. Each has its own parameter now.
105
+ test("from: and subject: travel as parameters", () => {
106
+ assert.deepEqual(criteriaOf("from:alice"), {
107
+ muted: false,
108
+ from: "alice",
109
+ });
110
+ assert.deepEqual(residualTypes("from:alice"), []);
111
+ assert.deepEqual(criteriaOf("subject:invoice"), {
112
+ muted: false,
113
+ subject: "invoice",
114
+ });
115
+ assert.deepEqual(residualTypes("subject:invoice"), []);
116
+ assert.equal(BRIEF_TOKEN_PARAMS.includes("from"), true);
117
+ assert.equal(BRIEF_TOKEN_PARAMS.includes("subject"), true);
118
+ });
119
+
120
+ // One parameter per field, so a second value cannot be expressed; it drops
121
+ // to the residue rather than being silently lost.
122
+ test("a second from: stays residue", () => {
123
+ assert.deepEqual(criteriaOf("from:alice from:bob"), {
124
+ muted: false,
125
+ from: "alice",
126
+ });
127
+ assert.deepEqual(residualTypes("from:alice from:bob"), ["from"]);
84
128
  });
85
129
 
86
130
  test("dates, the mailbox and the account stay residue", () => {
@@ -92,7 +136,10 @@ describe("briefCriteria", () => {
92
136
  });
93
137
 
94
138
  test("a chip beats the token that contradicts it", () => {
95
- assert.deepEqual(criteriaOf("is:read", ["unread"]), { unread: true });
139
+ assert.deepEqual(criteriaOf("is:read", ["unread"]), {
140
+ muted: false,
141
+ unread: true,
142
+ });
96
143
  assert.deepEqual(residualTypes("is:read", ["unread"]), ["isRead"]);
97
144
  });
98
145
  });
@@ -102,7 +149,6 @@ describe("briefCountsMatchRows", () => {
102
149
  briefCountsMatchRows({
103
150
  residual: [],
104
151
  attributes: new Set<string>(),
105
- accountScoped: false,
106
152
  ...over,
107
153
  });
108
154
 
@@ -112,11 +158,7 @@ describe("briefCountsMatchRows", () => {
112
158
  });
113
159
 
114
160
  test("a residual token makes the count wider than the list", () => {
115
- assert.equal(reach({ residual: tokensOf("from:alice") }), false);
116
- });
117
-
118
- test("the account pills scope the rows and nothing else", () => {
119
- assert.equal(reach({ accountScoped: true }), false);
161
+ assert.equal(reach({ residual: tokensOf("before:2024-01-01") }), false);
120
162
  });
121
163
 
122
164
  test("a chip with no parameter makes the count wider than the list", () => {
@@ -4,13 +4,15 @@
4
4
  * The brief's category scope is not a criterion here: every section is its own
5
5
  * category-scoped request, so the category travels per section rather than in
6
6
  * the shared criteria. What is shared is the attribute chips and the tokens
7
- * typed in the field — `is:unread`, `has:attachment`, `is:starred` — all of them
8
- * columns on the ThreadMessage row, so all of them travel as query parameters
9
- * over the whole scope instead of as a pass over the rows already fetched
10
- * (#312).
7
+ * typed in the field — `from:`, `subject:`, `is:unread`, `has:attachment`,
8
+ * `is:starred` — all of them fields on the ThreadMessage row, so all of them
9
+ * travel as query parameters over the whole scope instead of as a pass over the
10
+ * rows already fetched (#312, #1128).
11
11
  *
12
- * `from:` and `subject:` stay residue: `listAllThreads` has one text parameter
13
- * that matches both at once, so neither can be asked for on its own.
12
+ * The account pill and mute travel with them. Both used to narrow the rows after
13
+ * they arrived, which is what took the number off every section header: a count
14
+ * over every account, muted senders included, is not the size of a list showing
15
+ * one account without them (#1136, #1137).
14
16
  *
15
17
  * Two chips have no parameter anywhere — "From contacts" reads `senderTrust`,
16
18
  * which is off-row (design D7), and "Today" is a date, which no listing
@@ -24,11 +26,14 @@ import { inboxFilterParams } from "./inbox-filters.js";
24
26
  import type { SearchToken } from "./search-tokens.js";
25
27
  import {
26
28
  type ThreadSearchTokenParamName,
29
+ type ThreadSearchTokenParams,
27
30
  threadSearchTokens,
28
31
  } from "./thread-search-tokens.js";
29
32
 
30
33
  /** The token parameters `listAllThreads` carries. */
31
34
  export const BRIEF_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
35
+ "from",
36
+ "subject",
32
37
  "category",
33
38
  "unread",
34
39
  "starred",
@@ -53,7 +58,12 @@ export const briefClientOnlyFilters = (
53
58
  new Set(BRIEF_CLIENT_ONLY_FILTERS.filter((id) => attributes.has(id)));
54
59
 
55
60
  /** Everything a section request narrows by except its own category. */
56
- export type BriefSectionParams = Omit<InboxFilterParams, "category">;
61
+ export type BriefSectionParams = Omit<ThreadSearchTokenParams, "category"> & {
62
+ /** The account the pills narrow to, or absent for the whole config. */
63
+ accountId?: string;
64
+ /** Always `false` here: the brief renders no muted sender's mail. */
65
+ muted?: boolean;
66
+ };
57
67
 
58
68
  export interface BriefCriteria {
59
69
  /** What every section request carries, chips and carried tokens together. */
@@ -66,19 +76,29 @@ export const briefCriteria = (
66
76
  category: string,
67
77
  attributes: ReadonlySet<string>,
68
78
  tokens: readonly SearchToken[],
79
+ accountId?: string,
69
80
  ): BriefCriteria => {
70
- const chipParams = inboxFilterParams({ category, attributes });
81
+ const chipParams: InboxFilterParams = inboxFilterParams({
82
+ category,
83
+ attributes,
84
+ });
71
85
  const { params, residual } = threadSearchTokens(
72
86
  tokens,
73
87
  chipParams,
74
88
  BRIEF_TOKEN_PARAMS,
75
89
  );
76
90
  const request = { ...params, ...chipParams };
77
- const criteria: BriefSectionParams = {};
91
+ // Mute is not a control the reader sets: the brief is the view that hides
92
+ // muted senders, so every request it makes says so and every count it shows
93
+ // is a count of what it renders.
94
+ const criteria: BriefSectionParams = { muted: false };
95
+ if (request.from !== undefined) criteria.from = request.from;
96
+ if (request.subject !== undefined) criteria.subject = request.subject;
78
97
  if (request.unread !== undefined) criteria.unread = request.unread;
79
98
  if (request.starred !== undefined) criteria.starred = request.starred;
80
99
  if (request.attachments !== undefined)
81
100
  criteria.attachments = request.attachments;
101
+ if (accountId !== undefined) criteria.accountId = accountId;
82
102
  return { criteria, residual };
83
103
  };
84
104
 
@@ -87,24 +107,23 @@ export interface BriefCountReach {
87
107
  residual: readonly SearchToken[];
88
108
  /** Attribute chips currently on. */
89
109
  attributes: ReadonlySet<string>;
90
- /** True while the reader has narrowed the brief to one account. */
91
- accountScoped: boolean;
92
110
  }
93
111
 
94
112
  /**
95
113
  * Whether a section's server count is a count of what the section shows.
96
114
  *
97
115
  * The count answers the request. Anything narrowing the rows after they arrive —
98
- * a residual token, a chip with no parameter, the account pills, which no
99
- * listing endpoint scopes by — makes the count the size of a wider set than the
100
- * list. A wider number rendered as the section's size is a worse reading than no
101
- * number, so the caller asks for none.
116
+ * a residual token, or a chip with no parameter — makes the count the size of a
117
+ * wider set than the list. A wider number rendered as the section's size is a
118
+ * worse reading than no number, so the caller asks for none.
119
+ *
120
+ * The account pills are not among them any more, and neither is mute: both are
121
+ * parameters the request carries, so the count narrows exactly as the list does
122
+ * (#1136, #1137).
102
123
  */
103
124
  export const briefCountsMatchRows = ({
104
125
  residual,
105
126
  attributes,
106
- accountScoped,
107
127
  }: BriefCountReach): boolean =>
108
128
  residual.length === 0 &&
109
- !accountScoped &&
110
129
  !BRIEF_CLIENT_ONLY_FILTERS.some((id) => attributes.has(id));