@remit/web-client 0.0.18 → 0.0.20

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.18",
3
+ "version": "0.0.20",
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": {
@@ -9,6 +9,13 @@
9
9
  * The actions here are the ones that belong to the app rather than to whatever
10
10
  * is currently listed or open — compose, bug report, account. Reply, delete,
11
11
  * move and the rest stay on the reading pane's own toolbar, under this bar.
12
+ *
13
+ * The field carries one chip: the scope of the view the user navigated into
14
+ * (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
15
+ * searches everything. Typed `in:`/`from:` terms are not chipped here — they
16
+ * are already visible as the text the user typed, and chipping them would show
17
+ * the same term twice in one field; they render as chips over the result
18
+ * sections instead, where the text is not repeated.
12
19
  */
13
20
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
14
21
  import { AppTopBar, Button, SearchBar } from "@remit/ui";
@@ -16,8 +23,16 @@ import { SquarePen } from "lucide-react";
16
23
  import { AccountMenu } from "@/auth/AccountMenu";
17
24
  import { BugReportButton } from "@/components/ui/BugReportButton";
18
25
  import { useGlobalCompose } from "@/hooks/useComposeTarget";
26
+ import { useSearchScope } from "@/hooks/useSearchScope";
19
27
  import { tooltipForAction } from "@/lib/keymap";
20
28
  import { useMailContext } from "@/lib/mail-context";
29
+ import type { SearchScopeState } from "@/lib/search-scope";
30
+
31
+ const SEARCH_PLACEHOLDER: Record<SearchScopeState["kind"], string> = {
32
+ global: "Search all mail",
33
+ pending: "Search mail",
34
+ scoped: "Search this folder",
35
+ };
21
36
 
22
37
  interface MailTopBarProps {
23
38
  accounts: RemitImapAccountResponse[];
@@ -27,6 +42,15 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
27
42
  const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
28
43
  useMailContext();
29
44
  const compose = useGlobalCompose(accounts);
45
+ const { scope, clearScope } = useSearchScope(accounts);
46
+ const chips =
47
+ scope.kind === "scoped"
48
+ ? [{ id: scope.chip.id, label: scope.chip.label, tone: "scope" as const }]
49
+ : undefined;
50
+ // Only the brief may claim to search all mail. A mailbox route whose name has
51
+ // not loaded yet has no chip to show but is already narrowed, so it gets the
52
+ // neutral wording rather than a placeholder that asserts the wrong scope.
53
+ const placeholder = SEARCH_PLACEHOLDER[scope.kind];
30
54
 
31
55
  return (
32
56
  <AppTopBar
@@ -41,7 +65,9 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
41
65
  onChange={onSearchChange}
42
66
  onClear={onSearchClear}
43
67
  onClearQuery={onSearchClearQuery}
44
- placeholder="Search all mail"
68
+ chips={chips}
69
+ onRemoveChip={clearScope}
70
+ placeholder={placeholder}
45
71
  size="lg"
46
72
  />
47
73
  }
@@ -40,6 +40,7 @@ import { useNavigate } from "@tanstack/react-router";
40
40
  import { AlertCircle, RefreshCw, Sparkles } from "lucide-react";
41
41
  import { useCallback, useMemo, useState } from "react";
42
42
  import { useIsDesktop } from "@/hooks/useMediaQuery";
43
+ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
43
44
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
44
45
  import { sortAccountsByCreatedAt } from "@/lib/account-order";
45
46
  import {
@@ -168,7 +169,8 @@ export function DailyBrief({
168
169
  onSelectMessage,
169
170
  onSelectSearchResult,
170
171
  }: DailyBriefProps) {
171
- const { searchQuery, mailboxNameIndex, accountNameIndex } = useMailContext();
172
+ const { searchQuery } = useMailContext();
173
+ const tokenContext = useSearchTokenContext();
172
174
  const isDesktop = useIsDesktop();
173
175
 
174
176
  const nonMuted = useMemo(
@@ -259,7 +261,7 @@ export function DailyBrief({
259
261
 
260
262
  const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
261
263
  searchQuery.trim().toLowerCase(),
262
- { mailboxesByName: mailboxNameIndex, accountsByName: accountNameIndex },
264
+ tokenContext,
263
265
  );
264
266
 
265
267
  // Convert API rows to ThreadRowData, narrowing only by the selected account
@@ -22,6 +22,7 @@ import {
22
22
  import { useCallback, useMemo, useState } from "react";
23
23
  import { formatErrorMessage } from "@/components/ui/ErrorState";
24
24
  import { useIsDesktop } from "@/hooks/useMediaQuery";
25
+ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
25
26
  import { useStarredThreads } from "@/hooks/useStarredThreads";
26
27
  import {
27
28
  matchesBriefSearch,
@@ -49,7 +50,8 @@ export function FlaggedList({
49
50
  selectedMessageId,
50
51
  onSelectMessage,
51
52
  }: FlaggedListProps) {
52
- const { searchQuery, mailboxNameIndex, accountNameIndex } = useMailContext();
53
+ const { searchQuery } = useMailContext();
54
+ const tokenContext = useSearchTokenContext();
53
55
  const isDesktop = useIsDesktop();
54
56
 
55
57
  const [selectedCategory, setSelectedCategory] = useState("all");
@@ -84,7 +86,7 @@ export function FlaggedList({
84
86
 
85
87
  const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
86
88
  searchQuery.trim().toLowerCase(),
87
- { mailboxesByName: mailboxNameIndex, accountsByName: accountNameIndex },
89
+ tokenContext,
88
90
  );
89
91
 
90
92
  const rows = useMemo<ThreadRowData[]>(() => {
@@ -34,6 +34,7 @@ import {
34
34
  } from "@remit/ui";
35
35
  import { type ReactNode, useEffect, useRef, useState } from "react";
36
36
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
37
+ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
37
38
  import { useMailContext } from "@/lib/mail-context";
38
39
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
39
40
  import {
@@ -81,14 +82,9 @@ export function MailListHeader({
81
82
  searchResultsLabel = "Top matches",
82
83
  relatedResultsLabel = "Related",
83
84
  }: MailListHeaderProps) {
84
- const {
85
- searchInput,
86
- onSearchChange,
87
- onSearchClear,
88
- searchViewKey,
89
- mailboxNameIndex,
90
- accountNameIndex,
91
- } = useMailContext();
85
+ const { searchInput, onSearchChange, onSearchClear, searchViewKey } =
86
+ useMailContext();
87
+ const tokenContext = useSearchTokenContext();
92
88
  const layout = useAppShellLayout();
93
89
  const tier = useLayoutTier();
94
90
  const [searchOpen, setSearchOpen] = useState(false);
@@ -105,18 +101,18 @@ export function MailListHeader({
105
101
  }, [searchViewKey]);
106
102
 
107
103
  const hasQuery = searchInput.trim().length > 0;
108
- // Filter tokens (`from:`, `has:attachment`, `in:`, `account:`, …) parsed live
109
- // from the typed query render as removable chips above the sections;
110
- // removing one edits the query text directly, which re-parses on the next
111
- // render. `in:`/`account:` resolve against the shared name indexes so a
112
- // chip only appears for a name that actually matches a mailbox/account.
113
- const tokenChips = parseSearchTokens(searchInput, {
114
- mailboxesByName: mailboxNameIndex,
115
- accountsByName: accountNameIndex,
116
- }).tokens.map((token) => ({
117
- label: searchTokenLabel(token),
118
- onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
119
- }));
104
+ // Filter tokens (`from:`, `has:attachment`, `account:`, …) parsed live from
105
+ // the typed query render as removable chips above the sections; removing one
106
+ // edits the query text directly, which re-parses on the next render. The
107
+ // parse runs through `useSearchTokenContext`, the same one the engines use,
108
+ // so a chip appears only for a term that is actually being applied — on a
109
+ // scoped view `in:` is not resolved and so is never chipped here.
110
+ const tokenChips = parseSearchTokens(searchInput, tokenContext).tokens.map(
111
+ (token) => ({
112
+ label: searchTokenLabel(token),
113
+ onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
114
+ }),
115
+ );
120
116
  const topMatches = searchResults ?? [];
121
117
  const related = relatedResults ?? [];
122
118
  // Always offer both sections while a query is present; the kit drops the empty
@@ -291,8 +291,17 @@ export function MailSidebarAdapter({
291
291
  };
292
292
 
293
293
  const handleSelectNav = () => {
294
- // Navigation is handled by the router <Link>; this only runs the
295
- // post-selection side effect (drawer auto-close on mobile).
294
+ // Navigation is handled by the router <Link>; this runs the
295
+ // post-selection side effects.
296
+ //
297
+ // Clearing the field is one of them. Every link above drops `q`, and the
298
+ // shell re-seeds the field from the destination's `q` when the view
299
+ // changes — but picking the folder you are already in is the same view,
300
+ // so the shell leaves the field alone and the previous query would sit in
301
+ // the bar over a list that no longer has it in the URL. Selecting a nav
302
+ // entry is an unambiguous "show me this", so it clears here regardless.
303
+ // It is a user action, not a mirror, so it cannot race in-flight typing.
304
+ onSearchChange("");
296
305
  onMailboxSelect?.();
297
306
  };
298
307
 
@@ -88,6 +88,7 @@ import { useMailboxAccount } from "@/hooks/useMailboxAccount";
88
88
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
89
89
  import { useMoveMessages } from "@/hooks/useMoveMessages";
90
90
  import { useRescueCandidates } from "@/hooks/useRescueCandidates";
91
+ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
91
92
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
92
93
  import { useToggleStar } from "@/hooks/useToggleStar";
93
94
  import { useTriageKeyboard } from "@/hooks/useTriageKeyboard";
@@ -247,28 +248,25 @@ function MailboxPaneProvider({
247
248
  const telemetry = useTelemetry();
248
249
  const {
249
250
  accounts,
250
- mailboxNameIndex,
251
- accountNameIndex,
252
251
  searchQuery,
253
252
  searchInput,
254
253
  intelligenceOpen,
255
254
  onToggleIntelligence,
256
255
  onSetIntelligenceOpen,
257
256
  } = useMailContext();
257
+ const tokenContext = useSearchTokenContext();
258
258
 
259
259
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
260
260
  const hasSearchQuery = normalizedSearchQuery.length > 0;
261
261
  // Filter tokens (`from:`, `has:attachment`, `is:unread`) narrow this literal
262
262
  // search to the params `threadOperationsSearchThreads` supports; `before:`/
263
263
  // `after:`/`account:` have no equivalent on this endpoint and are left for
264
- // the semantic section only (see `useSemanticSearch`). `in:` is likewise not
265
- // applied here this view is already scoped to one mailbox by its route
266
- // but still needs the name-index context so a resolved `in:`/`account:`
267
- // token is stripped from `freeText` instead of leaking into the literal
268
- // query text as a stray word.
264
+ // the semantic section only (see `useSemanticSearch`). `in:` never reaches
265
+ // here at all: this view is scoped to one mailbox by its route, so
266
+ // `useSearchTokenContext` does not resolve the term and it stays free text.
269
267
  const { freeText, tokens: searchTokens } = parseSearchTokens(
270
268
  normalizedSearchQuery,
271
- { mailboxesByName: mailboxNameIndex, accountsByName: accountNameIndex },
269
+ tokenContext,
272
270
  );
273
271
  const fromToken = searchTokens.find((t) => t.type === "from");
274
272
  const searchThreadsQuery = {
@@ -949,12 +947,13 @@ function MailboxList() {
949
947
  () => threads.map(threadToSearchResult),
950
948
  [threads],
951
949
  );
952
- // The field above every pane says "Search all mail", and it means it (#47).
953
- // The literal engine only searches one mailbox at a time (there is no
954
- // cross-mailbox thread search), so this view names that section after the
955
- // mailbox and lets the semantic engine run unscoped — the "Everywhere"
956
- // section reaches the rest of the mail. Results are deduped by thread, so a
957
- // thread never shows in both.
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.
958
957
  const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
959
958
  filterCategory,
960
959
  });
@@ -232,6 +232,12 @@ export const MessageList = ({
232
232
  // leave this null, so the list never yanks focus out of the reading pane.
233
233
  const pendingDomFocusRef = useRef<string | null>(null);
234
234
 
235
+ // The row the cursor was on when the delete confirmation opened. The dialog
236
+ // takes DOM focus for as long as it is up, so dismissing it has to give that
237
+ // focus back or the list is left with no cursor and the next shortcut acts on
238
+ // nothing (#80).
239
+ const focusBeforeConfirmRef = useRef<string | null>(null);
240
+
235
241
  // Whether the list can serve keyboard commands at all. It stays true while
236
242
  // the delete confirmation is open — withdrawing the commands there would let
237
243
  // the route fall through to its own unconfirmed delete on a second Delete
@@ -341,9 +347,10 @@ export const MessageList = ({
341
347
  const requestDelete = useCallback(
342
348
  (ids: string[]) => {
343
349
  if (!onDeleteMessages || ids.length === 0) return;
350
+ focusBeforeConfirmRef.current = focusedMessageId ?? null;
344
351
  setPendingDeleteIds(ids);
345
352
  },
346
- [onDeleteMessages],
353
+ [onDeleteMessages, focusedMessageId],
347
354
  );
348
355
 
349
356
  // Keyboard shift-arrow range extend: move focus one row in `direction` and
@@ -452,9 +459,14 @@ export const MessageList = ({
452
459
 
453
460
  onDeleteMessages?.(pendingDeleteIds);
454
461
  clearSelection();
462
+ focusBeforeConfirmRef.current = null;
455
463
  setPendingDeleteIds(null);
456
464
 
457
465
  if (nextFocus !== undefined) {
466
+ // Same hand-back as cancelling, aimed at the surviving neighbour
467
+ // instead: confirming also closes a dialog that held DOM focus.
468
+ pendingDomFocusRef.current = nextFocus;
469
+ setFocusedMessageId(nextFocus);
458
470
  navigate({
459
471
  to: "/mail/$mailboxId",
460
472
  params: { mailboxId },
@@ -471,8 +483,17 @@ export const MessageList = ({
471
483
  mailboxId,
472
484
  ]);
473
485
 
486
+ // Every way out of the confirmation that isn't the delete — Escape, Cancel,
487
+ // the backdrop — arrives here, so this is the one place the keyboard has to
488
+ // be handed back. Restoring the cursor also puts DOM focus back on that row,
489
+ // via the same pending-focus channel j/k use.
474
490
  const handleCancelDelete = useCallback(() => {
491
+ const restoreTo = focusBeforeConfirmRef.current;
492
+ focusBeforeConfirmRef.current = null;
475
493
  setPendingDeleteIds(null);
494
+ if (restoreTo === null) return;
495
+ pendingDomFocusRef.current = restoreTo;
496
+ setFocusedMessageId(restoreTo);
476
497
  }, []);
477
498
 
478
499
  // Handle mark as read
@@ -46,8 +46,16 @@ import { MessageBody } from "@/components/mail/MessageBody";
46
46
  import { NavMenuButton } from "@/components/mail/NavMenuButton";
47
47
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
48
48
  import { buildMutationErrorBanner } from "@/components/ui/error-banners";
49
+ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
49
50
  import { formatDate as formatLocaleDate, toDate } from "@/lib/format";
51
+ import { useMailContext } from "@/lib/mail-context";
52
+ import {
53
+ matchesOutboxSearch,
54
+ outboxQueryIsUnsupported,
55
+ } from "@/lib/outbox-search";
50
56
  import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
57
+ import { normalizeSearchQuery } from "@/lib/search-query";
58
+ import { parseSearchTokens } from "@/lib/search-tokens";
51
59
  import { cn } from "@/lib/utils";
52
60
 
53
61
  /* ------------------------------------------------------------------ */
@@ -103,12 +111,31 @@ const formatRecipients = (message: RemitImapOutboxMessageResponse): string => {
103
111
  return `${recipients[0]} +${recipients.length - 1}`;
104
112
  };
105
113
 
114
+ /**
115
+ * What an empty outbox list says. A query that filtered everything out reads
116
+ * differently from an empty outbox, and a query the outbox cannot serve at all
117
+ * has to say so rather than look like a search that found nothing.
118
+ */
119
+ const outboxEmptyMessage = (
120
+ hasQuery: boolean,
121
+ unsupportedQuery: boolean,
122
+ ): string => {
123
+ if (unsupportedQuery) return "The outbox cannot filter on those terms";
124
+ if (hasQuery) return "No matching outbox messages";
125
+ return "No outbox messages";
126
+ };
127
+
106
128
  /* ------------------------------------------------------------------ */
107
129
  /* Context */
108
130
  /* ------------------------------------------------------------------ */
109
131
 
110
132
  interface OutboxPaneContextValue {
133
+ /** Outbox rows, narrowed by the search query when one is active. */
111
134
  messages: RemitImapOutboxMessageResponse[];
135
+ /** True while a query is narrowing `messages` — the empty state differs. */
136
+ hasQuery: boolean;
137
+ /** True when that query asks for a filter the outbox cannot apply. */
138
+ unsupportedQuery: boolean;
112
139
  isLoading: boolean;
113
140
  selectedMessageId: string | undefined;
114
141
  selectedMessage: RemitImapOutboxMessageResponse | undefined;
@@ -143,6 +170,20 @@ function OutboxPaneProvider({ children }: OutboxPaneProps) {
143
170
  outboxOperationsListOutboxMessagesOptions(),
144
171
  );
145
172
 
173
+ // The top bar's field scopes to this view (`in:outbox`), so a query typed
174
+ // here narrows these rows. The list is small and already fully loaded, so
175
+ // the narrowing is a filter over what is in hand — see lib/outbox-search.ts
176
+ // for which fields it matches and why the filter tokens are not honored.
177
+ // The query is parsed the same way every other engine parses it, and only
178
+ // the free text is used: `Q3 from:billing` still matches on "Q3" rather
179
+ // than searching the rows for the literal string "from:billing".
180
+ const { searchQuery } = useMailContext();
181
+ const tokenContext = useSearchTokenContext();
182
+ const normalizedQuery = normalizeSearchQuery(searchQuery);
183
+ const parsedQuery = parseSearchTokens(normalizedQuery, tokenContext);
184
+ const hasQuery = normalizedQuery.length > 0;
185
+ const unsupportedQuery = outboxQueryIsUnsupported(parsedQuery);
186
+
146
187
  // Outbox surfaces in-flight + actionable rows only. `draft` lives in the
147
188
  // Drafts view; `sent` rows are deleted by the IMAP append handler once
148
189
  // the message lands in Sent (issue #178), but we filter defensively so a
@@ -150,10 +191,12 @@ function OutboxPaneProvider({ children }: OutboxPaneProps) {
150
191
  // has not completed yet (issue #193).
151
192
  const messages = useMemo(
152
193
  () =>
153
- (outboxResponse?.items ?? []).filter((item) =>
154
- isOutboxListRow(item.status),
155
- ),
156
- [outboxResponse],
194
+ unsupportedQuery
195
+ ? []
196
+ : (outboxResponse?.items ?? [])
197
+ .filter((item) => isOutboxListRow(item.status))
198
+ .filter((item) => matchesOutboxSearch(item, parsedQuery.freeText)),
199
+ [outboxResponse, parsedQuery.freeText, unsupportedQuery],
157
200
  );
158
201
 
159
202
  const selectedMessage = useMemo(
@@ -176,6 +219,8 @@ function OutboxPaneProvider({ children }: OutboxPaneProps) {
176
219
 
177
220
  const ctx: OutboxPaneContextValue = {
178
221
  messages,
222
+ hasQuery,
223
+ unsupportedQuery,
179
224
  isLoading,
180
225
  selectedMessageId,
181
226
  selectedMessage,
@@ -381,8 +426,14 @@ function OutboxMessageDetail({ message, onBack }: OutboxMessageDetailProps) {
381
426
  * Outbox list with datum bar header. Mount in the `list` slot of `AppShellSlotted`.
382
427
  */
383
428
  function OutboxList() {
384
- const { messages, isLoading, selectedMessageId, onSelectMessage } =
385
- useOutboxPane();
429
+ const {
430
+ messages,
431
+ hasQuery,
432
+ unsupportedQuery,
433
+ isLoading,
434
+ selectedMessageId,
435
+ onSelectMessage,
436
+ } = useOutboxPane();
386
437
 
387
438
  if (isLoading) {
388
439
  return (
@@ -395,7 +446,10 @@ function OutboxList() {
395
446
  if (messages.length === 0) {
396
447
  return (
397
448
  <div className="flex h-full bg-surface">
398
- <ReadingPaneEmpty message="No outbox messages" showHints={false} />
449
+ <ReadingPaneEmpty
450
+ message={outboxEmptyMessage(hasQuery, unsupportedQuery)}
451
+ showHints={false}
452
+ />
399
453
  </div>
400
454
  );
401
455
  }
@@ -462,6 +516,8 @@ function OutboxReading() {
462
516
  function OutboxPhone() {
463
517
  const {
464
518
  messages,
519
+ hasQuery,
520
+ unsupportedQuery,
465
521
  isLoading,
466
522
  selectedMessageId,
467
523
  selectedMessage,
@@ -488,7 +544,10 @@ function OutboxPhone() {
488
544
  if (messages.length === 0) {
489
545
  return (
490
546
  <div className="flex h-full bg-surface">
491
- <ReadingPaneEmpty message="No outbox messages" showHints={false} />
547
+ <ReadingPaneEmpty
548
+ message={outboxEmptyMessage(hasQuery, unsupportedQuery)}
549
+ showHints={false}
550
+ />
492
551
  </div>
493
552
  );
494
553
  }
@@ -0,0 +1,49 @@
1
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
3
+ import { useCallback } from "react";
4
+ import { useMailContext } from "@/lib/mail-context";
5
+ import {
6
+ SEARCH_SCOPE_CHIP_ID,
7
+ type SearchScopeState,
8
+ searchScopeForRoute,
9
+ } from "@/lib/search-scope";
10
+ import { useCurrentMailboxName } from "./useCurrentMailboxName";
11
+
12
+ /**
13
+ * The scope chip for the active route and the way off it.
14
+ *
15
+ * `clearScope` navigates to the daily brief carrying the current query, which
16
+ * is what "search everything" means here — the brief is the unscoped
17
+ * cross-account view. It is a navigation rather than a query edit because the
18
+ * chip mirrors the route (see `lib/search-scope.ts`); editing the text would
19
+ * leave the chip in place and the list still narrowed.
20
+ */
21
+ export function useSearchScope(accounts: RemitImapAccountResponse[]): {
22
+ scope: SearchScopeState;
23
+ clearScope: (chipId: string) => void;
24
+ } {
25
+ const navigate = useNavigate();
26
+ const { searchInput } = useMailContext();
27
+ const mailboxName = useCurrentMailboxName({ accounts });
28
+ // Select the matches, not the scope: a select closing over `mailboxName`
29
+ // only re-runs on router state changes, so the chip would keep reading the
30
+ // previous folder until the next navigation.
31
+ const matches = useRouterState({ select: (s) => s.matches });
32
+ const scope = searchScopeForRoute(matches, mailboxName);
33
+
34
+ // Takes the chip id the field removed rather than assuming which chip that
35
+ // was. The bar owns one chip today; keying on the id means a second one
36
+ // added later cannot silently drop the user out of their scope.
37
+ const clearScope = useCallback(
38
+ (chipId: string) => {
39
+ if (chipId !== SEARCH_SCOPE_CHIP_ID) return;
40
+ navigate({
41
+ to: "/mail",
42
+ search: { q: searchInput || undefined, selectedMessageId: undefined },
43
+ });
44
+ },
45
+ [navigate, searchInput],
46
+ );
47
+
48
+ return { scope, clearScope };
49
+ }
@@ -0,0 +1,28 @@
1
+ import { useRouterState } from "@tanstack/react-router";
2
+ import { useMailContext } from "@/lib/mail-context";
3
+ import { isScopedRoute } from "@/lib/search-scope";
4
+ import type { SearchTokenContext } from "@/lib/search-tokens";
5
+
6
+ /**
7
+ * The one context every search engine parses the query through.
8
+ *
9
+ * `in:` is recognized only where the route carries no scope. On a scoped view
10
+ * the top bar's chip already answers "which mailbox", and a typed `in:` is a
11
+ * second, competing answer that no engine there acts on — the literal search is
12
+ * pinned to the route's mailbox and the semantic request takes the caller's
13
+ * scope. Leaving the term unresolved means it stays ordinary words: it is
14
+ * matched as text and never chipped, so the field can't advertise a filter that
15
+ * does nothing. To search another folder, drop the scope chip or go to it.
16
+ *
17
+ * Every call site takes this hook rather than building its own context, because
18
+ * the guarantee is that they agree. Two of them disagreeing is what made the
19
+ * same query behave one way on `/mail/flagged` and another on a mailbox.
20
+ */
21
+ export function useSearchTokenContext(): SearchTokenContext {
22
+ const { mailboxNameIndex, accountNameIndex } = useMailContext();
23
+ const scoped = useRouterState({ select: (s) => isScopedRoute(s.matches) });
24
+ return {
25
+ ...(scoped ? {} : { mailboxesByName: mailboxNameIndex }),
26
+ accountsByName: accountNameIndex,
27
+ };
28
+ }
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
5
5
  import { useMailContext } from "@/lib/mail-context";
6
6
  import { normalizeSearchQuery } from "@/lib/search-query";
7
7
  import { parseSearchTokens } from "@/lib/search-tokens";
8
+ import { useSearchTokenContext } from "./useSearchTokenContext";
8
9
 
9
10
  /** Cap the "Related" section; the literal "Top matches" is the primary surface. */
10
11
  const SEMANTIC_RESULT_LIMIT = 20;
@@ -44,9 +45,14 @@ interface UseSemanticSearchParams {
44
45
  * account filter) — both still render as chips and narrow the literal engine,
45
46
  * but never reach the semantic request (`account:` is a documented gap, see
46
47
  * doc/design/flows/06-search.md — the semantic index is per account config).
47
- * `in:` resolves to a mailboxId and, when present, overrides the caller's
48
- * `mailboxId` so typing `in:archive` re-scopes the semantic search to that
49
- * mailbox even from the cross-account daily brief.
48
+ * `in:` resolves to a mailboxId, so typing `in:archive` re-scopes the search
49
+ * from the unscoped daily brief. It cannot contradict a scoped view because
50
+ * `useSearchTokenContext` does not resolve the term there at all — the term
51
+ * stays free text and no engine, and no chip, treats it as a filter.
52
+ *
53
+ * Without a `mailboxId` the request spans every mailbox of every account the
54
+ * caller owns: the backend partitions the vector index by accountConfigId (one
55
+ * per signed-in user, not per mail account), so unscoped is genuinely global.
50
56
  */
51
57
  export function useSemanticSearch({
52
58
  mailboxId,
@@ -55,12 +61,10 @@ export function useSemanticSearch({
55
61
  hits: RemitImapSemanticSearchResult[];
56
62
  isLoading: boolean;
57
63
  } {
58
- const { searchQuery, mailboxNameIndex, accountNameIndex } = useMailContext();
64
+ const { searchQuery } = useMailContext();
65
+ const tokenContext = useSearchTokenContext();
59
66
  const normalizedQuery = normalizeSearchQuery(searchQuery);
60
- const { freeText, tokens } = parseSearchTokens(normalizedQuery, {
61
- mailboxesByName: mailboxNameIndex,
62
- accountsByName: accountNameIndex,
63
- });
67
+ const { freeText, tokens } = parseSearchTokens(normalizedQuery, tokenContext);
64
68
  const enabled = normalizedQuery.length > 0;
65
69
 
66
70
  const category = toCategoryParam(filterCategory);
@@ -71,7 +75,7 @@ export function useSemanticSearch({
71
75
  const afterToken = tokens.find((t) => t.type === "after");
72
76
  const beforeToken = tokens.find((t) => t.type === "before");
73
77
  const inToken = tokens.find((t) => t.type === "in");
74
- const effectiveMailboxId = inToken?.mailboxId ?? mailboxId;
78
+ const effectiveMailboxId = mailboxId ?? inToken?.mailboxId;
75
79
 
76
80
  const { data, isLoading } = useQuery({
77
81
  ...semanticSearchOperationsSemanticSearchOptions({
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { matchesOutboxSearch, outboxQueryIsUnsupported } from "./outbox-search";
4
+ import { parseSearchTokens } from "./search-tokens";
5
+
6
+ const row = {
7
+ subject: "Q3 Invoice",
8
+ fromAddress: "me@example.com",
9
+ fromName: "Me",
10
+ toAddresses: ["billing@acme.test"],
11
+ ccAddresses: ["accounts@acme.test"],
12
+ };
13
+
14
+ describe("matchesOutboxSearch", () => {
15
+ it("keeps every row when there is no query", () => {
16
+ assert.equal(matchesOutboxSearch(row, " "), true);
17
+ });
18
+
19
+ it("matches the subject", () => {
20
+ assert.equal(matchesOutboxSearch(row, "invoice"), true);
21
+ });
22
+
23
+ it("matches a recipient", () => {
24
+ assert.equal(matchesOutboxSearch(row, "acme"), true);
25
+ });
26
+
27
+ it("matches the sender", () => {
28
+ assert.equal(matchesOutboxSearch(row, "me@example"), true);
29
+ });
30
+
31
+ it("requires every word, in any order and any field", () => {
32
+ assert.equal(matchesOutboxSearch(row, "invoice billing"), true);
33
+ assert.equal(matchesOutboxSearch(row, "invoice missing"), false);
34
+ });
35
+
36
+ it("drops a row that matches nothing", () => {
37
+ assert.equal(matchesOutboxSearch(row, "receipt"), false);
38
+ });
39
+
40
+ it("tolerates rows with absent fields", () => {
41
+ assert.equal(matchesOutboxSearch({}, "anything"), false);
42
+ assert.equal(matchesOutboxSearch({}, ""), true);
43
+ });
44
+
45
+ it("matches the free text of a query that also carries a token", () => {
46
+ // The token is dropped, but what the user typed alongside it still
47
+ // searches — the row is not lost to an operator the outbox can't serve.
48
+ const { freeText } = parseSearchTokens("Q3 from:billing");
49
+ assert.equal(matchesOutboxSearch(row, freeText), true);
50
+ });
51
+ });
52
+
53
+ describe("outboxQueryIsUnsupported", () => {
54
+ it("is true for a query that is only tokens", () => {
55
+ assert.equal(
56
+ outboxQueryIsUnsupported(parseSearchTokens("from:billing")),
57
+ true,
58
+ );
59
+ });
60
+
61
+ it("is false when free text survives the parse", () => {
62
+ assert.equal(
63
+ outboxQueryIsUnsupported(parseSearchTokens("Q3 from:billing")),
64
+ false,
65
+ );
66
+ });
67
+
68
+ it("is false for plain free text", () => {
69
+ assert.equal(outboxQueryIsUnsupported(parseSearchTokens("invoice")), false);
70
+ });
71
+
72
+ it("is false for an empty query", () => {
73
+ assert.equal(outboxQueryIsUnsupported(parseSearchTokens("")), false);
74
+ });
75
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Query narrowing for the outbox list.
3
+ *
4
+ * The outbox is a small, fully-loaded client-side list of mail that has not
5
+ * left yet, so its search is a filter over the rows already in hand rather
6
+ * than a request. It matches what an outbox row shows — subject, recipients
7
+ * and sender — because that is what the user can see to search for.
8
+ *
9
+ * Filter tokens (`from:`, `is:unread`, dates) are not honored: an outbox row
10
+ * has no read state, no mailbox and no received date, so the operators have
11
+ * nothing to read. Only the free text narrows, so `Q3 from:billing` still
12
+ * matches on "Q3".
13
+ *
14
+ * A query made only of tokens asks for a filter this view cannot apply, and
15
+ * there is no free text left to fall back on. Rather than return every row as
16
+ * if the filter had been honored, that case returns nothing and the list says
17
+ * why — see `outboxQueryIsUnsupported`.
18
+ */
19
+ import type { ParsedSearchQuery } from "./search-tokens";
20
+
21
+ /** The outbox row fields a query is matched against. */
22
+ export interface OutboxSearchRow {
23
+ subject?: string | null;
24
+ fromAddress?: string | null;
25
+ fromName?: string | null;
26
+ toAddresses?: readonly string[] | null;
27
+ ccAddresses?: readonly string[] | null;
28
+ }
29
+
30
+ export function matchesOutboxSearch(
31
+ row: OutboxSearchRow,
32
+ freeText: string,
33
+ ): boolean {
34
+ const needle = freeText.trim().toLowerCase();
35
+ if (needle.length === 0) return true;
36
+ const haystack = [
37
+ row.subject,
38
+ row.fromAddress,
39
+ row.fromName,
40
+ ...(row.toAddresses ?? []),
41
+ ...(row.ccAddresses ?? []),
42
+ ]
43
+ .filter((value): value is string => typeof value === "string")
44
+ .join(" ")
45
+ .toLowerCase();
46
+ return needle
47
+ .split(/\s+/)
48
+ .every((word) => word.length === 0 || haystack.includes(word));
49
+ }
50
+
51
+ /**
52
+ * True when the query is nothing but filter tokens. The outbox can serve none
53
+ * of them, and matching on the empty free text left over would return every
54
+ * row — indistinguishable from a filter that matched everything.
55
+ */
56
+ export function outboxQueryIsUnsupported(parsed: ParsedSearchQuery): boolean {
57
+ return parsed.tokens.length > 0 && parsed.freeText.trim().length === 0;
58
+ }
@@ -0,0 +1,104 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ MAIL_BRIEF_ROUTE_ID,
5
+ MAIL_FLAGGED_ROUTE_ID,
6
+ MAIL_MAILBOX_ROUTE_ID,
7
+ MAIL_OUTBOX_ROUTE_ID,
8
+ } from "./mail-route";
9
+ import {
10
+ isScopedRoute,
11
+ SEARCH_SCOPE_CHIP_ID,
12
+ scopeLabelForMailboxName,
13
+ searchScopeForRoute,
14
+ } from "./search-scope";
15
+
16
+ const matches = (routeId: string) => [{ routeId: "/mail" }, { routeId }];
17
+
18
+ describe("searchScopeForRoute", () => {
19
+ it("gives the daily brief no scope — it is the global view", () => {
20
+ assert.deepEqual(searchScopeForRoute(matches(MAIL_BRIEF_ROUTE_ID)), {
21
+ kind: "global",
22
+ });
23
+ });
24
+
25
+ it("scopes a mailbox route to its sidebar label", () => {
26
+ assert.deepEqual(
27
+ searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), "Spam"),
28
+ {
29
+ kind: "scoped",
30
+ chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
31
+ },
32
+ );
33
+ });
34
+
35
+ it("is pending, not global, while the mailbox name resolves", () => {
36
+ // The list under the bar is already one mailbox. A chip reading a raw
37
+ // uuid is worse than no chip, but the field must not claim to search
38
+ // everything either.
39
+ assert.deepEqual(
40
+ searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), null),
41
+ { kind: "pending" },
42
+ );
43
+ });
44
+
45
+ it("scopes the starred view", () => {
46
+ const scope = searchScopeForRoute(matches(MAIL_FLAGGED_ROUTE_ID));
47
+ assert.equal(scope.kind === "scoped" && scope.chip.label, "is:starred");
48
+ });
49
+
50
+ it("scopes the outbox", () => {
51
+ const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID));
52
+ assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
53
+ });
54
+
55
+ it("prefers the virtual views over a lingering mailbox name", () => {
56
+ const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID), "Spam");
57
+ assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
58
+ });
59
+ });
60
+
61
+ describe("isScopedRoute", () => {
62
+ // This predicate decides whether a typed `in:` is recognized at all, so
63
+ // every route has to answer it the same way it answers for its chip: a
64
+ // route showing a scope chip must not also honour a competing typed term.
65
+ for (const routeId of [
66
+ MAIL_MAILBOX_ROUTE_ID,
67
+ MAIL_FLAGGED_ROUTE_ID,
68
+ MAIL_OUTBOX_ROUTE_ID,
69
+ ]) {
70
+ it(`${routeId} carries a scope`, () => {
71
+ assert.equal(isScopedRoute(matches(routeId)), true);
72
+ });
73
+ }
74
+
75
+ it("the daily brief carries none", () => {
76
+ assert.equal(isScopedRoute(matches(MAIL_BRIEF_ROUTE_ID)), false);
77
+ });
78
+
79
+ it("agrees with the chip on every route", () => {
80
+ for (const routeId of [
81
+ MAIL_BRIEF_ROUTE_ID,
82
+ MAIL_MAILBOX_ROUTE_ID,
83
+ MAIL_FLAGGED_ROUTE_ID,
84
+ MAIL_OUTBOX_ROUTE_ID,
85
+ ]) {
86
+ const scope = searchScopeForRoute(matches(routeId), "Spam");
87
+ assert.equal(
88
+ isScopedRoute(matches(routeId)),
89
+ scope.kind === "scoped",
90
+ `${routeId} disagrees about whether it has a scope`,
91
+ );
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("scopeLabelForMailboxName", () => {
97
+ it("lower-cases so the chip reads like the operator it mimics", () => {
98
+ assert.equal(scopeLabelForMailboxName("Archive"), "in:archive");
99
+ });
100
+
101
+ it("quotes a multi-word folder so the chip stays one term", () => {
102
+ assert.equal(scopeLabelForMailboxName("Work Stuff"), 'in:"work stuff"');
103
+ });
104
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The search scope is the route.
3
+ *
4
+ * Search is the app's search: one field in the top bar, and by default it
5
+ * covers everything. Navigating the sidebar narrows it, and the narrowing is
6
+ * shown as one chip in the field — `in:spam` while viewing Spam. The chip is
7
+ * derived from the active route, not parsed out of the typed text: the route
8
+ * decides which pane is mounted and which mail that pane reads, and the chip is
9
+ * that decision made visible. Navigating elsewhere replaces the chip because the
10
+ * route changed.
11
+ *
12
+ * Removing the chip means "search everything", which is the daily brief — the
13
+ * cross-account view whose scope is nothing. So removal is a navigation to
14
+ * `/mail/` carrying the query, not an edit of the query text.
15
+ *
16
+ * There is exactly one scope, and it is this one. A typed `in:` term is a
17
+ * second, competing answer to "which mailbox", so it is recognized only where
18
+ * there is no route scope to compete with — see `isScopedRoute` and
19
+ * `useSearchTokenContext`. Every engine parses the query through that one
20
+ * context, so a scoped view cannot show a chip for a term it is ignoring.
21
+ *
22
+ * Pure functions only. `useSearchScope` binds these to the router.
23
+ */
24
+ import {
25
+ isBriefRoute,
26
+ isFlaggedRoute,
27
+ isMailboxRoute,
28
+ isOutboxRoute,
29
+ type MailRouteMatch,
30
+ } from "./mail-route";
31
+
32
+ /**
33
+ * Chip id of the scope chip. The top bar owns exactly one, so a fixed id is
34
+ * enough of a removal handle.
35
+ */
36
+ export const SEARCH_SCOPE_CHIP_ID = "search-scope";
37
+
38
+ export interface SearchScope {
39
+ id: string;
40
+ /** What the chip reads, e.g. `in:spam`. */
41
+ label: string;
42
+ }
43
+
44
+ /**
45
+ * What the field can say about its own scope.
46
+ *
47
+ * `pending` is the mailbox route before its name has loaded: the list under the
48
+ * bar is already one mailbox, so the field must not claim to search everything,
49
+ * but there is no name yet to put in a chip. It is a real third state, not a
50
+ * flavour of `global`.
51
+ */
52
+ export type SearchScopeState =
53
+ | { kind: "global" }
54
+ | { kind: "pending" }
55
+ | { kind: "scoped"; chip: SearchScope };
56
+
57
+ /**
58
+ * Render a mailbox name as an `in:` term. Lower-cased so it reads like the
59
+ * operator it mimics, and quoted when the name has whitespace so the chip
60
+ * stays one legible term.
61
+ */
62
+ export function scopeLabelForMailboxName(name: string): string {
63
+ const collapsed = name.trim().replace(/\s+/g, " ").toLowerCase();
64
+ return /\s/.test(collapsed) ? `in:"${collapsed}"` : `in:${collapsed}`;
65
+ }
66
+
67
+ /**
68
+ * True on the routes whose pane reads one mailbox or one virtual collection.
69
+ * These carry a scope; the daily brief (and any route not recognized here) does
70
+ * not.
71
+ */
72
+ export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
73
+ return (
74
+ isFlaggedRoute(matches) || isOutboxRoute(matches) || isMailboxRoute(matches)
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The scope of the active route.
80
+ *
81
+ * `mailboxName` is the sidebar's own label for the mailbox route (resolved by
82
+ * `useCurrentMailboxName`). Until it resolves the scope is `pending`: no chip,
83
+ * because a chip reading a raw uuid is worse than none, but not `global`
84
+ * either, because the list underneath is already narrowed.
85
+ */
86
+ export function searchScopeForRoute(
87
+ matches: readonly MailRouteMatch[],
88
+ mailboxName?: string | null,
89
+ ): SearchScopeState {
90
+ if (isBriefRoute(matches)) return { kind: "global" };
91
+ if (isFlaggedRoute(matches)) {
92
+ return {
93
+ kind: "scoped",
94
+ chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
95
+ };
96
+ }
97
+ if (isOutboxRoute(matches)) {
98
+ return {
99
+ kind: "scoped",
100
+ chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
101
+ };
102
+ }
103
+ if (isMailboxRoute(matches)) {
104
+ if (!mailboxName) return { kind: "pending" };
105
+ return {
106
+ kind: "scoped",
107
+ chip: {
108
+ id: SEARCH_SCOPE_CHIP_ID,
109
+ label: scopeLabelForMailboxName(mailboxName),
110
+ },
111
+ };
112
+ }
113
+ return { kind: "global" };
114
+ }
@@ -109,3 +109,97 @@ describe("shouldMirrorQuery", () => {
109
109
  assert.equal(shouldMirrorQuery("invoi", "inv", ""), false);
110
110
  });
111
111
  });
112
+
113
+ /**
114
+ * The three rules compose into the shell's behaviour (`routes/mail.tsx`): the
115
+ * field is re-seeded during render on a view change, and the mirror decides
116
+ * separately whether to write back. These check the composition, because the
117
+ * two ways search ends — a view change (#47) and the sidebar's own clear —
118
+ * both have to end it without ever writing over what the user is typing.
119
+ */
120
+ interface Shell {
121
+ viewKey: string;
122
+ field: string;
123
+ debounced: string;
124
+ url: string;
125
+ }
126
+
127
+ /** One render of the shell for a location, returning the state it settles on. */
128
+ const render = (shell: Shell, viewKey: string, url: string): Shell => {
129
+ const field =
130
+ viewKey === shell.viewKey
131
+ ? shell.field
132
+ : (searchInputForView(shell.viewKey, viewKey, url) ?? shell.field);
133
+ return { ...shell, viewKey, field, url };
134
+ };
135
+
136
+ /** What the mirror effect would do after that render. */
137
+ const mirror = (shell: Shell): Shell => {
138
+ const committed = committedSearchQuery(shell.field, shell.debounced);
139
+ if (!shouldMirrorQuery(shell.field, committed, shell.url)) return shell;
140
+ return { ...shell, url: committed };
141
+ };
142
+
143
+ const typing = (shell: Shell, text: string): Shell => ({
144
+ ...shell,
145
+ field: text,
146
+ });
147
+ const settle = (shell: Shell): Shell => ({ ...shell, debounced: shell.field });
148
+
149
+ describe("search across a view change", () => {
150
+ const searching: Shell = {
151
+ viewKey: mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
152
+ field: "invoice",
153
+ debounced: "invoice",
154
+ url: "invoice",
155
+ };
156
+
157
+ it("ends the search when the user leaves the view", () => {
158
+ // The nav link drops `q`, so the destination carries none.
159
+ const next = mirror(
160
+ render(searching, mailViewKey(matches("/mail/$mailboxId", "sent-1")), ""),
161
+ );
162
+ assert.equal(next.field, "");
163
+ assert.equal(next.url, "");
164
+ });
165
+
166
+ it("does not put the query it just left onto the view it landed on", () => {
167
+ // The debounce still holds "invoice" for up to 200ms after the move. The
168
+ // mirror must not write it back — that is #47 returning by another route.
169
+ const landed = render(
170
+ searching,
171
+ mailViewKey(matches("/mail/$mailboxId", "sent-1")),
172
+ "",
173
+ );
174
+ assert.equal(landed.debounced, "invoice");
175
+ assert.equal(mirror(landed).url, "");
176
+ });
177
+
178
+ it("keeps a query the destination carries (scope chip removed)", () => {
179
+ // Dropping the scope chip navigates to the brief carrying the query, so
180
+ // the same words are searched with a wider scope.
181
+ const next = mirror(
182
+ render(searching, mailViewKey(matches("/mail/")), "invoice"),
183
+ );
184
+ assert.equal(next.field, "invoice");
185
+ assert.equal(next.url, "invoice");
186
+ });
187
+
188
+ it("never clobbers characters the user is still typing", () => {
189
+ // Opening a result and the q-mirror both re-render the same view. Neither
190
+ // is a view change, so neither may reach into the field.
191
+ const mailbox = mailViewKey(matches("/mail/$mailboxId", "inbox-1"));
192
+ let shell = typing(
193
+ { ...searching, field: "", debounced: "", url: "" },
194
+ "i",
195
+ );
196
+ shell = mirror(render(shell, mailbox, shell.url));
197
+ shell = typing(shell, "inv");
198
+ shell = mirror(render(shell, mailbox, shell.url));
199
+ shell = typing(shell, "invoice");
200
+ shell = settle(mirror(render(shell, mailbox, shell.url)));
201
+ shell = mirror(render(shell, mailbox, shell.url));
202
+ assert.equal(shell.field, "invoice");
203
+ assert.equal(shell.url, "invoice");
204
+ });
205
+ });
@@ -98,8 +98,8 @@ function MailLayout() {
98
98
 
99
99
  // Within one view, URL `q` seeds the input and is a one-directional write
100
100
  // target: the debounced local value drives the search API and is mirrored
101
- // back. Across views the URL wins again — see the view-change effect below
102
- // and `lib/search-view.ts` (#47).
101
+ // back. Across views the URL wins again — see the view-change adjustment
102
+ // below and `lib/search-view.ts` (#47).
103
103
  const [searchInput, setSearchInput] = useState(searchQuery);
104
104
  const debouncedSearchInput = useDebouncedValue(searchInput, 200);
105
105
  const committedQuery = committedSearchQuery(
@@ -110,11 +110,34 @@ function MailLayout() {
110
110
  const searchQueryRef = useRef(searchQuery);
111
111
  searchQueryRef.current = searchQuery;
112
112
 
113
+ // Search is a mode of the view it was typed in, so leaving that view re-seeds
114
+ // the field from wherever we land (#47): empty when the sidebar dropped `q`
115
+ // (a folder switch starts that folder's search fresh), and the carried query
116
+ // when the top bar's scope chip was removed and sent the user to the brief to
117
+ // search everything. Views that differ only in search params — opening a
118
+ // result, mirroring `q` — are the same view, so in-flight typing survives
119
+ // them (`searchInputForView`).
120
+ //
121
+ // Adjusted during render, not in an effect. This is React's documented
122
+ // "adjusting state when a prop changes" pattern: both updates are to this
123
+ // component's own state and are guarded by a changed value, so React re-runs
124
+ // the render before committing and nothing is painted with the stale query.
125
+ // An effect would commit one frame carrying the previous view's text, which
126
+ // the mirror below then has to be defended against.
127
+ const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
128
+ const [searchViewKey, setSearchViewKey] = useState(viewKey);
129
+ if (searchViewKey !== viewKey) {
130
+ const seeded = searchInputForView(searchViewKey, viewKey, searchQuery);
131
+ setSearchViewKey(viewKey);
132
+ if (seeded !== undefined) setSearchInput(seeded);
133
+ }
134
+
113
135
  // Mirror the settled search into the URL so links are shareable and a
114
136
  // refresh restores the query. Within a view this is one-directional — the
115
137
  // URL is not read back into state, so there is no sync loop — and it writes
116
138
  // only once the debounce agrees with the field, so a query arriving by URL
117
- // is never overwritten mid-debounce (`shouldMirrorQuery`).
139
+ // is never overwritten mid-debounce, and the query the user just left behind
140
+ // is never written onto the view they landed on (`shouldMirrorQuery`).
118
141
  // When a query *goes* active, also strip selectedMessageId so the reading
119
142
  // pane closes (#539): an open message from the pre-search list is not
120
143
  // meaningful in the search result set. Only strip on that transition though —
@@ -147,24 +170,6 @@ function MailLayout() {
147
170
  });
148
171
  }, [searchInput, committedQuery, navigate]);
149
172
 
150
- // Search is a mode of the view it was typed in, so leaving that view re-seeds
151
- // the field from wherever we land. The nav links already drop `q` when they
152
- // switch mailbox, so this clears the query instead of silently re-running it
153
- // against the new mailbox (#47), while a deep link or a saved search that
154
- // carries `q` still arrives searching.
155
- const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
156
- const viewKeyRef = useRef(viewKey);
157
- useEffect(() => {
158
- const next = searchInputForView(
159
- viewKeyRef.current,
160
- viewKey,
161
- searchQueryRef.current,
162
- );
163
- viewKeyRef.current = viewKey;
164
- if (next === undefined) return;
165
- setSearchInput(next);
166
- }, [viewKey]);
167
-
168
173
  const {
169
174
  data: config,
170
175
  isLoading,