@remit/web-client 0.0.17 → 0.0.19

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.17",
3
+ "version": "0.0.19",
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[]>(() => {
@@ -14,11 +14,13 @@
14
14
  * `SearchResults` sections under the shared `FilterSheet`. Consumers feed the
15
15
  * filter chrome and query-narrowed results; both tiers render identical rows.
16
16
  *
17
- * Results split into two sections: "Top matches" (literal/instant search) and
18
- * "Related" (semantic). The consumer dedupes them — a thread in both appears
19
- * only under "Top matches". Each loads independently; an empty section drops out
20
- * kit-side, so a "Related"-only result still shows when the literal search finds
21
- * nothing. The hamburger opens the nav drawer via the enclosing `AppShellSlotted`.
17
+ * Results split into two sections, one per engine: literal/instant and semantic.
18
+ * The consumer dedupes them — a thread in both appears only under the literal
19
+ * section and names them, so a view whose engines reach different mail says so
20
+ * ("In Archive" / "Everywhere"). Each loads independently; an empty section
21
+ * drops out kit-side, so a semantic-only result still shows when the literal
22
+ * search finds nothing. The hamburger opens the nav drawer via the enclosing
23
+ * `AppShellSlotted`.
22
24
  */
23
25
  import {
24
26
  FilterSheet,
@@ -30,8 +32,9 @@ import {
30
32
  SearchResults,
31
33
  useAppShellLayout,
32
34
  } from "@remit/ui";
33
- import { type ReactNode, useState } from "react";
35
+ import { type ReactNode, useEffect, useRef, useState } from "react";
34
36
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
37
+ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
35
38
  import { useMailContext } from "@/lib/mail-context";
36
39
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
37
40
  import {
@@ -56,6 +59,13 @@ interface MailListHeaderProps {
56
59
  relatedResults?: SearchResult[];
57
60
  relatedLoading?: boolean;
58
61
  onSelectSearchResult?: (result: SearchResult) => void;
62
+ /**
63
+ * Section headings. A view whose two engines cover different ground names
64
+ * them by that ground ("In Archive" / "Everywhere") so the reach of a result
65
+ * is on screen rather than inferred.
66
+ */
67
+ searchResultsLabel?: string;
68
+ relatedResultsLabel?: string;
59
69
  }
60
70
 
61
71
  export function MailListHeader({
@@ -69,32 +79,40 @@ export function MailListHeader({
69
79
  relatedResults,
70
80
  relatedLoading,
71
81
  onSelectSearchResult,
82
+ searchResultsLabel = "Top matches",
83
+ relatedResultsLabel = "Related",
72
84
  }: MailListHeaderProps) {
73
- const {
74
- searchInput,
75
- onSearchChange,
76
- onSearchClear,
77
- mailboxNameIndex,
78
- accountNameIndex,
79
- } = useMailContext();
85
+ const { searchInput, onSearchChange, onSearchClear, searchViewKey } =
86
+ useMailContext();
87
+ const tokenContext = useSearchTokenContext();
80
88
  const layout = useAppShellLayout();
81
89
  const tier = useLayoutTier();
82
90
  const [searchOpen, setSearchOpen] = useState(false);
83
91
  const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
84
92
 
93
+ // Leaving the view ends the search: the shell drops the query, and the chrome
94
+ // it opened — the phone takeover, the expanded tablet field — closes with it
95
+ // rather than sitting there empty over the new mailbox (#47).
96
+ const searchViewRef = useRef(searchViewKey);
97
+ useEffect(() => {
98
+ if (searchViewRef.current === searchViewKey) return;
99
+ searchViewRef.current = searchViewKey;
100
+ setSearchOpen(false);
101
+ }, [searchViewKey]);
102
+
85
103
  const hasQuery = searchInput.trim().length > 0;
86
- // Filter tokens (`from:`, `has:attachment`, `in:`, `account:`, …) parsed live
87
- // from the typed query render as removable chips above the sections;
88
- // removing one edits the query text directly, which re-parses on the next
89
- // render. `in:`/`account:` resolve against the shared name indexes so a
90
- // chip only appears for a name that actually matches a mailbox/account.
91
- const tokenChips = parseSearchTokens(searchInput, {
92
- mailboxesByName: mailboxNameIndex,
93
- accountsByName: accountNameIndex,
94
- }).tokens.map((token) => ({
95
- label: searchTokenLabel(token),
96
- onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
97
- }));
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
+ );
98
116
  const topMatches = searchResults ?? [];
99
117
  const related = relatedResults ?? [];
100
118
  // Always offer both sections while a query is present; the kit drops the empty
@@ -102,8 +120,8 @@ export function MailListHeader({
102
120
  // state. The empty-query case (recent searches) is the kit's job.
103
121
  const sections: SearchResultSection[] = hasQuery
104
122
  ? [
105
- { id: "top", label: "Top matches", results: topMatches },
106
- { id: "related", label: "Related", results: related },
123
+ { id: "top", label: searchResultsLabel, results: topMatches },
124
+ { id: "related", label: relatedResultsLabel, results: related },
107
125
  ]
108
126
  : [];
109
127
  // Skeleton only while nothing is in yet — once either section has rows, show
@@ -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
 
@@ -41,6 +41,9 @@ interface MailViewChromeProps {
41
41
  relatedResults?: SearchResult[];
42
42
  relatedLoading?: boolean;
43
43
  onSelectSearchResult?: (result: SearchResult) => void;
44
+ /** Section headings; see `MailListHeader`. */
45
+ searchResultsLabel?: string;
46
+ relatedResultsLabel?: string;
44
47
  }
45
48
 
46
49
  export function MailViewChrome({
@@ -60,6 +63,8 @@ export function MailViewChrome({
60
63
  relatedResults,
61
64
  relatedLoading,
62
65
  onSelectSearchResult,
66
+ searchResultsLabel,
67
+ relatedResultsLabel,
63
68
  }: MailViewChromeProps) {
64
69
  const [expanded, setExpanded] = useState(false);
65
70
 
@@ -88,6 +93,8 @@ export function MailViewChrome({
88
93
  relatedResults={relatedResults}
89
94
  relatedLoading={relatedLoading}
90
95
  onSelectSearchResult={onSelectSearchResult}
96
+ searchResultsLabel={searchResultsLabel}
97
+ relatedResultsLabel={relatedResultsLabel}
91
98
  >
92
99
  <FilterSheet {...filterConfig}>{children}</FilterSheet>
93
100
  </MailListHeader>
@@ -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,10 +947,14 @@ function MailboxList() {
949
947
  () => threads.map(threadToSearchResult),
950
948
  [threads],
951
949
  );
952
- // "Related" (semantic) is scoped to this mailbox and deduped against the
953
- // literal "Top matches" by thread, so a 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.
954
957
  const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
955
- mailboxId,
956
958
  filterCategory,
957
959
  });
958
960
  const relatedResults = useMemo(
@@ -967,7 +969,10 @@ function MailboxList() {
967
969
  (result: SearchResult) =>
968
970
  navigate({
969
971
  to: "/mail/$mailboxId",
970
- params: { 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.
975
+ params: { mailboxId: result.mailboxId ?? mailboxId },
971
976
  search: (prev: Record<string, unknown>) => ({
972
977
  ...prev,
973
978
  // Commit the active query alongside the selection. The debounced
@@ -1061,6 +1066,8 @@ function MailboxList() {
1061
1066
  relatedResults={relatedResults}
1062
1067
  relatedLoading={relatedLoading}
1063
1068
  onSelectSearchResult={handleSelectSearchResult}
1069
+ searchResultsLabel={`In ${listTitle}`}
1070
+ relatedResultsLabel="Everywhere"
1064
1071
  >
1065
1072
  {body}
1066
1073
  </MailViewChrome>
@@ -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({
@@ -23,8 +23,14 @@ export interface MailContextValue {
23
23
  mailboxNameIndex: ReadonlyMap<string, string>;
24
24
  accountNameIndex: ReadonlyMap<string, string>;
25
25
  searchQuery: string;
26
- /** Live (pre-debounce) search input — the toolbar's search field binds this. */
26
+ /** Live (pre-debounce) search input — the search field binds this. */
27
27
  searchInput: string;
28
+ /**
29
+ * Identity of the view the current search belongs to (`lib/mail-route.ts`).
30
+ * It changes when the user leaves that view, which is when search ends: the
31
+ * query re-seeds from the new URL and any search chrome collapses (#47).
32
+ */
33
+ searchViewKey: string;
28
34
  onSearchChange: (query: string) => void;
29
35
  /** Full clear (X button): drops the query and any selected thread (#538). */
30
36
  onSearchClear: () => void;
@@ -53,6 +59,7 @@ export const useMailContext = (): MailContextValue => {
53
59
  accountNameIndex: new Map(),
54
60
  searchQuery: "",
55
61
  searchInput: "",
62
+ searchViewKey: "",
56
63
  onSearchChange: () => {},
57
64
  onSearchClear: () => {},
58
65
  onSearchClearQuery: () => {},