@remit/web-client 0.0.146 → 0.0.148

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.146",
3
+ "version": "0.0.148",
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": {
@@ -21,7 +21,12 @@ export const InlineCompose = ({
21
21
  // Capped against the viewport as well as in pixels: on a short one — a phone
22
22
  // in landscape, or one with the keyboard up — a flat 400px is taller than
23
23
  // the pane, and the surface's own header scrolls off before its verbs do.
24
- <div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col">
24
+ //
25
+ // A desktop pane has room a phone does not, so the surface takes a height
26
+ // there rather than a ceiling. Left to size itself, the recipient rows and
27
+ // the verbs come first and the writing area gets its 120px floor — a couple
28
+ // of lines to answer a message in.
29
+ <div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col lg:h-[min(560px,55dvh)] lg:max-h-none">
25
30
  <ComposeForm
26
31
  mode={mode}
27
32
  account={account}
@@ -2,6 +2,7 @@ import { useLocation } from "@tanstack/react-router";
2
2
  import { Pencil } from "lucide-react";
3
3
  import { useCallback } from "react";
4
4
  import { useCompose } from "@/components/compose/ComposeProvider";
5
+ import { locationOpensDetail } from "@/lib/mail-route";
5
6
 
6
7
  /**
7
8
  * Floating Action Button for composing a new message. Mobile-only.
@@ -12,8 +13,9 @@ import { useCompose } from "@/components/compose/ComposeProvider";
12
13
  * `/mail` shell also stops mounting the FAB above that width; the
13
14
  * `lg:hidden` class covers the pre-hydration frame.
14
15
  * - The compose surface is already open.
15
- * - The user is reading a thread (`?selectedMessageId=…`) — the single
16
- * pane is the conversation, and its reply bar is under this corner.
16
+ * - The user is reading a thread — the single pane is the conversation, and
17
+ * its reply bar is under this corner. The brief says so in its path; the
18
+ * lists still to move say so in `?selectedMessageId=…`.
17
19
  * - The user is off `/mail`, which is every route with no mail in it.
18
20
  */
19
21
  export const ComposeFab = () => {
@@ -24,7 +26,9 @@ export const ComposeFab = () => {
24
26
  }, [openCompose]);
25
27
 
26
28
  const search = location.search as Record<string, unknown> | undefined;
27
- const isReadingThread = Boolean(search?.selectedMessageId);
29
+ const isReadingThread =
30
+ Boolean(search?.selectedMessageId) ||
31
+ locationOpensDetail(location.pathname);
28
32
 
29
33
  if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
30
34
  return null;
@@ -1,12 +1,12 @@
1
1
  /**
2
- * BriefPane — compound component for the daily-brief view (/mail route).
2
+ * BriefPane — compound component for the daily brief.
3
3
  *
4
- * Usage in mail.tsx:
4
+ * Usage in the list layout route:
5
5
  *
6
- * <BriefPane selectedMessageId={...}>
7
- * <AppShellSlotted
6
+ * <BriefPane thread={useBriefThreadPath()}>
7
+ * <MailShell
8
8
  * list={<BriefPane.List />}
9
- * reading={<BriefPane.Reading />}
9
+ * reading={<Outlet />}
10
10
  * />
11
11
  * </BriefPane>
12
12
  *
@@ -14,13 +14,9 @@
14
14
  */
15
15
  import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
16
16
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
17
- import {
18
- ReadingPaneEmpty,
19
- type SearchResult,
20
- useAppShellLayout,
21
- } from "@remit/ui";
17
+ import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
22
18
  import { useQuery } from "@tanstack/react-query";
23
- import { useNavigate, useSearch } from "@tanstack/react-router";
19
+ import { useNavigate } from "@tanstack/react-router";
24
20
  import {
25
21
  createContext,
26
22
  type ReactNode,
@@ -37,32 +33,34 @@ import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction
37
33
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
38
34
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
39
35
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
36
+ import { useThreadRow } from "@/hooks/useThreadRow";
40
37
  import {
41
38
  type TriageContext,
42
39
  useTriageContext,
43
40
  useTriageLayer,
44
41
  } from "@/hooks/useTriageLayer";
45
- import {
46
- buildConversationTarget,
47
- type ConversationTarget,
48
- } from "@/lib/conversation-target";
42
+ import type { ConversationTarget } from "@/lib/conversation-target";
49
43
  import { useMailContext } from "@/lib/mail-context";
44
+ import type { BriefThreadPath, BriefThreadTarget } from "@/routing";
50
45
 
51
46
  /* ------------------------------------------------------------------ */
52
47
  /* Context */
53
48
  /* ------------------------------------------------------------------ */
54
49
 
55
50
  interface BriefPaneContextValue {
51
+ /** The row the reader pointed at, which is the one the list highlights. */
56
52
  selectedMessageId: string | undefined;
57
53
  selectedThread: RemitImapThreadMessageResponse | undefined;
58
- /** The conversation to open — the loaded thread, or a tapped "Related" hit. */
54
+ /** The conversation the pane shows, or none when the address names no thread. */
59
55
  conversation: ConversationTarget | undefined;
60
- onSelectMessage: (id: string, options?: OpenMessageOptions) => void;
61
- onSelectSearchResult: (
62
- result: SearchResult,
56
+ onOpenThread: (
57
+ target: BriefThreadTarget,
63
58
  options?: OpenMessageOptions,
64
59
  ) => void;
65
60
  onCloseThread: () => void;
61
+ /** The rows either side of the open one — the phone's swipe gestures. */
62
+ nextThread: BriefThreadTarget | undefined;
63
+ previousThread: BriefThreadTarget | undefined;
66
64
  /**
67
65
  * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
68
66
  * account — the brief spans accounts, so there is no route mailbox to key by.
@@ -71,8 +69,6 @@ interface BriefPaneContextValue {
71
69
  /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
72
70
  triage: TriageContext;
73
71
  onDeleteMessages: (messageIds: string[]) => void;
74
- nextMessageId: string | undefined;
75
- previousMessageId: string | undefined;
76
72
  /**
77
73
  * Deselects the open message when it's the one a mutation just removed
78
74
  * from view — wired into every mutation that can take the open message out
@@ -95,93 +91,79 @@ function useBriefPane(): BriefPaneContextValue {
95
91
  /* ------------------------------------------------------------------ */
96
92
 
97
93
  interface BriefPaneProps {
98
- selectedMessageId: string | undefined;
94
+ /** The open conversation, as the address states it. */
95
+ thread: BriefThreadPath | undefined;
99
96
  children: ReactNode;
100
97
  }
101
98
 
102
- function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
99
+ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
103
100
  const navigate = useNavigate();
104
101
  const { searchInput } = useMailContext();
105
- const { selectedThreadId, selectedMailboxId } = useSearch({
106
- strict: false,
107
- }) as { selectedThreadId?: string; selectedMailboxId?: string };
102
+ const threadId = thread?.threadId;
103
+ const pointedAtMessageId = thread?.messageId;
108
104
 
109
105
  const { data: threadsData } = useQuery({
110
106
  ...unifiedThreadOperationsListAllThreadsOptions(),
111
107
  staleTime: 60_000,
112
108
  });
109
+ const briefThreads = useMemo(() => threadsData?.items ?? [], [threadsData]);
113
110
 
114
- const selectedThread = useMemo(() => {
115
- if (!selectedMessageId) return undefined;
116
- return threadsData?.items.find((t) => t.messageId === selectedMessageId);
117
- }, [threadsData, selectedMessageId]);
118
-
119
- // A literal hit resolves to a loaded thread; a semantic "Related" hit may not
120
- // be in the capped brief list, so fall back to the thread + mailbox the hit
121
- // carried through the URL.
122
- const conversation = useMemo(
123
- () =>
124
- buildConversationTarget(selectedThread, {
125
- messageId: selectedMessageId,
126
- threadId: selectedThreadId,
127
- mailboxId: selectedMailboxId,
128
- }),
129
- [selectedThread, selectedMessageId, selectedThreadId, selectedMailboxId],
130
- );
131
-
132
- const handleSelectMessage = useCallback(
133
- (id: string, options?: OpenMessageOptions) => {
134
- navigate({
135
- to: "/mail/brief",
136
- search: (prev) => ({
137
- ...prev,
138
- selectedMessageId: id,
139
- selectedThreadId: undefined,
140
- selectedMailboxId: undefined,
141
- }),
142
- replace: options?.replace,
143
- });
144
- },
145
- [navigate],
146
- );
147
-
148
- const handleSelectSearchResult = useCallback(
149
- (result: SearchResult, options?: OpenMessageOptions) => {
111
+ // The row the brief itself lists, preferred because a mutation patches it in
112
+ // place. A thread reached from a cross-folder search hit or a cold address is
113
+ // in no listing here, and answers for itself — which is where
114
+ // `selectedMailboxId` used to come in: the folder a thread is filed in is the
115
+ // thread's own data, so the brief spanning folders costs the URL nothing.
116
+ const listedThread = useMemo(() => {
117
+ if (!threadId) return undefined;
118
+ return (
119
+ briefThreads.find((t) => t.messageId === pointedAtMessageId) ??
120
+ briefThreads.find((t) => t.threadId === threadId)
121
+ );
122
+ }, [briefThreads, threadId, pointedAtMessageId]);
123
+ const ownRow = useThreadRow(threadId, pointedAtMessageId);
124
+ const selectedThread = listedThread ?? ownRow;
125
+
126
+ const selectedMessageId = pointedAtMessageId ?? selectedThread?.messageId;
127
+
128
+ const conversation = useMemo<ConversationTarget | undefined>(() => {
129
+ if (!threadId) return undefined;
130
+ return {
131
+ threadId,
132
+ mailboxId: selectedThread?.mailboxId ?? "",
133
+ subject: selectedThread?.subject,
134
+ messageId: selectedMessageId,
135
+ authenticity: selectedThread?.authenticity,
136
+ };
137
+ }, [threadId, selectedThread, selectedMessageId]);
138
+
139
+ const handleOpenThread = useCallback(
140
+ (target: BriefThreadTarget, options?: OpenMessageOptions) => {
150
141
  navigate({
151
- to: "/mail/brief",
142
+ to: "/mail/brief/$threadId/$messageId",
143
+ params: target,
152
144
  replace: options?.replace,
153
- search: (prev) => ({
154
- ...prev,
155
- // Commit the active query with the selection so the debounced
156
- // q-mirror (`useSearchMirror`) which strips the selection when the query
157
- // goes active is already satisfied and leaves the opened result
158
- // alone. Use the *live* `searchInput`: the row can be tapped before
159
- // the debounce settles, when the committed query is still empty.
160
- q: searchInput || undefined,
161
- selectedMessageId: result.id,
162
- selectedThreadId: result.threadId,
163
- selectedMailboxId: result.mailboxId,
164
- }),
145
+ // Commit the active query with the open so the debounced q-mirror —
146
+ // which walks back up to the list when the query goes active — is
147
+ // already satisfied and leaves the conversation alone. The *live*
148
+ // `searchInput`: a row can be tapped before the debounce settles, when
149
+ // the committed query is still empty.
150
+ search: (prev) => ({ ...prev, q: searchInput || undefined }),
165
151
  });
166
152
  },
167
153
  [navigate, searchInput],
168
154
  );
169
155
 
156
+ const handleCloseThread = useCallback(() => {
157
+ navigate({ to: "/mail/brief", search: (prev) => prev });
158
+ }, [navigate]);
159
+
170
160
  const handleDeselectIfRemoved = useCallback(
171
161
  (removedIds: string[]) => {
172
162
  if (!selectedMessageId) return;
173
163
  if (!removedIds.includes(selectedMessageId)) return;
174
- navigate({
175
- to: "/mail/brief",
176
- search: (prev) => ({
177
- ...prev,
178
- selectedMessageId: undefined,
179
- selectedThreadId: undefined,
180
- selectedMailboxId: undefined,
181
- }),
182
- });
164
+ handleCloseThread();
183
165
  },
184
- [selectedMessageId, navigate],
166
+ [selectedMessageId, handleCloseThread],
185
167
  );
186
168
 
187
169
  const actions = useThreadActions({
@@ -189,24 +171,11 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
189
171
  onAfterOptimisticRemove: handleDeselectIfRemoved,
190
172
  });
191
173
 
192
- const handleCloseThread = useCallback(() => {
193
- navigate({
194
- to: "/mail/brief",
195
- search: (prev) => ({
196
- ...prev,
197
- selectedMessageId: undefined,
198
- selectedThreadId: undefined,
199
- selectedMailboxId: undefined,
200
- }),
201
- });
202
- }, [navigate]);
203
-
204
174
  const triage = useTriageContext();
205
175
 
206
176
  // A brief selection spans accounts and mailboxes, so the listings these
207
177
  // patch are resolved from each message's own mailbox — the open thread's is
208
178
  // only the fallback.
209
- const briefThreads = useMemo(() => threadsData?.items ?? [], [threadsData]);
210
179
  const { deleteMessages } = useDeleteMessages({
211
180
  mailboxId: selectedThread?.mailboxId ?? "",
212
181
  messages: briefThreads,
@@ -219,8 +188,8 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
219
188
 
220
189
  const focusedThreadId = triage.focusedMessageId;
221
190
  const focusedThread = useMemo(
222
- () => threadsData?.items.find((t) => t.messageId === focusedThreadId),
223
- [threadsData, focusedThreadId],
191
+ () => briefThreads.find((t) => t.messageId === focusedThreadId),
192
+ [briefThreads, focusedThreadId],
224
193
  );
225
194
  const triageTarget = focusedThread ?? selectedThread;
226
195
  const triageActions = useThreadActions({ thread: triageTarget });
@@ -252,18 +221,29 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
252
221
  },
253
222
  });
254
223
 
224
+ // The swipe gestures open a whole conversation, so the adjacent row has to
225
+ // name its thread. The brief's own listing is where that is looked up, so a
226
+ // row it does not hold offers no gesture rather than a tap that goes nowhere.
227
+ const adjacentThread = useCallback(
228
+ (messageId: string | undefined): BriefThreadTarget | undefined => {
229
+ if (!messageId) return undefined;
230
+ const row = briefThreads.find((t) => t.messageId === messageId);
231
+ return row ? { threadId: row.threadId, messageId } : undefined;
232
+ },
233
+ [briefThreads],
234
+ );
235
+
255
236
  const ctx: BriefPaneContextValue = {
256
237
  selectedMessageId,
257
238
  selectedThread,
258
239
  conversation,
259
- onSelectMessage: handleSelectMessage,
260
- onSelectSearchResult: handleSelectSearchResult,
240
+ onOpenThread: handleOpenThread,
261
241
  onCloseThread: handleCloseThread,
242
+ nextThread: adjacentThread(nextMessageId),
243
+ previousThread: adjacentThread(previousMessageId),
262
244
  actions,
263
245
  triage,
264
246
  onDeleteMessages: deleteMessages,
265
- nextMessageId,
266
- previousMessageId,
267
247
  handleDeselectIfRemoved,
268
248
  };
269
249
 
@@ -278,21 +258,15 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
278
258
  * Daily brief list. Mount in the `list` slot of `AppShellSlotted`.
279
259
  */
280
260
  function BriefList() {
281
- const {
282
- selectedMessageId,
283
- onSelectMessage,
284
- onSelectSearchResult,
285
- triage,
286
- onDeleteMessages,
287
- } = useBriefPane();
261
+ const { selectedMessageId, onOpenThread, triage, onDeleteMessages } =
262
+ useBriefPane();
288
263
  const { accounts } = useMailContext();
289
264
 
290
265
  return (
291
266
  <DailyBrief
292
267
  accounts={accounts}
293
268
  selectedMessageId={selectedMessageId}
294
- onSelectMessage={onSelectMessage}
295
- onSelectSearchResult={onSelectSearchResult}
269
+ onOpenThread={onOpenThread}
296
270
  commandsRef={triage.listCommandsRef}
297
271
  onTriageContextChange={triage.onTriageContextChange}
298
272
  onDeleteMessages={onDeleteMessages}
@@ -386,10 +360,10 @@ function BriefPhone() {
386
360
  const {
387
361
  selectedThread,
388
362
  conversation,
389
- onSelectMessage,
363
+ onOpenThread,
390
364
  onCloseThread,
391
- nextMessageId,
392
- previousMessageId,
365
+ nextThread,
366
+ previousThread,
393
367
  handleDeselectIfRemoved,
394
368
  } = useBriefPane();
395
369
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
@@ -405,13 +379,9 @@ function BriefPhone() {
405
379
  authenticity={conversation.authenticity}
406
380
  onBack={onCloseThread}
407
381
  onOpenIntelligence={onToggleIntelligence}
408
- onSwipeNext={
409
- nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
410
- }
382
+ onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
411
383
  onSwipePrevious={
412
- previousMessageId
413
- ? () => onSelectMessage(previousMessageId)
414
- : undefined
384
+ previousThread ? () => onOpenThread(previousThread) : undefined
415
385
  }
416
386
  mobileIntelligenceOpen={intelligenceOpen}
417
387
  />
@@ -322,8 +322,8 @@ export const ConversationView = ({
322
322
  onComposeClose?.();
323
323
  }, [onComposeClose]);
324
324
 
325
- // Single-pane compose opens below the message, which on a long one is several
326
- // screens down: without this, replying to it looks like nothing happening.
325
+ // Compose opens below the message, which on a long one is several screens
326
+ // down: without this, replying to it looks like nothing happening.
327
327
  //
328
328
  // Aligned on its bottom edge, where the send and discard verbs are, and held
329
329
  // there while it changes height. It mounts before the message it quotes has
@@ -483,15 +483,23 @@ export const ConversationView = ({
483
483
  onOpenIntelligence={onOpenIntelligence}
484
484
  />
485
485
  )}
486
- <div className="flex-1 overflow-auto">{renderMessages(false)}</div>
487
- {composeMode !== null && (
488
- <InlineCompose
489
- mode={composeMode}
490
- account={activeAccount}
491
- sourceMessage={latestMessageData}
492
- onClose={handleCloseCompose}
493
- />
494
- )}
486
+ {/* The reply is part of the conversation, so it scrolls with it. Held
487
+ below the pane instead, it took whatever height the message left
488
+ over — nothing on a long one — and no amount of scrolling could
489
+ reach it. */}
490
+ <div className="min-h-0 flex-1 overflow-auto">
491
+ {renderMessages(false)}
492
+ {composeMode !== null && (
493
+ <div ref={composeRef}>
494
+ <InlineCompose
495
+ mode={composeMode}
496
+ account={activeAccount}
497
+ sourceMessage={latestMessageData}
498
+ onClose={handleCloseCompose}
499
+ />
500
+ </div>
501
+ )}
502
+ </div>
495
503
  </article>
496
504
  );
497
505
  };
@@ -105,6 +105,7 @@ import {
105
105
  type SelectionWizardControl,
106
106
  useSelectionWizard,
107
107
  } from "@/lib/wizard-history";
108
+ import type { BriefThreadTarget } from "@/routing";
108
109
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
109
110
  import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
110
111
  import type { MessageListCommands } from "./MessageList";
@@ -378,14 +379,13 @@ function BriefSelectionChrome({
378
379
  interface DailyBriefProps {
379
380
  accounts: RemitImapAccountResponse[];
380
381
  selectedMessageId?: string;
381
- /** Opens an in-list brief row (resolved by messageId against the loaded list). */
382
- onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
383
382
  /**
384
- * Opens a search result. A semantic "Related" hit carries its thread + mailbox
385
- * so it opens even when its message isn't in the loaded brief list.
383
+ * Opens a row. Every row the brief renders names its thread, so the row the
384
+ * search widened in from another folder opens by exactly the same route as one
385
+ * from the unified inbox.
386
386
  */
387
- onSelectSearchResult?: (
388
- result: SearchResult,
387
+ onOpenThread?: (
388
+ target: BriefThreadTarget,
389
389
  options?: OpenMessageOptions,
390
390
  ) => void;
391
391
  /** Where the list publishes the commands the keyboard layer drives. */
@@ -398,8 +398,7 @@ interface DailyBriefProps {
398
398
  export function DailyBrief({
399
399
  accounts,
400
400
  selectedMessageId,
401
- onSelectMessage,
402
- onSelectSearchResult,
401
+ onOpenThread,
403
402
  commandsRef,
404
403
  onTriageContextChange,
405
404
  onDeleteMessages,
@@ -618,26 +617,29 @@ export function DailyBrief({
618
617
  );
619
618
 
620
619
  // A committed query puts rows the widened cross-folder search found into the
621
- // body, and those messages are in folders the brief itself never loads. The
622
- // reading pane resolves a bare `selectedMessageId` against the brief's own
623
- // thread list, so such a row opens nothing; it goes through the search path
624
- // instead, which carries the thread and mailbox the conversation is fetched
625
- // by (#635).
620
+ // body, and those messages are in folders the brief itself never loads. They
621
+ // open like any other row: what the address carries is the thread, which
622
+ // every row names, and the conversation is fetched by it (#635).
626
623
  const openRow = useCallback(
627
624
  (id: string, options?: OpenMessageOptions) => {
628
- const row = sq
629
- ? bodyRows.find((candidate) => candidate.id === id)
630
- : undefined;
631
- if (row?.threadId && row.mailboxId && onSelectSearchResult) {
632
- onSelectSearchResult(
633
- rowToSearchResult(row, resultFolderIndex),
634
- options,
635
- );
636
- return;
637
- }
638
- onSelectMessage?.(id, options);
625
+ const threadId = filteredRows.find((row) => row.id === id)?.threadId;
626
+ if (!threadId) return;
627
+ onOpenThread?.({ threadId, messageId: id }, options);
628
+ },
629
+ [filteredRows, onOpenThread],
630
+ );
631
+
632
+ // The two-engine results panel, whose semantic hits are in no list at all —
633
+ // each one carries the thread it belongs to.
634
+ const openResult = useCallback(
635
+ (result: SearchResult) => {
636
+ const threadId =
637
+ result.threadId ??
638
+ filteredRows.find((row) => row.id === result.id)?.threadId;
639
+ if (!threadId) return;
640
+ onOpenThread?.({ threadId, messageId: result.id });
639
641
  },
640
- [sq, bodyRows, resultFolderIndex, onSelectSearchResult, onSelectMessage],
642
+ [filteredRows, onOpenThread],
641
643
  );
642
644
 
643
645
  const accountSources = useMemo<FilterSheetSource[]>(() => {
@@ -903,7 +905,7 @@ export function DailyBrief({
903
905
  searchLoading: isLoading || searchFetching,
904
906
  relatedResults,
905
907
  relatedLoading,
906
- onSelectSearchResult,
908
+ onSelectSearchResult: openResult,
907
909
  // The body already narrows to the committed query
908
910
  // (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
909
911
  // above), so a committed search is a selectable list here exactly as
@@ -229,7 +229,7 @@ export function MailSidebarAdapter({
229
229
  return (
230
230
  <NavLink
231
231
  to="/mail/brief"
232
- search={{ q: undefined, selectedMessageId: undefined }}
232
+ search={{ q: undefined }}
233
233
  activeOptions={{ includeSearch: false }}
234
234
  onClick={() => onClick?.()}
235
235
  className={className}
@@ -340,10 +340,7 @@ export function MailSidebarAdapter({
340
340
  const handleSelectSavedSearch = useCallback(
341
341
  (query: string) => {
342
342
  onSearchChange(query);
343
- navigate({
344
- to: "/mail/brief",
345
- search: { q: query, selectedMessageId: undefined },
346
- });
343
+ navigate({ to: "/mail/brief", search: { q: query } });
347
344
  onMailboxSelect?.();
348
345
  },
349
346
  [onSearchChange, navigate, onMailboxSelect],