@remit/web-client 0.0.16 → 0.0.18

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.16",
3
+ "version": "0.0.18",
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": {
@@ -1,28 +1,28 @@
1
1
  /**
2
2
  * FlaggedList — a FLAT, cross-account inbox of starred mail.
3
3
  *
4
- * Reads GET /threads with `starred=true`, which is served by the `byStarred`
5
- * index and returns every starred thread in the config across all non-muted
6
- * mailboxes, paged. Starredness is decided server-side from `hasStars`; the
7
- * client neither re-filters nor caps the set, so a starred thread outside the
8
- * newest inbox page still appears. Rendered as one continuous list (no category
4
+ * Reads the starred listing through `useStarredThreads` GET /threads with
5
+ * `starred=true`, served by the `byStarred` index which returns every starred
6
+ * thread in the config across all non-muted mailboxes, paged. `FlaggedPane`
7
+ * resolves the open thread from that same hook, so every row rendered here can
8
+ * be opened. Starredness is decided server-side from `hasStars`; the client
9
+ * neither re-filters nor caps the set, so a starred thread outside the newest
10
+ * inbox page still appears. Rendered as one continuous list (no category
9
11
  * sections). The shared `MailViewChrome` owns the `MailHeader` + filter
10
12
  * expando; the kit `MessageListPane` (flat, no `briefFilters`) owns the loading
11
13
  * / empty / error chrome and keyboard hints, with a consumer-supplied
12
14
  * `listBody` so the real rows render at every width.
13
15
  */
14
- import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
15
- import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
16
16
  import {
17
17
  ComfortableRow,
18
18
  flaggedFilterConfig,
19
19
  MessageListPane,
20
20
  type ThreadRowData,
21
21
  } from "@remit/ui";
22
- import { useInfiniteQuery } from "@tanstack/react-query";
23
22
  import { useCallback, useMemo, useState } from "react";
24
23
  import { formatErrorMessage } from "@/components/ui/ErrorState";
25
24
  import { useIsDesktop } from "@/hooks/useMediaQuery";
