@remit/web-client 0.0.143 → 0.0.145

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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeProvider.tsx +79 -3
  3. package/src/components/compose/compose-clears-open-thread.render.test.ts +302 -0
  4. package/src/components/compose/compose-send-stops-autosave.render.test.ts +34 -5
  5. package/src/components/layout/ComposeFab.tsx +11 -36
  6. package/src/components/layout/MailShell.tsx +98 -0
  7. package/src/components/layout/MailTopBar.tsx +6 -3
  8. package/src/components/mail/BriefPane.tsx +5 -5
  9. package/src/components/mail/DraftsView.tsx +0 -11
  10. package/src/components/mail/FlaggedPane.tsx +1 -1
  11. package/src/components/mail/MailSidebarAdapter.tsx +5 -8
  12. package/src/components/mail/MailboxPane.tsx +15 -5
  13. package/src/components/ui/FatalErrorOverlay.tsx +4 -1
  14. package/src/hooks/useComposeTargetMailbox.ts +75 -0
  15. package/src/hooks/useSearchMirror.ts +93 -0
  16. package/src/hooks/useSearchScope.ts +1 -1
  17. package/src/lib/compose-routes.test.ts +3 -1
  18. package/src/lib/compose-routes.ts +6 -6
  19. package/src/lib/mail-route.test.ts +157 -57
  20. package/src/lib/mail-route.ts +71 -38
  21. package/src/lib/mail-search.ts +51 -0
  22. package/src/lib/route-search-query.test.ts +50 -28
  23. package/src/lib/search-scope.ts +11 -19
  24. package/src/lib/search-view.test.ts +142 -38
  25. package/src/lib/search-view.ts +35 -11
  26. package/src/routeTree.gen.ts +155 -18
  27. package/src/router.tsx +17 -0
  28. package/src/routes/index.tsx +1 -1
  29. package/src/routes/mail/$mailboxId/index.tsx +10 -0
  30. package/src/routes/mail/$mailboxId.tsx +27 -19
  31. package/src/routes/mail/brief/index.tsx +14 -0
  32. package/src/routes/mail/brief.tsx +50 -0
  33. package/src/routes/mail/flagged/index.tsx +10 -0
  34. package/src/routes/mail/flagged.tsx +25 -13
  35. package/src/routes/mail/index.tsx +9 -36
  36. package/src/routes/mail/outbox/index.tsx +10 -0
  37. package/src/routes/mail/outbox.tsx +23 -13
  38. package/src/routes/mail.tsx +51 -249
  39. package/src/test-support/list-search-binding.ts +34 -0
  40. package/src/hooks/useComposeTarget.ts +0 -92
@@ -10,6 +10,7 @@
10
10
  * on, so switching mailbox clears a stale query while a deep link or a saved
11
11
  * search that carries `q` still arrives with the query intact.
12
12
  */
13
+ import { locationIsOnList } from "./mail-route";
13
14
 
14
15
  /**
15
16
  * The field text after a view transition, or `undefined` when nothing changes
@@ -36,19 +37,42 @@ export function committedSearchQuery(
36
37
  return searchInput === "" ? "" : debouncedSearchInput;
37
38
  }
38
39
 
40
+ export interface MirrorDecision {
41
+ /** The live field text. */
42
+ searchInput: string;
43
+ /** The debounced query the search APIs are running. */
44
+ committedQuery: string;
45
+ /** What the URL currently says the query is. */
46
+ urlQuery: string;
47
+ /** Where the router says the reader is, which is where a write would land. */
48
+ pathname: string;
49
+ /** The path of the list asking. */
50
+ listPath: string;
51
+ }
52
+
39
53
  /**
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.
54
+ * Whether a list may write the committed query into the URL.
55
+ *
56
+ * Only a settled query may: mid-debounce the committed value is the *previous*
57
+ * query, and writing it would overwrite the URL that a deep link or a saved
58
+ * search just arrived with stripping `q` for as long as the debounce lasts,
59
+ * and taking the search with it. The mirror waits for the field and the
60
+ * committed query to agree, then writes only if the URL says something else.
61
+ *
62
+ * And only the list the reader is actually on may: a list stays mounted, effects
63
+ * and all, until the list they navigated to is ready to paint, by which time the
64
+ * address is already the new one. A write from the outgoing list in that window
65
+ * navigates back to itself, superseding the load in flight and replacing the
66
+ * entry the reader just pushed — they click Inbox and land on the brief.
46
67
  */
