@remit/web-client 0.0.22 → 0.0.23

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.22",
3
+ "version": "0.0.23",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -47,6 +47,7 @@ import {
47
47
  groupBriefSections,
48
48
  matchesBriefSearch,
49
49
  matchesSearchTokens,
50
+ mergeSearchRows,
50
51
  toThreadRowData,
51
52
  } from "@/lib/brief";
52
53
  import { isServerError } from "@/lib/error-classifier";
@@ -57,6 +58,10 @@ import { MailListHeader } from "./MailListHeader";
57
58
 
58
59
  /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
59
60
  ids) so the phone search takeover narrows results the same way the list does. */
61
+ /* Page size for the unscoped cross-folder search. One page is what the takeover
62
+ and the "Top matches" list render; the server caps it at 500. */
63
+ const UNSCOPED_SEARCH_PAGE_SIZE = 200;
64
+
60
65
  const BRIEF_SEARCH_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
61
66
  unread: (t) => !t.isRead,
62
67
  attachment: (t) => t.hasAttachment === true,
@@ -264,6 +269,21 @@ export function DailyBrief({
264
269
  tokenContext,
265
270
  );
266
271
 
272
+ // --- Unscoped search query ---
273
+ // The brief's list is the unified INBOX, so filtering it client-side can only
274
+ // ever find inbox mail. The same endpoint in search mode (`query`) widens to
275
+ // every non-muted folder of every account — Archive, Sent, Spam, custom
276
+ // folders — and matches subject/From in the query. Its rows are merged with
277
+ // the client-side pass below, which still contributes snippet matches the
278
+ // server does not index.
279
+ const { data: searchData, isFetching: searchFetching } = useQuery({
280
+ ...unifiedThreadOperationsListAllThreadsOptions({
281
+ query: { query: sq, limit: UNSCOPED_SEARCH_PAGE_SIZE },
282
+ }),
283
+ enabled: sq.length > 0,
284
+ staleTime: 30_000,
285
+ });
286
+
267
287
  // Convert API rows to ThreadRowData, narrowing only by the selected account
268
288
  // and the free-text search plus any filter tokens (`from:`, `has:attachment`,
269
289
  // `is:unread`, `before:`/`after:`, `in:`, `account:`) parsed out of the
@@ -271,20 +291,20 @@ export function DailyBrief({
271
291
  // `BriefSections` filter row's job, so the full per-category sections are
272
292
  // handed to it; it groups, narrows, and flattens.
273
293
  const filteredRows = useMemo<ThreadRowData[]>(() => {
274
- const raw = threadsData?.items ?? [];
275
- return raw
276
- .filter(
277
- (t) =>
278
- selectedAccountId === "all" ||
279
- (t.accountId ?? t.accountConfigId) === selectedAccountId,
280
- )
281
- .map(toThreadRowData)
282
- .filter(
283
- (t) =>
284
- (!sq || matchesBriefSearch(t, sq)) &&
285
- matchesSearchTokens(t, queryTokens),
286
- );
287
- }, [threadsData, selectedAccountId, sq, queryTokens]);
294
+ const briefRows = (threadsData?.items ?? []).map(toThreadRowData);
295
+ // No free text: the brief list as it comes, order untouched.
296
+ const rows = sq
297
+ ? mergeSearchRows(
298
+ briefRows.filter((t) => matchesBriefSearch(t, sq)),
299
+ (searchData?.items ?? []).map(toThreadRowData),
300
+ )
301
+ : briefRows;
302
+ return rows.filter(
303
+ (t) =>
304
+ (selectedAccountId === "all" || t.accountId === selectedAccountId) &&
305
+ matchesSearchTokens(t, queryTokens),
306
+ );
307
+ }, [threadsData, searchData, selectedAccountId, sq, queryTokens]);
288
308
 
289
309
  const sections = useMemo<ThreadSection[]>(
290
310
  () => groupBriefSections(filteredRows),
@@ -433,7 +453,7 @@ export function DailyBrief({
433
453
  footer={isDesktop ? <KeyboardHintBar /> : undefined}
434
454
  searchFilter={searchFilterConfig}
435
455
  searchResults={searchResults}
436
- searchLoading={isLoading}
456
+ searchLoading={isLoading || searchFetching}
437
457
  relatedResults={relatedResults}
438
458
  relatedLoading={relatedLoading}
439
459
  onSelectSearchResult={onSelectSearchResult}
@@ -947,14 +947,13 @@ function MailboxList() {
947
947
  () => threads.map(threadToSearchResult),
948
948
  [threads],
949
949
  );
950
- // The route scopes this view, and the top bar's chip says so. Both sections
951
- // are named by the ground they cover so that scope is never overstated: the
952
- // literal engine searches this mailbox and nothing else (there is no
953
- // cross-mailbox thread search), while the semantic engine runs unscoped and
954
- // its section says "Everywhere" (#47). Reaching past the folder is a labelled
955
- // section, not a silent widening of the chip. Results are deduped by thread,
956
- // so a thread never shows in both.
950
+ // The route scopes this view and the top bar's chip says so, so every engine
951
+ // here respects it: the literal engine searches this mailbox, and the
952
+ // semantic engine takes the same `mailboxId`. No chip means global; a chip
953
+ // means nothing on the route reaches past it. Results are deduped by thread,
954
+ // so a thread never shows in both sections.
957
955
  const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
956
+ mailboxId,
958
957
  filterCategory,
959
958
  });
960
959
  const relatedResults = useMemo(
@@ -969,9 +968,9 @@ function MailboxList() {
969
968
  (result: SearchResult) =>
970
969
  navigate({
971
970
  to: "/mail/$mailboxId",
972
- // A hit from the unscoped "Everywhere" section lives in another
973
- // mailbox; open it there, so the list, the toolbar's verbs and the
974
- // message all belong to the same mailbox.
971
+ // Both sections are scoped to this mailbox, so a result's own
972
+ // mailbox is normally this one; keep reading it off the result so a
973
+ // row can never open under a mailbox it does not belong to.
975
974
  params: { mailboxId: result.mailboxId ?? mailboxId },
976
975
  search: (prev: Record<string, unknown>) => ({
977
976
  ...prev,
@@ -1066,8 +1065,6 @@ function MailboxList() {
1066
1065
  relatedResults={relatedResults}
1067
1066
  relatedLoading={relatedLoading}
1068
1067
  onSelectSearchResult={handleSelectSearchResult}
1069
- searchResultsLabel={`In ${listTitle}`}
1070
- relatedResultsLabel="Everywhere"
1071
1068
  >
1072
1069
  {body}
1073
1070
  </MailViewChrome>
@@ -2,8 +2,10 @@ import { semanticSearchOperationsSemanticSearchOptions } from "@remit/api-http-c
2
2
  import type { RemitImapSemanticSearchResult } from "@remit/api-http-client/types.gen.ts";
3
3
  import { MessageCategory } from "@remit/domain-enums";
4
4
  import { useQuery } from "@tanstack/react-query";
5
+ import { useRouterState } from "@tanstack/react-router";
5
6
  import { useMailContext } from "@/lib/mail-context";
6
7
  import { normalizeSearchQuery } from "@/lib/search-query";
8
+ import { semanticMailboxScope } from "@/lib/search-scope";
7
9
  import { parseSearchTokens } from "@/lib/search-tokens";
8
10
  import { useSearchTokenContext } from "./useSearchTokenContext";
9
11
 
@@ -22,8 +24,9 @@ const toCategoryParam = (
22
24
 
23
25
  interface UseSemanticSearchParams {
24
26
  /**
25
- * Restrict results to a single mailbox (the scoped inbox). Omit for the
26
- * cross-account daily brief, where semantic search spans every account.
27
+ * Restrict results to a single mailbox. A mailbox route pins the scope to its
28
+ * own mailbox regardless of what is passed here, so this only carries a scope
29
+ * a route does not already imply.
27
30
  */
28
31
  mailboxId?: string;
29
32
  /**
@@ -35,9 +38,10 @@ interface UseSemanticSearchParams {
35
38
 
36
39
  /**
37
40
  * Fetch semantic-search hits for the active query (one source of truth: the
38
- * debounced `searchQuery` in `MailContext`). Scoping mirrors the literal search:
39
- * pass `mailboxId` for a scoped inbox, omit it for the global brief. Disabled
40
- * until the query is non-empty so an empty field issues no request.
41
+ * debounced `searchQuery` in `MailContext`). Scoping mirrors the literal search
42
+ * and is taken from the route (`semanticMailboxScope`): no chip means global, a
43
+ * chip means this engine respects it like every other. Disabled until the query
44
+ * is non-empty so an empty field issues no request.
41
45
  *
42
46
  * Filter tokens (`has:attachment`, `is:unread`, `before:`/`after:`) parsed from
43
47
  * the query map onto the search API's own filter params. `from:` and
@@ -50,7 +54,7 @@ interface UseSemanticSearchParams {
50
54
  * `useSearchTokenContext` does not resolve the term there at all — the term
51
55
  * stays free text and no engine, and no chip, treats it as a filter.
52
56
  *
53
- * Without a `mailboxId` the request spans every mailbox of every account the
57
+ * With no resolved mailbox the request spans every mailbox of every account the
54
58
  * caller owns: the backend partitions the vector index by accountConfigId (one
55
59
  * per signed-in user, not per mail account), so unscoped is genuinely global.
56
60
  */
@@ -63,9 +67,15 @@ export function useSemanticSearch({
63
67
  } {
64
68
  const { searchQuery } = useMailContext();
65
69
  const tokenContext = useSearchTokenContext();
70
+ const matches = useRouterState({ select: (s) => s.matches });
66
71
  const normalizedQuery = normalizeSearchQuery(searchQuery);
67
72
  const { freeText, tokens } = parseSearchTokens(normalizedQuery, tokenContext);
68
- const enabled = normalizedQuery.length > 0;
73
+ // `freeText`, not the raw query: a query of nothing but tokens
74
+ // (`has:attachment`, `in:archive`) parses to empty free text, and asking a
75
+ // vector index what an empty string means has no answer to give. The literal
76
+ // engines still apply those tokens; the semantic section simply has nothing
77
+ // to rank.
78
+ const enabled = freeText.length > 0;
69
79
 
70
80
  const category = toCategoryParam(filterCategory);
71
81
  const hasAttachment = tokens.some((t) => t.type === "hasAttachment")
@@ -75,7 +85,12 @@ export function useSemanticSearch({
75
85
  const afterToken = tokens.find((t) => t.type === "after");
76
86
  const beforeToken = tokens.find((t) => t.type === "before");
77
87
  const inToken = tokens.find((t) => t.type === "in");
78
- const effectiveMailboxId = mailboxId ?? inToken?.mailboxId;
88
+ // The route decides the scope, not the call site — see `semanticMailboxScope`.
89
+ const effectiveMailboxId = semanticMailboxScope({
90
+ matches,
91
+ callerMailboxId: mailboxId,
92
+ inTokenMailboxId: inToken?.mailboxId,
93
+ });
79
94
 
80
95
  const { data, isLoading } = useQuery({
81
96
  ...semanticSearchOperationsSemanticSearchOptions({
@@ -6,6 +6,7 @@ import {
6
6
  groupBriefSections,
7
7
  matchesBriefSearch,
8
8
  matchesSearchTokens,
9
+ mergeSearchRows,
9
10
  toThreadRowData,
10
11
  } from "./brief.js";
11
12
  import type { SearchToken } from "./search-tokens.js";
@@ -433,3 +434,81 @@ describe("matchesSearchTokens", () => {
433
434
  );
434
435
  });
435
436
  });
437
+
438
+ // #49: the brief's list is the unified INBOX, so filtering it client-side found
439
+ // only inbox mail. The server's cross-folder search supplies the rest, and the
440
+ // two are merged.
441
+ describe("mergeSearchRows", () => {
442
+ test("keeps rows the server found in other folders", () => {
443
+ const merged = mergeSearchRows(
444
+ [row({ id: "inbox-hit", sentDate: 300 })],
445
+ [
446
+ row({ id: "archive-hit", sentDate: 200 }),
447
+ row({ id: "spam-hit", sentDate: 100 }),
448
+ ],
449
+ );
450
+
451
+ assert.deepEqual(
452
+ merged.map((r) => r.id),
453
+ ["inbox-hit", "archive-hit", "spam-hit"],
454
+ );
455
+ });
456
+
457
+ test("keeps a snippet-only match the server cannot see", () => {
458
+ const merged = mergeSearchRows(
459
+ [row({ id: "snippet-only", sentDate: 100 })],
460
+ [],
461
+ );
462
+
463
+ assert.deepEqual(
464
+ merged.map((r) => r.id),
465
+ ["snippet-only"],
466
+ );
467
+ });
468
+
469
+ test("the two sources overlap on INBOX, so rows are deduped", () => {
470
+ const merged = mergeSearchRows(
471
+ [row({ id: "shared", sentDate: 200, subject: "from the brief" })],
472
+ [
473
+ row({ id: "shared", sentDate: 200, subject: "from the search" }),
474
+ row({ id: "archive-hit", sentDate: 100 }),
475
+ ],
476
+ );
477
+
478
+ assert.deepEqual(
479
+ merged.map((r) => r.id),
480
+ ["shared", "archive-hit"],
481
+ );
482
+ assert.equal(merged[0].subject, "from the brief");
483
+ });
484
+
485
+ test("the union reads newest first, interleaving both sources", () => {
486
+ const merged = mergeSearchRows(
487
+ [
488
+ row({ id: "brief-new", sentDate: 400 }),
489
+ row({ id: "brief-old", sentDate: 200 }),
490
+ ],
491
+ [
492
+ row({ id: "search-mid", sentDate: 300 }),
493
+ row({ id: "search-oldest", sentDate: 100 }),
494
+ ],
495
+ );
496
+
497
+ assert.deepEqual(
498
+ merged.map((r) => r.id),
499
+ ["brief-new", "search-mid", "brief-old", "search-oldest"],
500
+ );
501
+ });
502
+
503
+ test("a row without a sentDate sorts last", () => {
504
+ const merged = mergeSearchRows(
505
+ [row({ id: "undated" })],
506
+ [row({ id: "dated", sentDate: 100 })],
507
+ );
508
+
509
+ assert.deepEqual(
510
+ merged.map((r) => r.id),
511
+ ["dated", "undated"],
512
+ );
513
+ });
514
+ });
package/src/lib/brief.ts CHANGED
@@ -67,6 +67,33 @@ export function toThreadRowData(
67
67
  };
68
68
  }
69
69
 
70
+ /**
71
+ * Union of the brief's own rows with the rows the server's cross-folder search
72
+ * returned, newest first.
73
+ *
74
+ * Both lists are needed. The server matches subject and From only, so a row
75
+ * whose snippet carries the term is found only by the client-side pass over the
76
+ * loaded brief rows; the server pass is the only one that reaches Archive,
77
+ * Sent, Spam and custom folders, which the brief list never loads. The two
78
+ * overlap on INBOX, so rows are deduped by id, the first occurrence winning.
79
+ *
80
+ * Rows without a `sentDate` sort last; the brief's own list is already newest
81
+ * first, so this only has to re-interleave the two sources.
82
+ */
83
+ export function mergeSearchRows(
84
+ briefRows: ThreadRowData[],
85
+ searchRows: ThreadRowData[],
86
+ ): ThreadRowData[] {
87
+ const seen = new Set<string>();
88
+ const merged: ThreadRowData[] = [];
89
+ for (const row of [...briefRows, ...searchRows]) {
90
+ if (seen.has(row.id)) continue;
91
+ seen.add(row.id);
92
+ merged.push(row);
93
+ }
94
+ return merged.sort((a, b) => (b.sentDate ?? 0) - (a.sentDate ?? 0));
95
+ }
96
+
70
97
  /**
71
98
  * Category sections in fixed display order. The `id`/`label` drive the rendered
72
99
  * section; `category` is the row category that routes into it.
@@ -11,6 +11,7 @@ import {
11
11
  SEARCH_SCOPE_CHIP_ID,
12
12
  scopeLabelForMailboxName,
13
13
  searchScopeForRoute,
14
+ semanticMailboxScope,
14
15
  } from "./search-scope";
15
16
 
16
17
  const matches = (routeId: string) => [{ routeId: "/mail" }, { routeId }];
@@ -102,3 +103,95 @@ describe("scopeLabelForMailboxName", () => {
102
103
  assert.equal(scopeLabelForMailboxName("Work Stuff"), 'in:"work stuff"');
103
104
  });
104
105
  });
106
+
107
+ // The invariant: no chip means global, a chip means every engine on that route
108
+ // respects it. The semantic engine used to run unscoped inside a mailbox under
109
+ // an "Everywhere" heading, contradicting the `in:` chip the same bar showed.
110
+ describe("semanticMailboxScope — a chip binds every engine on the route", () => {
111
+ const mailbox = (mailboxId: string) => [
112
+ { routeId: "/mail" },
113
+ { routeId: MAIL_MAILBOX_ROUTE_ID, params: { mailboxId } },
114
+ ];
115
+
116
+ it("pins a mailbox route to its own mailbox", () => {
117
+ assert.equal(
118
+ semanticMailboxScope({ matches: mailbox("mbx-spam") }),
119
+ "mbx-spam",
120
+ );
121
+ });
122
+
123
+ it("a mailbox route ignores a caller asking for somewhere else", () => {
124
+ assert.equal(
125
+ semanticMailboxScope({
126
+ matches: mailbox("mbx-spam"),
127
+ callerMailboxId: "mbx-archive",
128
+ }),
129
+ "mbx-spam",
130
+ );
131
+ });
132
+
133
+ it("a mailbox route ignores a typed in: term", () => {
134
+ assert.equal(
135
+ semanticMailboxScope({
136
+ matches: mailbox("mbx-spam"),
137
+ inTokenMailboxId: "mbx-archive",
138
+ }),
139
+ "mbx-spam",
140
+ );
141
+ });
142
+
143
+ it("a mailbox route never widens to global", () => {
144
+ const scope = semanticMailboxScope({ matches: mailbox("mbx-spam") });
145
+ assert.notEqual(scope, undefined, "a scoped route must carry a scope");
146
+ });
147
+
148
+ // Flagged and outbox are scoped to a collection, which the mailbox-scoped
149
+ // semantic API cannot express — so neither runs a semantic section, and
150
+ // neither may fall back to a typed `in:` under its own chip.
151
+ for (const routeId of [MAIL_FLAGGED_ROUTE_ID, MAIL_OUTBOX_ROUTE_ID]) {
152
+ it(`${routeId} never honours a typed in: term`, () => {
153
+ assert.equal(
154
+ semanticMailboxScope({
155
+ matches: matches(routeId),
156
+ inTokenMailboxId: "mbx-archive",
157
+ }),
158
+ undefined,
159
+ );
160
+ });
161
+ }
162
+
163
+ it("the daily brief is global — no route scope, no chip", () => {
164
+ assert.equal(
165
+ semanticMailboxScope({ matches: matches(MAIL_BRIEF_ROUTE_ID) }),
166
+ undefined,
167
+ );
168
+ });
169
+
170
+ it("the daily brief is the one route a typed in: narrows", () => {
171
+ assert.equal(
172
+ semanticMailboxScope({
173
+ matches: matches(MAIL_BRIEF_ROUTE_ID),
174
+ inTokenMailboxId: "mbx-archive",
175
+ }),
176
+ "mbx-archive",
177
+ );
178
+ });
179
+
180
+ it("every scoped route resolves a scope or refuses to widen", () => {
181
+ // Pins the invariant across the route table rather than one case at a
182
+ // time: on a scoped route the resolved scope is either the route's own
183
+ // mailbox, or nothing that came from the typed query.
184
+ const scoped = [
185
+ { matches: mailbox("mbx-spam"), expected: "mbx-spam" },
186
+ { matches: matches(MAIL_FLAGGED_ROUTE_ID), expected: undefined },
187
+ { matches: matches(MAIL_OUTBOX_ROUTE_ID), expected: undefined },
188
+ ];
189
+ for (const { matches: m, expected } of scoped) {
190
+ assert.equal(isScopedRoute(m), true);
191
+ assert.equal(
192
+ semanticMailboxScope({ matches: m, inTokenMailboxId: "mbx-elsewhere" }),
193
+ expected,
194
+ );
195
+ }
196
+ });
197
+ });
@@ -26,6 +26,7 @@ import {
26
26
  isFlaggedRoute,
27
27
  isMailboxRoute,
28
28
  isOutboxRoute,
29
+ MAIL_MAILBOX_ROUTE_ID,
29
30
  type MailRouteMatch,
30
31
  } from "./mail-route";
31
32
 
@@ -75,6 +76,45 @@ export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
75
76
  );
76
77
  }
77
78
 
79
+ /**
80
+ * The mailbox id carried by a mailbox route, or `undefined` on any other route.
81
+ */
82
+ export function routeMailboxId(
83
+ matches: readonly MailRouteMatch[],
84
+ ): string | undefined {
85
+ return matches.find((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID)?.params
86
+ ?.mailboxId;
87
+ }
88
+
89
+ /**
90
+ * The mailbox every engine on the active route must be pinned to.
91
+ *
92
+ * No chip means global; a chip means nothing on the route reaches past it. The
93
+ * semantic engine used to run unscoped inside a mailbox under an "Everywhere"
94
+ * heading, which contradicted the `in:` chip the same bar was showing, so the
95
+ * scope is now resolved from the route here rather than left to each caller:
96
+ *
97
+ * - A mailbox route pins to its own mailbox. It beats both the caller's
98
+ * argument and a typed `in:`, so no call site can widen or redirect it.
99
+ * - Any other scoped route (flagged, outbox) never falls back to a typed
100
+ * `in:`. Its scope is a collection rather than a folder, which the semantic
101
+ * API cannot express — which is why neither view runs a semantic section
102
+ * today. Honouring `in:` there would be a search reaching past a chip that
103
+ * promises otherwise.
104
+ * - An unscoped route (the daily brief) is global unless a typed `in:` narrows
105
+ * it, which is the one place `useSearchTokenContext` resolves that term.
106
+ */
107
+ export function semanticMailboxScope(args: {
108
+ matches: readonly MailRouteMatch[];
109
+ callerMailboxId?: string;
110
+ inTokenMailboxId?: string;
111
+ }): string | undefined {
112
+ const fromRoute = routeMailboxId(args.matches);
113
+ if (fromRoute) return fromRoute;
114
+ if (isScopedRoute(args.matches)) return args.callerMailboxId;
115
+ return args.callerMailboxId ?? args.inTokenMailboxId;
116
+ }
117
+
78
118
  /**
79
119
  * The scope of the active route.
80
120
  *