25
+ import { useStarredThreads } from "@/hooks/useStarredThreads";
26
26
  import {
27
27
  matchesBriefSearch,
28
28
  matchesSearchTokens,
@@ -72,7 +72,7 @@ export function FlaggedList({
72
72
  }, []);
73
73
 
74
74
  const {
75
- data: threadsData,
75
+ threads,
76
76
  isLoading,
77
77
  isError,
78
78
  error,
@@ -80,25 +80,7 @@ export function FlaggedList({
80
80
  fetchNextPage,
81
81
  hasNextPage,
82
82
  isFetchingNextPage,
83
- } = useInfiniteQuery({
84
- queryKey: unifiedThreadOperationsListAllThreadsQueryKey({
85
- query: { starred: true, order: "desc" },
86
- }),
87
- queryFn: async ({ pageParam }) => {
88
- const { data } = await unifiedThreadOperationsListAllThreads({
89
- query: {
90
- starred: true,
91
- order: "desc",
92
- continuationToken: pageParam,
93
- },
94
- throwOnError: true,
95
- });
96
- return data;
97
- },
98
- initialPageParam: undefined as string | undefined,
99
- getNextPageParam: (lastPage) => lastPage.continuationToken,
100
- staleTime: 60_000,
101
- });
83
+ } = useStarredThreads();
102
84
 
103
85
  const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
104
86
  searchQuery.trim().toLowerCase(),
@@ -109,9 +91,7 @@ export function FlaggedList({
109
91
  const predicates = Array.from(activeFilters)
110
92
  .map((id) => FILTER_PREDICATES[id])
111
93
  .filter((p): p is (t: ThreadRowData) => boolean => p != null);
112
- return dedupeByThread(
113
- (threadsData?.pages ?? []).flatMap((page) => page.items ?? []),
114
- )
94
+ return dedupeByThread(threads)
115
95
  .map(toThreadRowData)
116
96
  .filter(
117
97
  (t) =>
@@ -120,7 +100,7 @@ export function FlaggedList({
120
100
  (!sq || matchesBriefSearch(t, sq)) &&
121
101
  matchesSearchTokens(t, queryTokens),
122
102
  );
123
- }, [threadsData, selectedCategory, activeFilters, sq, queryTokens]);
103
+ }, [threads, selectedCategory, activeFilters, sq, queryTokens]);
124
104
 
125
105
  const preset = useMemo(() => flaggedFilterConfig(), []);
126
106
 
@@ -2,9 +2,14 @@
2
2
  * FlaggedPane — compound component for the Flagged virtual mailbox
3
3
  * (/mail/flagged route).
4
4
  *
5
- * Mirrors BriefPane: it reads the unified cross-account thread list to resolve
6
- * the open thread and owns the list / reading / phone slots. The list itself is
7
- * a FLAT inbox of starred mail (see `FlaggedList`), not the sectioned brief.
5
+ * Mirrors BriefPane in shape: it resolves the open thread and owns the list /
6
+ * reading / phone slots. The list itself is a FLAT inbox of starred mail (see
7
+ * `FlaggedList`), not the sectioned brief.
8
+ *
9
+ * The selection resolves from the starred listing, the same query that produced
10
+ * the rows. The unified listing is INBOX-scoped, so resolving against it left
11
+ * every starred thread filed elsewhere — Sent, an archive folder, anything past
12
+ * the inbox window — visible in the list but impossible to open (issue #70).
8
13
  *
9
14
  * <FlaggedPane selectedMessageId={...}>
10
15
  * <AppShellSlotted
@@ -15,10 +20,8 @@
15
20
  *
16
21
  * On phone, use `<FlaggedPane.Phone />` instead.
17
22
  */
18
- import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
19
23
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
20
24
  import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
21
- import { useQuery } from "@tanstack/react-query";
22
25
  import { useNavigate } from "@tanstack/react-router";
23
26
  import {
24
27
  createContext,
@@ -32,6 +35,7 @@ import { ConversationView } from "@/components/mail/ConversationView";
32
35
  import { FlaggedList } from "@/components/mail/FlaggedList";
33
36
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
34
37
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
38
+ import { useStarredThreads } from "@/hooks/useStarredThreads";
35
39
  import { useMailContext } from "@/lib/mail-context";
36
40
 
37
41
  /* ------------------------------------------------------------------ */
@@ -68,15 +72,12 @@ function FlaggedPaneProvider({
68
72
  }: FlaggedPaneProps) {
69
73
  const navigate = useNavigate();
70
74
 
71
- const { data: threadsData } = useQuery({
72
- ...unifiedThreadOperationsListAllThreadsOptions(),
73
- staleTime: 60_000,
74
- });
75
+ const { threads } = useStarredThreads();
75
76
 
76
77
  const selectedThread = useMemo(() => {
77
78
  if (!selectedMessageId) return undefined;
78
- return threadsData?.items.find((t) => t.messageId === selectedMessageId);
79
- }, [threadsData, selectedMessageId]);
79
+ return threads.find((t) => t.messageId === selectedMessageId);
80
+ }, [threads, selectedMessageId]);
80
81
 
81
82
  const handleSelectMessage = useCallback(
82
83
  (id: string) => {
@@ -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,7 +32,7 @@ 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";
35
37
  import { useMailContext } from "@/lib/mail-context";
36
38
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
@@ -56,6 +58,13 @@ interface MailListHeaderProps {
56
58
  relatedResults?: SearchResult[];
57
59
  relatedLoading?: boolean;
58
60
  onSelectSearchResult?: (result: SearchResult) => void;
61
+ /**
62
+ * Section headings. A view whose two engines cover different ground names
63
+ * them by that ground ("In Archive" / "Everywhere") so the reach of a result
64
+ * is on screen rather than inferred.
65
+ */
66
+ searchResultsLabel?: string;
67
+ relatedResultsLabel?: string;
59
68
  }
60
69
 
61
70
  export function MailListHeader({
@@ -69,11 +78,14 @@ export function MailListHeader({
69
78
  relatedResults,
70
79
  relatedLoading,
71
80
  onSelectSearchResult,
81
+ searchResultsLabel = "Top matches",
82
+ relatedResultsLabel = "Related",
72
83
  }: MailListHeaderProps) {
73
84
  const {
74
85
  searchInput,
75
86
  onSearchChange,
76
87
  onSearchClear,
88
+ searchViewKey,
77
89
  mailboxNameIndex,
78
90
  accountNameIndex,
79
91
  } = useMailContext();
@@ -82,6 +94,16 @@ export function MailListHeader({
82
94
  const [searchOpen, setSearchOpen] = useState(false);
83
95
  const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
84
96
 
97
+ // Leaving the view ends the search: the shell drops the query, and the chrome
98
+ // it opened — the phone takeover, the expanded tablet field — closes with it
99
+ // rather than sitting there empty over the new mailbox (#47).
100
+ const searchViewRef = useRef(searchViewKey);
101
+ useEffect(() => {
102
+ if (searchViewRef.current === searchViewKey) return;
103
+ searchViewRef.current = searchViewKey;
104
+ setSearchOpen(false);
105
+ }, [searchViewKey]);
106
+
85
107
  const hasQuery = searchInput.trim().length > 0;
86
108
  // Filter tokens (`from:`, `has:attachment`, `in:`, `account:`, …) parsed live
87
109
  // from the typed query render as removable chips above the sections;
@@ -102,8 +124,8 @@ export function MailListHeader({
102
124
  // state. The empty-query case (recent searches) is the kit's job.
103
125
  const sections: SearchResultSection[] = hasQuery
104
126
  ? [
105
- { id: "top", label: "Top matches", results: topMatches },
106
- { id: "related", label: "Related", results: related },
127
+ { id: "top", label: searchResultsLabel, results: topMatches },
128
+ { id: "related", label: relatedResultsLabel, results: related },
107
129
  ]
108
130
  : [];
109
131
  // Skeleton only while nothing is in yet — once either section has rows, show
@@ -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>
@@ -949,10 +949,13 @@ function MailboxList() {
949
949
  () => threads.map(threadToSearchResult),
950
950
  [threads],
951
951
  );
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.
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.
954
958
  const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
955
- mailboxId,
956
959
  filterCategory,
957
960
  });
958
961
  const relatedResults = useMemo(
@@ -967,7 +970,10 @@ function MailboxList() {
967
970
  (result: SearchResult) =>
968
971
  navigate({
969
972
  to: "/mail/$mailboxId",
970
- params: { mailboxId },
973
+ // A hit from the unscoped "Everywhere" section lives in another
974
+ // mailbox; open it there, so the list, the toolbar's verbs and the
975
+ // message all belong to the same mailbox.
976
+ params: { mailboxId: result.mailboxId ?? mailboxId },
971
977
  search: (prev: Record<string, unknown>) => ({
972
978
  ...prev,
973
979
  // Commit the active query alongside the selection. The debounced
@@ -1061,6 +1067,8 @@ function MailboxList() {
1061
1067
  relatedResults={relatedResults}
1062
1068
  relatedLoading={relatedLoading}
1063
1069
  onSelectSearchResult={handleSelectSearchResult}
1070
+ searchResultsLabel={`In ${listTitle}`}
1071
+ relatedResultsLabel="Everywhere"
1064
1072
  >
1065
1073
  {body}
1066
1074
  </MailViewChrome>
@@ -0,0 +1,143 @@
1
+ /**
2
+ * useStarredThreads — regression cover for issue #70.
3
+ *
4
+ * The Starred pane rendered its rows from `GET /threads?starred=true` but
5
+ * resolved the open thread from the unfiltered (INBOX-scoped) listing. Rows for
6
+ * starred mail filed outside INBOX therefore had nothing to resolve to and
7
+ * opened no reading pane.
8
+ *
9
+ * Strategy follows useRescueCandidates.test.ts: pre-seed a QueryClient under the
10
+ * key the hook generates and render it synchronously, so the queryFn never
11
+ * fires. The last two cases are the ones that fail if the wrong listing comes
12
+ * back — the unfiltered key is seeded alongside and must not be read.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { describe, test } from "node:test";
17
+ import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
18
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
19
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
20
+ import { createElement } from "react";
21
+ import { renderToString } from "react-dom/server";
22
+ import {
23
+ starredThreadsQueryKey,
24
+ useStarredThreads,
25
+ } from "./useStarredThreads.js";
26
+
27
+ interface Page {
28
+ items: RemitImapThreadMessageResponse[];
29
+ continuationToken?: string;
30
+ }
31
+
32
+ function makeThread(
33
+ messageId: string,
34
+ overrides: Partial<RemitImapThreadMessageResponse> = {},
35
+ ): RemitImapThreadMessageResponse {
36
+ return {
37
+ messageId,
38
+ threadMessageId: `tm-${messageId}`,
39
+ threadId: `t-${messageId}`,
40
+ accountConfigId: "acc",
41
+ mailboxId: "mb-inbox",
42
+ sentDate: 1_000_000,
43
+ isRead: false,
44
+ hasAttachment: false,
45
+ hasStars: true,
46
+ isDeleted: false,
47
+ createdAt: 1_000_000,
48
+ updatedAt: 1_000_000,
49
+ fromName: "Anna de Vries",
50
+ fromEmail: "anna@example.nl",
51
+ subject: `subject ${messageId}`,
52
+ snippet: "…",
53
+ senderTrust: "vip",
54
+ ...overrides,
55
+ } as RemitImapThreadMessageResponse;
56
+ }
57
+
58
+ /**
59
+ * Renders the hook against a cache holding `starredPages` under the starred key
60
+ * and `unfilteredItems` under the unfiltered one, so a hook reading the wrong
61
+ * listing returns the wrong threads instead of returning nothing.
62
+ */
63
+ function renderHook(
64
+ starredPages: Page[],
65
+ unfilteredItems: RemitImapThreadMessageResponse[] = [],
66
+ ): RemitImapThreadMessageResponse[] {
67
+ const client = new QueryClient({
68
+ defaultOptions: { queries: { retry: false } },
69
+ });
70
+ client.setQueryData(starredThreadsQueryKey(), {
71
+ pages: starredPages,
72
+ pageParams: starredPages.map((_, index) =>
73
+ index === 0 ? undefined : `page-${index}`,
74
+ ),
75
+ });
76
+ client.setQueryData(unifiedThreadOperationsListAllThreadsQueryKey(), {
77
+ items: unfilteredItems,
78
+ });
79
+
80
+ let captured: RemitImapThreadMessageResponse[] = [];
81
+
82
+ function Capture() {
83
+ captured = useStarredThreads().threads;
84
+ return null;
85
+ }
86
+
87
+ renderToString(
88
+ createElement(
89
+ QueryClientProvider,
90
+ { client },
91
+ createElement(Capture),
92
+ ) as never,
93
+ );
94
+
95
+ return captured;
96
+ }
97
+
98
+ describe("starredThreadsQueryKey", () => {
99
+ test("asks for the starred listing, newest first", () => {
100
+ const [params] = starredThreadsQueryKey() as [
101
+ { query?: { starred?: boolean; order?: string } },
102
+ ];
103
+ assert.equal(params.query?.starred, true);
104
+ assert.equal(params.query?.order, "desc");
105
+ });
106
+ });
107
+
108
+ describe("useStarredThreads", () => {
109
+ test("returns no threads when the listing is empty", () => {
110
+ assert.deepEqual(renderHook([{ items: [] }]), []);
111
+ });
112
+
113
+ test("flattens every loaded page, in page order", () => {
114
+ const threads = renderHook([
115
+ { items: [makeThread("m1"), makeThread("m2")], continuationToken: "p2" },
116
+ { items: [makeThread("m3")] },
117
+ ]);
118
+ assert.deepEqual(
119
+ threads.map((thread) => thread.messageId),
120
+ ["m1", "m2", "m3"],
121
+ );
122
+ });
123
+
124
+ test("returns a starred thread that lives outside INBOX", () => {
125
+ // The case issue #70 could not open: starred, filed in Sent, and absent
126
+ // from the unfiltered listing the pane used to resolve against.
127
+ const sent = makeThread("m-sent", { mailboxId: "mb-sent" });
128
+ const threads = renderHook([{ items: [sent] }], [makeThread("m-inbox")]);
129
+ assert.deepEqual(
130
+ threads.map((thread) => thread.messageId),
131
+ ["m-sent"],
132
+ );
133
+ assert.equal(
134
+ threads.find((thread) => thread.messageId === "m-sent")?.mailboxId,
135
+ "mb-sent",
136
+ );
137
+ });
138
+
139
+ test("does not read the unfiltered INBOX listing", () => {
140
+ const threads = renderHook([{ items: [] }], [makeThread("m-inbox")]);
141
+ assert.deepEqual(threads, []);
142
+ });
143
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The starred listing — `GET /threads?starred=true`, served by the `byStarred`
3
+ * index — as one hook, so the Starred pane's list and its selection resolve
4
+ * from the same query.
5
+ *
6
+ * The two are the same cache entry, not two requests: they share a query key,
7
+ * so `threads` here is exactly the set of rows the list rendered. Resolving a
8
+ * selection from any other listing reintroduces issue #70 — the INBOX-scoped
9
+ * unified listing cannot see a starred thread filed elsewhere, so its row is
10
+ * clickable but resolves to nothing and no reading pane opens.
11
+ */
12
+ import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
13
+ import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
14
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
15
+ import { useInfiniteQuery } from "@tanstack/react-query";
16
+ import { useMemo } from "react";
17
+
18
+ export const starredThreadsQueryKey = () =>
19
+ unifiedThreadOperationsListAllThreadsQueryKey({
20
+ query: { starred: true, order: "desc" },
21
+ });
22
+
23
+ interface StarredThreads {
24
+ /** Every starred thread across the pages loaded so far, newest first. */
25
+ threads: RemitImapThreadMessageResponse[];
26
+ isLoading: boolean;
27
+ isError: boolean;
28
+ error: unknown;
29
+ refetch: () => void;
30
+ fetchNextPage: () => void;
31
+ hasNextPage: boolean;
32
+ isFetchingNextPage: boolean;
33
+ }
34
+
35
+ export function useStarredThreads(): StarredThreads {
36
+ const {
37
+ data,
38
+ isLoading,
39
+ isError,
40
+ error,
41
+ refetch,
42
+ fetchNextPage,
43
+ hasNextPage,
44
+ isFetchingNextPage,
45
+ } = useInfiniteQuery({
46
+ queryKey: starredThreadsQueryKey(),
47
+ queryFn: async ({ pageParam }) => {
48
+ const { data: page } = await unifiedThreadOperationsListAllThreads({
49
+ query: {
50
+ starred: true,
51
+ order: "desc",
52
+ continuationToken: pageParam,
53
+ },
54
+ throwOnError: true,
55
+ });
56
+ return page;
57
+ },
58
+ initialPageParam: undefined as string | undefined,
59
+ getNextPageParam: (lastPage) => lastPage.continuationToken,
60
+ staleTime: 60_000,
61
+ });
62
+
63
+ const threads = useMemo(
64
+ () => (data?.pages ?? []).flatMap((page) => page.items ?? []),
65
+ [data],
66
+ );
67
+
68
+ return {
69
+ threads,
70
+ isLoading,
71
+ isError,
72
+ error,
73
+ refetch,
74
+ fetchNextPage,
75
+ hasNextPage,
76
+ isFetchingNextPage,
77
+ };
78
+ }
@@ -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: () => {},
@@ -15,6 +15,7 @@
15
15
  /** A matched route, minimal shape needed for pane detection. */
16
16
  export interface MailRouteMatch {
17
17
  routeId: string;
18
+ params?: Record<string, string | undefined>;
18
19
  }
19
20
 
20
21
  /** The leaf route ids the /mail shell branches on. */
@@ -42,3 +43,19 @@ export function isOutboxRoute(matches: readonly MailRouteMatch[]): boolean {
42
43
  export function isMailboxRoute(matches: readonly MailRouteMatch[]): boolean {
43
44
  return matches.some((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID);
44
45
  }
46
+
47
+ /**
48
+ * Identity of the list view the shell is showing — one mailbox, the brief, the
49
+ * flagged list, or the outbox. Two locations share a key when they differ only
50
+ * in search params (opening a result, mirroring `q`), so opening a hit is not a
51
+ * view change while switching mailbox is. `lib/search-view.ts` re-seeds the
52
+ * search field whenever this changes.
53
+ */
54
+ export function mailViewKey(matches: readonly MailRouteMatch[]): string {
55
+ const mailboxId = matches.find((m) => m.params?.mailboxId)?.params?.mailboxId;
56
+ if (mailboxId) return `${MAIL_MAILBOX_ROUTE_ID}:${mailboxId}`;
57
+ if (isFlaggedRoute(matches)) return MAIL_FLAGGED_ROUTE_ID;
58
+ if (isOutboxRoute(matches)) return MAIL_OUTBOX_ROUTE_ID;
59
+ if (isBriefRoute(matches)) return MAIL_BRIEF_ROUTE_ID;
60
+ return "";
61
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Regression (#47): the search query must not survive a move to another
3
+ * mailbox. The /mail shell holds the field state and never unmounts between
4
+ * child routes, so without these rules the text — and the query it drives —
5
+ * followed the user from inbox to inbox.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { type MailRouteMatch, mailViewKey } from "./mail-route.js";
10
+ import {
11
+ committedSearchQuery,
12
+ searchInputForView,
13
+ shouldMirrorQuery,
14
+ } from "./search-view.js";
15
+
16
+ const matches = (routeId: string, mailboxId?: string): MailRouteMatch[] => [
17
+ { routeId: "__root__" },
18
+ { routeId: "/mail" },
19
+ { routeId, ...(mailboxId ? { params: { mailboxId } } : {}) },
20
+ ];
21
+
22
+ describe("mailViewKey", () => {
23
+ it("distinguishes two mailboxes", () => {
24
+ assert.notEqual(
25
+ mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
26
+ mailViewKey(matches("/mail/$mailboxId", "archive-1")),
27
+ );
28
+ });
29
+
30
+ it("gives the same mailbox one key regardless of search params", () => {
31
+ assert.equal(
32
+ mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
33
+ mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
34
+ );
35
+ });
36
+
37
+ it("separates the brief, flagged and outbox views", () => {
38
+ const keys = [
39
+ mailViewKey(matches("/mail/")),
40
+ mailViewKey(matches("/mail/flagged")),
41
+ mailViewKey(matches("/mail/outbox")),
42
+ ];
43
+ assert.equal(new Set(keys).size, 3);
44
+ });
45
+ });
46
+
47
+ describe("searchInputForView", () => {
48
+ it("clears the field when the destination carries no query", () => {
49
+ assert.equal(
50
+ searchInputForView(
51
+ "/mail/$mailboxId:inbox-1",
52
+ "/mail/$mailboxId:arch",
53
+ "",
54
+ ),
55
+ "",
56
+ );
57
+ });
58
+
59
+ it("leaves the field alone while the view stays the same", () => {
60
+ // Opening a result and the q-mirror both re-render on the same view; the
61
+ // user's in-flight typing must survive both.
62
+ assert.equal(
63
+ searchInputForView(
64
+ "/mail/$mailboxId:inbox-1",
65
+ "/mail/$mailboxId:inbox-1",
66
+ "",
67
+ ),
68
+ undefined,
69
+ );
70
+ });
71
+
72
+ it("seeds from the destination's own query (deep link, saved search)", () => {
73
+ assert.equal(
74
+ searchInputForView("/mail/$mailboxId:inbox-1", "/mail/", "invoice"),
75
+ "invoice",
76
+ );
77
+ });
78
+ });
79
+
80
+ describe("committedSearchQuery", () => {
81
+ it("commits an empty field immediately, without waiting on the debounce", () => {
82
+ assert.equal(committedSearchQuery("", "stale query"), "");
83
+ });
84
+
85
+ it("debounces everything else", () => {
86
+ assert.equal(committedSearchQuery("inv", ""), "");
87
+ assert.equal(committedSearchQuery("invoice", "inv"), "inv");
88
+ });
89
+ });
90
+
91
+ describe("shouldMirrorQuery", () => {
92
+ it("writes a settled query the URL does not have yet", () => {
93
+ assert.equal(shouldMirrorQuery("invoice", "invoice", ""), true);
94
+ });
95
+
96
+ it("stays quiet once the URL already says it", () => {
97
+ assert.equal(shouldMirrorQuery("invoice", "invoice", "invoice"), false);
98
+ });
99
+
100
+ it("does not strip the query a deep link just arrived with", () => {
101
+ // The field is seeded from the URL and the debounce has not caught up, so
102
+ // the committed query is still the previous view's. Writing it would drop
103
+ // `q` for the length of the debounce — and with it the search the link
104
+ // asked for.
105
+ assert.equal(shouldMirrorQuery("invoice", "", "invoice"), false);
106
+ });
107
+
108
+ it("waits for the debounce while the user is still typing", () => {
109
+ assert.equal(shouldMirrorQuery("invoi", "inv", ""), false);
110
+ });
111
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Search is a view-level mode, not a persistent global filter (#47).
3
+ *
4
+ * The query belongs to the location it was typed in: the URL's `q` is what a
5
+ * view is searching for, and the nav links already drop `q` when they switch
6
+ * mailbox. The search field, however, is local state in the /mail shell — which
7
+ * outlives every child route — so it kept the old text and went on querying the
8
+ * new mailbox with it (the confusing Mac Mail behaviour). These rules make the
9
+ * URL win on every view change: the field re-seeds from the location it lands
10
+ * on, so switching mailbox clears a stale query while a deep link or a saved
11
+ * search that carries `q` still arrives with the query intact.
12
+ */
13
+
14
+ /**
15
+ * The field text after a view transition, or `undefined` when nothing changes
16
+ * (same view — typing, opening a result, mirroring `q` back to the URL).
17
+ */
18
+ export function searchInputForView(
19
+ previousViewKey: string,
20
+ viewKey: string,
21
+ urlQuery: string,
22
+ ): string | undefined {
23
+ if (previousViewKey === viewKey) return undefined;
24
+ return urlQuery;
25
+ }
26
+
27
+ /**
28
+ * The committed query the search APIs run. Typing debounces, but an empty field
29
+ * commits immediately — otherwise a view change fires one more request for the
30
+ * query the user just left behind.
31
+ */
32
+ export function committedSearchQuery(
33
+ searchInput: string,
34
+ debouncedSearchInput: string,
35
+ ): string {
36
+ return searchInput === "" ? "" : debouncedSearchInput;
37
+ }
38
+
39
+ /**
40
+ * Whether the committed query may be written back to the URL. Only a settled
41
+ * one may: mid-debounce the committed value is the *previous* query, and
42
+ * writing it would overwrite the URL that a deep link or a saved search just
43
+ * arrived with — stripping `q` for as long as the debounce lasts, and taking
44
+ * the search with it. The mirror waits for the field and the committed query to
45
+ * agree, then writes only if the URL says something else.
46
+ */
47
+ export function shouldMirrorQuery(
48
+ searchInput: string,
49
+ committedQuery: string,
50
+ urlQuery: string,
51
+ ): boolean {
52
+ if (committedQuery !== searchInput) return false;
53
+ return committedQuery !== urlQuery;
54
+ }
@@ -30,8 +30,18 @@ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
30
30
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
31
31
  import { writeIntelligencePref } from "@/lib/intelligence-pref";
32
32
  import { MailContext } from "@/lib/mail-context";
33
- import { isBriefRoute, isFlaggedRoute, isOutboxRoute } from "@/lib/mail-route";
33
+ import {
34
+ isBriefRoute,
35
+ isFlaggedRoute,
36
+ isOutboxRoute,
37
+ mailViewKey,
38
+ } from "@/lib/mail-route";
34
39
  import { buildAccountNameIndex } from "@/lib/search-token-index";
40
+ import {
41
+ committedSearchQuery,
42
+ searchInputForView,
43
+ shouldMirrorQuery,
44
+ } from "@/lib/search-view";
35
45
  import "@/lib/client";
36
46
 
37
47
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -86,19 +96,25 @@ function MailLayout() {
86
96
  writeIntelligencePref(open);
87
97
  }, []);
88
98
 
89
- // URL `q` is a load-once seed for the input and a one-directional write
90
- // target. The debounced local value is the source of truth and drives the
91
- // search API; it is mirrored back to the URL but the URL is never read into
92
- // state after mount.
99
+ // Within one view, URL `q` seeds the input and is a one-directional write
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).
93
103
  const [searchInput, setSearchInput] = useState(searchQuery);
94
104
  const debouncedSearchInput = useDebouncedValue(searchInput, 200);
105
+ const committedQuery = committedSearchQuery(
106
+ searchInput,
107
+ debouncedSearchInput,
108
+ );
95
109
 
96
110
  const searchQueryRef = useRef(searchQuery);
97
111
  searchQueryRef.current = searchQuery;
98
112
 
99
- // Mirror the debounced search into the URL so links are shareable and a
100
- // refresh restores the query. One-directional: the URL is never read back
101
- // into state, so there is no sync loop.
113
+ // Mirror the settled search into the URL so links are shareable and a
114
+ // refresh restores the query. Within a view this is one-directional the
115
+ // URL is not read back into state, so there is no sync loop — and it writes
116
+ // only once the debounce agrees with the field, so a query arriving by URL
117
+ // is never overwritten mid-debounce (`shouldMirrorQuery`).
102
118
  // When a query *goes* active, also strip selectedMessageId so the reading
103
119
  // pane closes (#539): an open message from the pre-search list is not
104
120
  // meaningful in the search result set. Only strip on that transition though —
@@ -108,16 +124,17 @@ function MailLayout() {
108
124
  // the row shows before the debounce settles, so this mirror can land just
109
125
  // after the open and close it again.
110
126
  useEffect(() => {
111
- if (debouncedSearchInput === searchQueryRef.current) return;
127
+ if (!shouldMirrorQuery(searchInput, committedQuery, searchQueryRef.current))
128
+ return;
112
129
  navigate({
113
130
  to: ".",
114
131
  search: (prev) => {
115
132
  const queryAlreadyActive =
116
- (prev as { q?: string }).q === debouncedSearchInput;
133
+ (prev as { q?: string }).q === committedQuery;
117
134
  return {
118
135
  ...prev,
119
- q: debouncedSearchInput || undefined,
120
- ...(debouncedSearchInput && !queryAlreadyActive
136
+ q: committedQuery || undefined,
137
+ ...(committedQuery && !queryAlreadyActive
121
138
  ? {
122
139
  selectedMessageId: undefined,
123
140
  selectedThreadId: undefined,
@@ -128,7 +145,25 @@ function MailLayout() {
128
145
  },
129
146
  replace: true,
130
147
  });
131
- }, [debouncedSearchInput, navigate]);
148
+ }, [searchInput, committedQuery, navigate]);
149
+
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]);
132
167
 
133
168
  const {
134
169
  data: config,
@@ -250,10 +285,11 @@ function MailLayout() {
250
285
  accounts,
251
286
  mailboxNameIndex,
252
287
  accountNameIndex,
253
- // Debounced local value is the source of truth for search; it is
254
- // mirrored to the URL one-directionally for shareable links.
255
- searchQuery: debouncedSearchInput,
288
+ // The committed local value is the source of truth for search; it is
289
+ // mirrored to the URL for shareable links.
290
+ searchQuery: committedQuery,
256
291
  searchInput,
292
+ searchViewKey: viewKey,
257
293
  onSearchChange: handleSearchChange,
258
294
  onSearchClear: handleSearchClear,
259
295
  onSearchClearQuery: handleSearchClearQuery,