47
- export function shouldMirrorQuery(
48
- searchInput: string,
49
- committedQuery: string,
50
- urlQuery: string,
51
- ): boolean {
68
+ export function shouldMirrorQuery({
69
+ searchInput,
70
+ committedQuery,
71
+ urlQuery,
72
+ pathname,
73
+ listPath,
74
+ }: MirrorDecision): boolean {
75
+ if (!locationIsOnList(pathname, listPath)) return false;
52
76
  if (committedQuery !== searchInput) return false;
53
77
  return committedQuery !== urlQuery;
54
78
  }
@@ -15,6 +15,7 @@ import { Route as OnboardingRouteImport } from './routes/onboarding'
15
15
  import { Route as SettingsRouteImport } from './routes/settings'
16
16
  import { Route as MailIndexRouteImport } from './routes/mail/index'
17
17
  import { Route as MailMailboxIdRouteImport } from './routes/mail/$mailboxId'
18
+ import { Route as MailBriefRouteImport } from './routes/mail/brief'
18
19
  import { Route as MailFlaggedRouteImport } from './routes/mail/flagged'
19
20
  import { Route as MailOutboxRouteImport } from './routes/mail/outbox'
20
21
  import { Route as SettingsIndexRouteImport } from './routes/settings/index'
@@ -26,6 +27,10 @@ import { Route as SettingsFoldersRouteImport } from './routes/settings/folders'
26
27
  import { Route as SettingsLabelsRouteImport } from './routes/settings/labels'
27
28
  import { Route as SettingsSendersRouteImport } from './routes/settings/senders'
28
29
  import { Route as SettingsSuggestedVipsRouteImport } from './routes/settings/suggested-vips'
30
+ import { Route as MailMailboxIdIndexRouteImport } from './routes/mail/$mailboxId/index'
31
+ import { Route as MailBriefIndexRouteImport } from './routes/mail/brief/index'
32
+ import { Route as MailFlaggedIndexRouteImport } from './routes/mail/flagged/index'
33
+ import { Route as MailOutboxIndexRouteImport } from './routes/mail/outbox/index'
29
34
 
30
35
  const IndexRoute = IndexRouteImport.update({
31
36
  id: '/',
@@ -57,6 +62,11 @@ const MailMailboxIdRoute = MailMailboxIdRouteImport.update({
57
62
  path: '/$mailboxId',
58
63
  getParentRoute: () => MailRoute,
59
64
  } as any)
65
+ const MailBriefRoute = MailBriefRouteImport.update({
66
+ id: '/brief',
67
+ path: '/brief',
68
+ getParentRoute: () => MailRoute,
69
+ } as any)
60
70
  const MailFlaggedRoute = MailFlaggedRouteImport.update({
61
71
  id: '/flagged',
62
72
  path: '/flagged',
@@ -112,15 +122,36 @@ const SettingsSuggestedVipsRoute = SettingsSuggestedVipsRouteImport.update({
112
122
  path: '/suggested-vips',
113
123
  getParentRoute: () => SettingsRoute,
114
124
  } as any)
125
+ const MailMailboxIdIndexRoute = MailMailboxIdIndexRouteImport.update({
126
+ id: '/',
127
+ path: '/',
128
+ getParentRoute: () => MailMailboxIdRoute,
129
+ } as any)
130
+ const MailBriefIndexRoute = MailBriefIndexRouteImport.update({
131
+ id: '/',
132
+ path: '/',
133
+ getParentRoute: () => MailBriefRoute,
134
+ } as any)
135
+ const MailFlaggedIndexRoute = MailFlaggedIndexRouteImport.update({
136
+ id: '/',
137
+ path: '/',
138
+ getParentRoute: () => MailFlaggedRoute,
139
+ } as any)
140
+ const MailOutboxIndexRoute = MailOutboxIndexRouteImport.update({
141
+ id: '/',
142
+ path: '/',
143
+ getParentRoute: () => MailOutboxRoute,
144
+ } as any)
115
145
 
116
146
  export interface FileRoutesByFullPath {
117
147
  '/': typeof IndexRoute
118
148
  '/mail': typeof MailRouteWithChildren
119
149
  '/onboarding': typeof OnboardingRoute
120
150
  '/settings': typeof SettingsRouteWithChildren
121
- '/mail/$mailboxId': typeof MailMailboxIdRoute
122
- '/mail/flagged': typeof MailFlaggedRoute
123
- '/mail/outbox': typeof MailOutboxRoute
151
+ '/mail/$mailboxId': typeof MailMailboxIdRouteWithChildren
152
+ '/mail/brief': typeof MailBriefRouteWithChildren
153
+ '/mail/flagged': typeof MailFlaggedRouteWithChildren
154
+ '/mail/outbox': typeof MailOutboxRouteWithChildren
124
155
  '/settings/accounts': typeof SettingsAccountsRoute
125
156
  '/settings/advanced': typeof SettingsAdvancedRoute
126
157
  '/settings/appearance': typeof SettingsAppearanceRoute
@@ -131,13 +162,14 @@ export interface FileRoutesByFullPath {
131
162
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
132
163
  '/mail/': typeof MailIndexRoute
133
164
  '/settings/': typeof SettingsIndexRoute
165
+ '/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
166
+ '/mail/brief/': typeof MailBriefIndexRoute
167
+ '/mail/flagged/': typeof MailFlaggedIndexRoute
168
+ '/mail/outbox/': typeof MailOutboxIndexRoute
134
169
  }
135
170
  export interface FileRoutesByTo {
136
171
  '/': typeof IndexRoute
137
172
  '/onboarding': typeof OnboardingRoute
138
- '/mail/$mailboxId': typeof MailMailboxIdRoute
139
- '/mail/flagged': typeof MailFlaggedRoute
140
- '/mail/outbox': typeof MailOutboxRoute
141
173
  '/settings/accounts': typeof SettingsAccountsRoute
142
174
  '/settings/advanced': typeof SettingsAdvancedRoute
143
175
  '/settings/appearance': typeof SettingsAppearanceRoute
@@ -148,6 +180,10 @@ export interface FileRoutesByTo {
148
180
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
149
181
  '/mail': typeof MailIndexRoute
150
182
  '/settings': typeof SettingsIndexRoute
183
+ '/mail/$mailboxId': typeof MailMailboxIdIndexRoute
184
+ '/mail/brief': typeof MailBriefIndexRoute
185
+ '/mail/flagged': typeof MailFlaggedIndexRoute
186
+ '/mail/outbox': typeof MailOutboxIndexRoute
151
187
  }
152
188
  export interface FileRoutesById {
153
189
  __root__: typeof rootRouteImport
@@ -155,9 +191,10 @@ export interface FileRoutesById {
155
191
  '/mail': typeof MailRouteWithChildren
156
192
  '/onboarding': typeof OnboardingRoute
157
193
  '/settings': typeof SettingsRouteWithChildren
158
- '/mail/$mailboxId': typeof MailMailboxIdRoute
159
- '/mail/flagged': typeof MailFlaggedRoute
160
- '/mail/outbox': typeof MailOutboxRoute
194
+ '/mail/$mailboxId': typeof MailMailboxIdRouteWithChildren
195
+ '/mail/brief': typeof MailBriefRouteWithChildren
196
+ '/mail/flagged': typeof MailFlaggedRouteWithChildren
197
+ '/mail/outbox': typeof MailOutboxRouteWithChildren
161
198
  '/settings/accounts': typeof SettingsAccountsRoute
162
199
  '/settings/advanced': typeof SettingsAdvancedRoute
163
200
  '/settings/appearance': typeof SettingsAppearanceRoute
@@ -168,6 +205,10 @@ export interface FileRoutesById {
168
205
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
169
206
  '/mail/': typeof MailIndexRoute
170
207
  '/settings/': typeof SettingsIndexRoute
208
+ '/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
209
+ '/mail/brief/': typeof MailBriefIndexRoute
210
+ '/mail/flagged/': typeof MailFlaggedIndexRoute
211
+ '/mail/outbox/': typeof MailOutboxIndexRoute
171
212
  }
172
213
  export interface FileRouteTypes {
173
214
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -177,6 +218,7 @@ export interface FileRouteTypes {
177
218
  | '/onboarding'
178
219
  | '/settings'
179
220
  | '/mail/$mailboxId'
221
+ | '/mail/brief'
180
222
  | '/mail/flagged'
181
223
  | '/mail/outbox'
182
224
  | '/settings/accounts'
@@ -189,13 +231,14 @@ export interface FileRouteTypes {
189
231
  | '/settings/suggested-vips'
190
232
  | '/mail/'
191
233
  | '/settings/'
234
+ | '/mail/$mailboxId/'
235
+ | '/mail/brief/'
236
+ | '/mail/flagged/'
237
+ | '/mail/outbox/'
192
238
  fileRoutesByTo: FileRoutesByTo
193
239
  to:
194
240
  | '/'
195
241
  | '/onboarding'
196
- | '/mail/$mailboxId'
197
- | '/mail/flagged'
198
- | '/mail/outbox'
199
242
  | '/settings/accounts'
200
243
  | '/settings/advanced'
201
244
  | '/settings/appearance'
@@ -206,6 +249,10 @@ export interface FileRouteTypes {
206
249
  | '/settings/suggested-vips'
207
250
  | '/mail'
208
251
  | '/settings'
252
+ | '/mail/$mailboxId'
253
+ | '/mail/brief'
254
+ | '/mail/flagged'
255
+ | '/mail/outbox'
209
256
  id:
210
257
  | '__root__'
211
258
  | '/'
@@ -213,6 +260,7 @@ export interface FileRouteTypes {
213
260
  | '/onboarding'
214
261
  | '/settings'
215
262
  | '/mail/$mailboxId'
263
+ | '/mail/brief'
216
264
  | '/mail/flagged'
217
265
  | '/mail/outbox'
218
266
  | '/settings/accounts'
@@ -225,6 +273,10 @@ export interface FileRouteTypes {
225
273
  | '/settings/suggested-vips'
226
274
  | '/mail/'
227
275
  | '/settings/'
276
+ | '/mail/$mailboxId/'
277
+ | '/mail/brief/'
278
+ | '/mail/flagged/'
279
+ | '/mail/outbox/'
228
280
  fileRoutesById: FileRoutesById
229
281
  }
230
282
  export interface RootRouteChildren {
@@ -278,6 +330,13 @@ declare module '@tanstack/react-router' {
278
330
  preLoaderRoute: typeof MailMailboxIdRouteImport
279
331
  parentRoute: typeof MailRoute
280
332
  }
333
+ '/mail/brief': {
334
+ id: '/mail/brief'
335
+ path: '/brief'
336
+ fullPath: '/mail/brief'
337
+ preLoaderRoute: typeof MailBriefRouteImport
338
+ parentRoute: typeof MailRoute
339
+ }
281
340
  '/mail/flagged': {
282
341
  id: '/mail/flagged'
283
342
  path: '/flagged'
@@ -355,20 +414,98 @@ declare module '@tanstack/react-router' {
355
414
  preLoaderRoute: typeof SettingsSuggestedVipsRouteImport
356
415
  parentRoute: typeof SettingsRoute
357
416
  }
417
+ '/mail/$mailboxId/': {
418
+ id: '/mail/$mailboxId/'
419
+ path: '/'
420
+ fullPath: '/mail/$mailboxId/'
421
+ preLoaderRoute: typeof MailMailboxIdIndexRouteImport
422
+ parentRoute: typeof MailMailboxIdRoute
423
+ }
424
+ '/mail/brief/': {
425
+ id: '/mail/brief/'
426
+ path: '/'
427
+ fullPath: '/mail/brief/'
428
+ preLoaderRoute: typeof MailBriefIndexRouteImport
429
+ parentRoute: typeof MailBriefRoute
430
+ }
431
+ '/mail/flagged/': {
432
+ id: '/mail/flagged/'
433
+ path: '/'
434
+ fullPath: '/mail/flagged/'
435
+ preLoaderRoute: typeof MailFlaggedIndexRouteImport
436
+ parentRoute: typeof MailFlaggedRoute
437
+ }
438
+ '/mail/outbox/': {
439
+ id: '/mail/outbox/'
440
+ path: '/'
441
+ fullPath: '/mail/outbox/'
442
+ preLoaderRoute: typeof MailOutboxIndexRouteImport
443
+ parentRoute: typeof MailOutboxRoute
444
+ }
358
445
  }
359
446
  }
360
447
 
448
+ interface MailMailboxIdRouteChildren {
449
+ MailMailboxIdIndexRoute: typeof MailMailboxIdIndexRoute
450
+ }
451
+
452
+ const MailMailboxIdRouteChildren: MailMailboxIdRouteChildren = {
453
+ MailMailboxIdIndexRoute: MailMailboxIdIndexRoute,
454
+ }
455
+
456
+ const MailMailboxIdRouteWithChildren = MailMailboxIdRoute._addFileChildren(
457
+ MailMailboxIdRouteChildren,
458
+ )
459
+
460
+ interface MailBriefRouteChildren {
461
+ MailBriefIndexRoute: typeof MailBriefIndexRoute
462
+ }
463
+
464
+ const MailBriefRouteChildren: MailBriefRouteChildren = {
465
+ MailBriefIndexRoute: MailBriefIndexRoute,
466
+ }
467
+
468
+ const MailBriefRouteWithChildren = MailBriefRoute._addFileChildren(
469
+ MailBriefRouteChildren,
470
+ )
471
+
472
+ interface MailFlaggedRouteChildren {
473
+ MailFlaggedIndexRoute: typeof MailFlaggedIndexRoute
474
+ }
475
+
476
+ const MailFlaggedRouteChildren: MailFlaggedRouteChildren = {
477
+ MailFlaggedIndexRoute: MailFlaggedIndexRoute,
478
+ }
479
+
480
+ const MailFlaggedRouteWithChildren = MailFlaggedRoute._addFileChildren(
481
+ MailFlaggedRouteChildren,
482
+ )
483
+
484
+ interface MailOutboxRouteChildren {
485
+ MailOutboxIndexRoute: typeof MailOutboxIndexRoute
486
+ }
487
+
488
+ const MailOutboxRouteChildren: MailOutboxRouteChildren = {
489
+ MailOutboxIndexRoute: MailOutboxIndexRoute,
490
+ }
491
+
492
+ const MailOutboxRouteWithChildren = MailOutboxRoute._addFileChildren(
493
+ MailOutboxRouteChildren,
494
+ )
495
+
361
496
  interface MailRouteChildren {
362
- MailMailboxIdRoute: typeof MailMailboxIdRoute
363
- MailFlaggedRoute: typeof MailFlaggedRoute
364
- MailOutboxRoute: typeof MailOutboxRoute
497
+ MailMailboxIdRoute: typeof MailMailboxIdRouteWithChildren
498
+ MailBriefRoute: typeof MailBriefRouteWithChildren
499
+ MailFlaggedRoute: typeof MailFlaggedRouteWithChildren
500
+ MailOutboxRoute: typeof MailOutboxRouteWithChildren
365
501
  MailIndexRoute: typeof MailIndexRoute
366
502
  }
367
503
 
368
504
  const MailRouteChildren: MailRouteChildren = {
369
- MailMailboxIdRoute: MailMailboxIdRoute,
370
- MailFlaggedRoute: MailFlaggedRoute,
371
- MailOutboxRoute: MailOutboxRoute,
505
+ MailMailboxIdRoute: MailMailboxIdRouteWithChildren,
506
+ MailBriefRoute: MailBriefRouteWithChildren,
507
+ MailFlaggedRoute: MailFlaggedRouteWithChildren,
508
+ MailOutboxRoute: MailOutboxRouteWithChildren,
372
509
  MailIndexRoute: MailIndexRoute,
373
510
  }
374
511
 
package/src/router.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { QueryClient } from "@tanstack/react-query";
2
2
  import { createRouter } from "@tanstack/react-router";
3
+ import { AppShellSkeleton } from "./components/layout/AppShellSkeleton";
3
4
  import { recordRoute } from "./lib/route-breadcrumbs";
4
5
  import type { Telemetry } from "./lib/telemetry";
5
6
  import { routeTree } from "./routeTree.gen";
@@ -16,6 +17,22 @@ export const createAppRouter = (
16
17
  routeTree,
17
18
  context: { queryClient },
18
19
  defaultPreload: "intent",
20
+ // A route arrives with its own code, and each mail list carries a whole pane
21
+ // in it. Without a pending state the router keeps the previous route on
22
+ // screen for that fetch and first mount, under the new address — so the list
23
+ // the reader has left stays interactive: a click routes through its handlers
24
+ // and takes them back to it, and the keyboard drives its cursor. Preloading
25
+ // on intent does not close that: it fires on hover and focus, so it only
26
+ // moves the window's start, and it never applies to the navigations the
27
+ // keyboard shortcuts make.
28
+ //
29
+ // 200ms is above a preloaded transition, which lands in tens of milliseconds
30
+ // and so paints no skeleton at all, and below the point where a stale list
31
+ // invites a click. Once shown it stays 300ms, so a transition that settles
32
+ // just after the threshold does not strobe.
33
+ defaultPendingComponent: AppShellSkeleton,
34
+ defaultPendingMs: 200,
35
+ defaultPendingMinMs: 300,
19
36
  });
20
37
 
21
38
  router.subscribe("onResolved", (event) => {
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
2
2
 
3
3
  export const Route = createFileRoute("/")({
4
4
  beforeLoad: () => {
5
- throw redirect({ to: "/mail" });
5
+ throw redirect({ to: "/mail/brief" });
6
6
  },
7
7
  });
@@ -0,0 +1,10 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { MailboxPane } from "@/components/mail/MailboxPane";
3
+
4
+ function MailboxReadingPane() {
5
+ return <MailboxPane.Reading />;
6
+ }
7
+
8
+ export const Route = createFileRoute("/mail/$mailboxId/")({
9
+ component: MailboxReadingPane,
10
+ });
@@ -1,27 +1,35 @@
1
1
  /**
2
- * /mail/$mailboxId route shell activation only.
2
+ * /mail/$mailboxId — one folder, the fullest of the four list layouts: list,
3
+ * reading pane and intelligence rail.
3
4
  *
4
- * The component returns null because rendering is lifted to `mail.tsx`
5
- * which reads the active mailboxId via `useRouterState` and mounts
6
- * `<MailboxPane>` directly into `<AppShellSlotted>` slots. TanStack Router
7
- * still needs this file to:
8
- * - Register the route so URL matching works
9
- * - Validate the search params so selectedMessageId is properly typed
5
+ * The literal lists are declared as siblings and TanStack matches literals
6
+ * first, so a mailbox id can never be read as one of them.
10
7
  */
11
- import { createFileRoute } from "@tanstack/react-router";
12
- import { z } from "zod";
8
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
9
+ import { MailShell } from "@/components/layout/MailShell";
10
+ import { MailboxPane } from "@/components/mail/MailboxPane";
11
+ import { useSearchMirror } from "@/hooks/useSearchMirror";
12
+ import { mailboxSearchSchema } from "@/lib/mail-search";
13
13
 
14
- // Search schema includes q from parent route for proper inheritance
15
- const mailboxSearchSchema = z.object({
16
- selectedMessageId: z.string().optional(),
17
- // A tapped semantic "Related" hit can point at a message outside the loaded
18
- // list; carrying its thread lets the mailbox open it directly (the mailbox is
19
- // the route param). See `buildConversationTarget`.
20
- selectedThreadId: z.string().optional(),
21
- q: z.string().optional(),
22
- });
14
+ function MailboxLayout() {
15
+ const { mailboxId } = Route.useParams();
16
+ const { selectedMessageId } = Route.useSearch();
17
+ useSearchMirror({ to: "/mail/$mailboxId", params: { mailboxId } });
18
+
19
+ return (
20
+ <MailboxPane mailboxId={mailboxId} selectedMessageId={selectedMessageId}>
21
+ <MailShell
22
+ phone={<MailboxPane.Phone />}
23
+ list={<MailboxPane.List />}
24
+ reading={<Outlet />}
25
+ intelligence={<MailboxPane.Intelligence />}
26
+ hasThread={Boolean(selectedMessageId)}
27
+ />
28
+ </MailboxPane>
29
+ );
30
+ }
23
31
 
24
32
  export const Route = createFileRoute("/mail/$mailboxId")({
25
- component: () => null,
33
+ component: MailboxLayout,
26
34
  validateSearch: mailboxSearchSchema,
27
35
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The brief's reading pane. It is a route because the pane belongs to the list
3
+ * it was opened from; the thread and message segments arrive under it.
4
+ */
5
+ import { createFileRoute } from "@tanstack/react-router";
6
+ import { BriefPane } from "@/components/mail/BriefPane";
7
+
8
+ function BriefReadingPane() {
9
+ return <BriefPane.Reading />;
10
+ }
11
+
12
+ export const Route = createFileRoute("/mail/brief/")({
13
+ component: BriefReadingPane,
14
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * /mail/brief — the daily brief, one of the four list layouts.
3
+ *
4
+ * The route mounts the list and the shell around it, and the reading pane is
5
+ * the `Outlet`, so what the pane shows is whatever route is matched under this
6
+ * one rather than a decision made somewhere above.
7
+ */
8
+ import {
9
+ createFileRoute,
10
+ type ErrorComponentProps,
11
+ Outlet,
12
+ } from "@tanstack/react-router";
13
+ import { MailShell } from "@/components/layout/MailShell";
14
+ import { BriefPane } from "@/components/mail/BriefPane";
15
+ import { ErrorState } from "@/components/ui/ErrorState";
16
+ import { useSearchMirror } from "@/hooks/useSearchMirror";
17
+ import { briefSearchSchema } from "@/lib/mail-search";
18
+
19
+ const BriefError = ({ error, reset }: ErrorComponentProps) => (
20
+ <div className="flex h-full items-center justify-center bg-canvas p-4">
21
+ <ErrorState
22
+ title="Couldn't load your mailboxes"
23
+ error={error}
24
+ onRetry={reset}
25
+ />
26
+ </div>
27
+ );
28
+
29
+ function BriefLayout() {
30
+ const { selectedMessageId } = Route.useSearch();
31
+ useSearchMirror({ to: "/mail/brief" });
32
+
33
+ return (
34
+ <BriefPane selectedMessageId={selectedMessageId}>
35
+ <MailShell
36
+ phone={<BriefPane.Phone />}
37
+ list={<BriefPane.List />}
38
+ reading={<Outlet />}
39
+ intelligence={<BriefPane.Intelligence />}
40
+ hasThread={Boolean(selectedMessageId)}
41
+ />
42
+ </BriefPane>
43
+ );
44
+ }
45
+
46
+ export const Route = createFileRoute("/mail/brief")({
47
+ component: BriefLayout,
48
+ validateSearch: briefSearchSchema,
49
+ errorComponent: BriefError,
50
+ });
@@ -0,0 +1,10 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { FlaggedPane } from "@/components/mail/FlaggedPane";
3
+
4
+ function FlaggedReadingPane() {
5
+ return <FlaggedPane.Reading />;
6
+ }
7
+
8
+ export const Route = createFileRoute("/mail/flagged/")({
9
+ component: FlaggedReadingPane,
10
+ });
@@ -1,16 +1,19 @@
1
1
  /**
2
- * /mail/flagged route (Flagged virtual mailbox) shell activation only.
2
+ * /mail/flagged the Flagged virtual mailbox, one of the four list layouts.
3
3
  *
4
- * The component returns null because rendering is lifted to `mail.tsx`, which
5
- * detects the flagged route via `useRouterState` and mounts `<FlaggedPane>`
6
- * directly into `<AppShellSlotted>` slots — the same pattern the brief uses.
4
+ * A flat starred list across accounts; same slots as the brief, intelligence
5
+ * rail included.
7
6
  */
8
7
  import {
9
8
  createFileRoute,
10
9
  type ErrorComponentProps,
10
+ Outlet,
11
11
  } from "@tanstack/react-router";
12
- import { z } from "zod";
12
+ import { MailShell } from "@/components/layout/MailShell";
13
+ import { FlaggedPane } from "@/components/mail/FlaggedPane";
13
14
  import { ErrorState } from "@/components/ui/ErrorState";
15
+ import { useSearchMirror } from "@/hooks/useSearchMirror";
16
+ import { flaggedSearchSchema } from "@/lib/mail-search";
14
17
 
15
18
  const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
16
19
  <div className="flex h-full items-center justify-center bg-canvas p-4">
@@ -22,16 +25,25 @@ const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
22
25
  </div>
23
26
  );
24
27
 
25
- // `q` is inherited from the parent /mail route; re-declared here so it survives
26
- // this route's own search validation and isn't dropped when navigating with a
27
- // functional search updater.
28
- const flaggedSearchSchema = z.object({
29
- selectedMessageId: z.string().optional(),
30
- q: z.string().optional(),
31
- });
28
+ function FlaggedLayout() {
29
+ const { selectedMessageId } = Route.useSearch();
30
+ useSearchMirror({ to: "/mail/flagged" });
31
+
32
+ return (
33
+ <FlaggedPane selectedMessageId={selectedMessageId}>
34
+ <MailShell
35
+ phone={<FlaggedPane.Phone />}
36
+ list={<FlaggedPane.List />}
37
+ reading={<Outlet />}
38
+ intelligence={<FlaggedPane.Intelligence />}
39
+ hasThread={Boolean(selectedMessageId)}
40
+ />
41
+ </FlaggedPane>
42
+ );
43
+ }
32
44
 
33
45
  export const Route = createFileRoute("/mail/flagged")({
34
- component: () => null,
46
+ component: FlaggedLayout,
35
47
  validateSearch: flaggedSearchSchema,
36
48
  errorComponent: FlaggedError,
37
49
  });
@@ -1,41 +1,14 @@
1
1
  /**
2
- * /mail/ route (Daily Brief) shell activation only.
3
- *
4
- * The component returns null because rendering is lifted to `mail.tsx`
5
- * which detects the brief route via `useRouterState` and mounts
6
- * `<BriefPane>` directly into `<AppShellSlotted>` slots.
2
+ * `/mail` has no view of its own: the daily brief is one of the four lists and
3
+ * lives at its own path, so every link into the mail app that names no list
4
+ * lands here and is sent on to the brief carrying whatever it arrived with.
7
5
  */
8
- import {
9
- createFileRoute,
10
- type ErrorComponentProps,
11
- } from "@tanstack/react-router";
12
- import { z } from "zod";
13
- import { ErrorState } from "@/components/ui/ErrorState";
14
-
15
- const MailIndexError = ({ error, reset }: ErrorComponentProps) => (
16
- <div className="flex h-full items-center justify-center bg-canvas p-4">
17
- <ErrorState
18
- title="Couldn't load your mailboxes"
19
- error={error}
20
- onRetry={reset}
21
- />
22
- </div>
23
- );
24
-
25
- // `q` is inherited from the parent /mail route; re-declared here so it
26
- // survives this route's own search validation and isn't dropped when
27
- // navigating with a functional search updater.
28
- const briefSearchSchema = z.object({
29
- selectedMessageId: z.string().optional(),
30
- // A tapped semantic "Related" hit can point at a message outside the loaded
31
- // brief list; carrying its thread + mailbox lets the brief open it directly.
32
- selectedThreadId: z.string().optional(),
33
- selectedMailboxId: z.string().optional(),
34
- q: z.string().optional(),
35
- });
6
+ import { createFileRoute, redirect } from "@tanstack/react-router";
7
+ import { mailIndexSearchSchema } from "@/lib/mail-search";
36
8
 
37
9
  export const Route = createFileRoute("/mail/")({
38
- component: () => null,
39
- validateSearch: briefSearchSchema,
40
- errorComponent: MailIndexError,
10
+ validateSearch: mailIndexSearchSchema,
11
+ beforeLoad: ({ search }) => {
12
+ throw redirect({ to: "/mail/brief", search, replace: true });
13
+ },
41
14
  });
@@ -0,0 +1,10 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { OutboxPane } from "@/components/mail/OutboxPane";
3
+
4
+ function OutboxReadingPane() {
5
+ return <OutboxPane.Reading />;
6
+ }
7
+
8
+ export const Route = createFileRoute("/mail/outbox/")({
9
+ component: OutboxReadingPane,
10
+ });