@remit/web-client 0.0.162 → 0.0.164

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.162",
3
+ "version": "0.0.164",
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": {
@@ -41,7 +41,11 @@ import {
41
41
  } from "@/hooks/useTriageLayer";
42
42
  import type { ConversationTarget } from "@/lib/conversation-target";
43
43
  import { useMailContext } from "@/lib/mail-context";
44
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
44
+ import {
45
+ type OpenThreadPath,
46
+ type OpenThreadTarget,
47
+ retainOpenPanels,
48
+ } from "@/routing";
45
49
 
46
50
  /* ------------------------------------------------------------------ */
47
51
  /* Context */
@@ -148,13 +152,18 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
148
152
  // `searchInput`: a row can be tapped before the debounce settles, when
149
153
  // the committed query is still empty.
150
154
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
155
+ hash: retainOpenPanels,
151
156
  });
152
157
  },
153
158
  [navigate, searchInput],
154
159
  );
155
160
 
156
161
  const handleCloseThread = useCallback(() => {
157
- navigate({ to: "/mail/brief", search: (prev) => prev });
162
+ navigate({
163
+ to: "/mail/brief",
164
+ search: (prev) => prev,
165
+ hash: retainOpenPanels,
166
+ });
158
167
  }, [navigate]);
159
168
 
160
169
  const handleDeselectIfRemoved = useCallback(
@@ -0,0 +1,76 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { ThreadRowData } from "@remit/ui";
4
+ import { wizardScopeFor } from "@remit/ui";
5
+ import { resolveBriefSelectionScope } from "./DailyBrief";
6
+
7
+ /**
8
+ * The brief spans accounts and folders, so it resolves both from the selection
9
+ * itself. Which of the two a selection spans decides what the wizard's folder
10
+ * and rule steps can say: told to pick a single account, a selection already
11
+ * inside one account has nothing to act on and the flow dead-ends (#525).
12
+ */
13
+
14
+ const row = (
15
+ id: string,
16
+ accountId: string,
17
+ mailboxId: string,
18
+ ): ThreadRowData => ({
19
+ id,
20
+ accountId,
21
+ mailboxId,
22
+ fromName: "Booking.com",
23
+ fromEmail: "noreply@booking.com",
24
+ subject: "Booking confirmation",
25
+ snippet: "Your stay is confirmed",
26
+ timeLabel: "Jul 2",
27
+ });
28
+
29
+ const rows: ThreadRowData[] = [
30
+ row("m1", "acc-personal", "mbx-inbox"),
31
+ row("m2", "acc-personal", "mbx-archive"),
32
+ row("m3", "acc-work", "mbx-inbox"),
33
+ ];
34
+
35
+ const scopeOf = (...ids: string[]) =>
36
+ resolveBriefSelectionScope(rows, new Set(ids));
37
+
38
+ describe("the scope a brief selection resolves to", () => {
39
+ it("carries the folder restriction when one account holds every row", () => {
40
+ const scope = scopeOf("m1", "m2");
41
+ assert.equal(scope.restriction, "spansFolders");
42
+ assert.equal(scope.accountId, "acc-personal");
43
+ assert.equal(scope.mailboxId, undefined);
44
+ assert.match(scope.moveDisabledHint ?? "", /spans 2 folders/);
45
+ });
46
+
47
+ it("reaches the folder step told about folders, not accounts", () => {
48
+ const scope = scopeOf("m1", "m2");
49
+ const wizard = wizardScopeFor(scope.accountId, scope.restriction);
50
+ assert.match(wizard.destination ?? "", /within one folder/);
51
+ assert.doesNotMatch(wizard.destination ?? "", /account/);
52
+ // The account is settled, so the preview behind a widened door has one to
53
+ // count against and the match step keeps it.
54
+ assert.equal(wizard.accountId, "acc-personal");
55
+ });
56
+
57
+ it("carries the account restriction when the rows span accounts", () => {
58
+ const scope = scopeOf("m1", "m3");
59
+ assert.equal(scope.restriction, "spansAccounts");
60
+ assert.equal(scope.accountId, undefined);
61
+ assert.match(
62
+ wizardScopeFor(scope.accountId, scope.restriction).destination ?? "",
63
+ /single account/,
64
+ );
65
+ });
66
+
67
+ it("carries no restriction from one account and one folder", () => {
68
+ const scope = scopeOf("m1");
69
+ assert.equal(scope.restriction, undefined);
70
+ assert.equal(scope.mailboxId, "mbx-inbox");
71
+ assert.equal(
72
+ wizardScopeFor(scope.accountId, scope.restriction).destination,
73
+ undefined,
74
+ );
75
+ });
76
+ });
@@ -54,6 +54,7 @@ import {
54
54
  partitionSpamResults,
55
55
  RefreshButton,
56
56
  type SearchResult,
57
+ type SelectionRestriction,
57
58
  SelectionTopBar,
58
59
  SpamResultsOffer,
59
60
  setBriefCategoryInQuery,
@@ -185,6 +186,8 @@ export interface BriefSelectionScope {
185
186
  accountId?: string;
186
187
  /** Source folder, when every selected row shares one. */
187
188
  mailboxId?: string;
189
+ /** Which scope the selection spans more of than those verbs can take. */
190
+ restriction?: SelectionRestriction;
188
191
  /** Why folder-scoped verbs are withheld, in the toolbar's own words. */
189
192
  moveDisabledHint?: string;
190
193
  }
@@ -214,6 +217,7 @@ export const resolveBriefSelectionScope = (
214
217
  }
215
218
  if (accountIds.size > 1) {
216
219
  return {
220
+ restriction: "spansAccounts",
217
221
  moveDisabledHint:
218
222
  "Move only works within one account — clear selection or pick messages from a single account",
219
223
  };
@@ -222,6 +226,7 @@ export const resolveBriefSelectionScope = (
222
226
  if (mailboxIds.size > 1) {
223
227
  return {
224
228
  accountId,
229
+ restriction: "spansFolders",
225
230
  moveDisabledHint: `Move only works within one folder — this selection spans ${mailboxIds.size} folders`,
226
231
  };
227
232
  }
@@ -362,7 +367,7 @@ function BriefSelectionChrome({
362
367
  accountId={scope.accountId}
363
368
  mailboxId={scope.mailboxId}
364
369
  selection={wizardSelection}
365
- crossAccount={scope.moveDisabledHint !== undefined}
370
+ selectionRestriction={scope.restriction}
366
371
  onFinished={exitSelection}
367
372
  />
368
373
  }
@@ -41,6 +41,7 @@ import { FileText, Inbox, Trash2 } from "lucide-react";
41
41
  import { useMemo } from "react";
42
42
  import { useCompose } from "@/components/compose/ComposeProvider";
43
43
  import { groupDraftSections } from "@/lib/drafts";
44
+ import { retainOpenPanels } from "@/routing";
44
45
  import { NavMenuButton } from "./NavMenuButton";
45
46
 
46
47
  // ---------------------------------------------------------------------------
@@ -197,6 +198,7 @@ export function DraftsView({
197
198
  to: "/mail/$mailboxId/$threadId/$messageId",
198
199
  params: { mailboxId, threadId, messageId },
199
200
  search: (prev) => prev,
201
+ hash: retainOpenPanels,
200
202
  });
201
203
  };
202
204
 
@@ -95,7 +95,7 @@ function StarredWizardHost({
95
95
  <SelectionWizardHost
96
96
  verb={verb}
97
97
  selection={selection}
98
- crossAccount
98
+ selectionRestriction="spansAccounts"
99
99
  onFinished={exitSelection}
100
100
  />
101
101
  );
@@ -52,7 +52,11 @@ import {
52
52
  } from "@/hooks/useTriageLayer";
53
53
  import type { ConversationTarget } from "@/lib/conversation-target";
54
54
  import { useMailContext } from "@/lib/mail-context";
55
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
55
+ import {
56
+ type OpenThreadPath,
57
+ type OpenThreadTarget,
58
+ retainOpenPanels,
59
+ } from "@/routing";
56
60
 
57
61
  /* ------------------------------------------------------------------ */
58
62
  /* Context */
@@ -154,13 +158,18 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
154
158
  // `searchInput`: a row can be tapped before the debounce settles, when
155
159
  // the committed query is still empty.
156
160
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
161
+ hash: retainOpenPanels,
157
162
  });
158
163
  },
159
164
  [navigate, searchInput],
160
165
  );
161
166
 
162
167
  const handleCloseThread = useCallback(() => {
163
- navigate({ to: "/mail/flagged", search: (prev) => prev });
168
+ navigate({
169
+ to: "/mail/flagged",
170
+ search: (prev) => prev,
171
+ hash: retainOpenPanels,
172
+ });
164
173
  }, [navigate]);
165
174
 
166
175
  const handleDeselectIfRemoved = useCallback(
@@ -108,7 +108,6 @@ import {
108
108
  inboxFilterParams,
109
109
  sameInboxFilter,
110
110
  } from "@/lib/inbox-filters";
111
- import { readIntelligencePref } from "@/lib/intelligence-pref";
112
111
  import { junkDestination } from "@/lib/junk-destination";
113
112
  import { useMailContext } from "@/lib/mail-context";
114
113
  import { useMailFreshness } from "@/lib/mail-freshness";
@@ -125,7 +124,11 @@ import {
125
124
  applyResidualTokens,
126
125
  threadSearchTokens,
127
126
  } from "@/lib/thread-search-tokens";
128
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
127
+ import {
128
+ type OpenThreadPath,
129
+ type OpenThreadTarget,
130
+ retainOpenPanels,
131
+ } from "@/routing";
129
132
  import { MailViewChrome } from "./MailViewChrome";
130
133
 
131
134
  /* ------------------------------------------------------------------ */
@@ -263,16 +266,9 @@ function MailboxPaneProvider({
263
266
  const navigate = useNavigate();
264
267
  const threadId = thread?.threadId;
265
268
  const pointedAtMessageId = thread?.messageId;
266
- const tier = useLayoutTier();
267
- const isDesktop = tier === "desktop";
268
269
  const telemetry = useTelemetry();
269
- const {
270
- accounts,
271
- searchQuery,
272
- intelligenceOpen,
273
- onToggleIntelligence,
274
- onSetIntelligenceOpen,
275
- } = useMailContext();
270
+ const { accounts, searchQuery, intelligenceOpen, onToggleIntelligence } =
271
+ useMailContext();
276
272
  const tokenContext = useSearchTokenContext();
277
273
 
278
274
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
@@ -471,6 +467,7 @@ function MailboxPaneProvider({
471
467
  to: "/mail/$mailboxId",
472
468
  params: { mailboxId },
473
469
  search: (prev) => prev,
470
+ hash: retainOpenPanels,
474
471
  });
475
472
  }, [mailboxId, navigate]);
476
473
 
@@ -480,6 +477,7 @@ function MailboxPaneProvider({
480
477
  to: "/mail/$mailboxId/$threadId/$messageId",
481
478
  params: { mailboxId, ...target },
482
479
  search: (prev) => prev,
480
+ hash: retainOpenPanels,
483
481
  });
484
482
  },
485
483
  [mailboxId, navigate],
@@ -535,23 +533,6 @@ function MailboxPaneProvider({
535
533
  onToggleIntelligence,
536
534
  ]);
537
535
 
538
- // Desktop default-open (#782)
539
- const appliedDefaultRef = useRef(false);
540
- useEffect(() => {
541
- if (appliedDefaultRef.current) return;
542
- if (!isDesktop) return;
543
- if (!selectedThread?.messageId) return;
544
- appliedDefaultRef.current = true;
545
- if (readIntelligencePref() && !intelligenceOpen) {
546
- onSetIntelligenceOpen(true);
547
- }
548
- }, [
549
- isDesktop,
550
- selectedThread?.messageId,
551
- intelligenceOpen,
552
- onSetIntelligenceOpen,
553
- ]);
554
-
555
536
  // The mailbox's own unseen total. A count over the loaded pages undercounts
556
537
  // every mailbox larger than one page and creeps upward as the user scrolls,
557
538
  // so there is no fallback: until the mailbox resolves there is no number.
@@ -1048,6 +1029,7 @@ function MailboxList() {
1048
1029
  // `searchInput`: a row can be tapped before the debounce settles, when
1049
1030
  // the committed query is still empty.
1050
1031
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
1032
+ hash: retainOpenPanels,
1051
1033
  });
1052
1034
  },
1053
1035
  [mailboxId, navigate, searchInput, threads],
@@ -35,7 +35,7 @@ import {
35
35
  type ThreadListSnapshotEntry,
36
36
  threadListCacheKeys,
37
37
  } from "@/lib/thread-list-cache";
38
- import { useOpenThreadPath } from "@/routing";
38
+ import { retainOpenPanels, useOpenThreadPath } from "@/routing";
39
39
  import { MoveToTrigger } from "./MoveToTrigger";
40
40
 
41
41
  interface ThreadMessagesData {
@@ -193,6 +193,7 @@ export const MessageActionMenu = ({
193
193
  to: "/mail/$mailboxId",
194
194
  params: { mailboxId },
195
195
  search: (prev) => prev,
196
+ hash: retainOpenPanels,
196
197
  });
197
198
  },
198
199
  [selectedMessageId, mailboxId, navigate],
@@ -53,6 +53,7 @@ import { listVerbRequest } from "@/lib/list-verb-request";
53
53
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
54
54
  import { cn } from "@/lib/utils";
55
55
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
56
+ import { retainOpenPanels } from "@/routing";
56
57
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
57
58
  import {
58
59
  type EscalatedSelection,
@@ -634,6 +635,7 @@ export const MessageList = ({
634
635
  to: "/mail/$mailboxId/$threadId/$messageId",
635
636
  params: { mailboxId, threadId, messageId },
636
637
  search: (prev) => prev,
638
+ hash: retainOpenPanels,
637
639
  replace: options?.replace,
638
640
  });
639
641
  },
@@ -1349,7 +1351,7 @@ export const MessageList = ({
1349
1351
  accountId={accountId}
1350
1352
  mailboxId={mailboxId}
1351
1353
  selection={wizardSelection}
1352
- crossAccount={moveDisabledHint !== undefined}
1354
+ selectionRestriction={moveDisabledHint ? "spansAccounts" : undefined}
1353
1355
  escalated={escalatedSelection}
1354
1356
  escalatedProgress={escalation.progress}
1355
1357
  onFinished={exitSelection}
@@ -63,6 +63,7 @@ import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
63
63
  import { normalizeSearchQuery } from "@/lib/search-query";
64
64
  import { parseSearchTokens } from "@/lib/search-tokens";
65
65
  import { cn } from "@/lib/utils";
66
+ import { retainOpenPanels } from "@/routing";
66
67
 
67
68
  /* ------------------------------------------------------------------ */
68
69
  /* Helpers (shared between List, Reading, Phone sub-views) */
@@ -218,13 +219,18 @@ function OutboxPaneProvider({
218
219
  to: "/mail/outbox/draft/$outboxMessageId",
219
220
  params: { outboxMessageId },
220
221
  search: (prev) => prev,
222
+ hash: retainOpenPanels,
221
223
  });
222
224
  },
223
225
  [navigate],
224
226
  );
225
227
 
226
228
  const handleCloseMessage = useCallback(() => {
227
- navigate({ to: "/mail/outbox", search: (prev) => prev });
229
+ navigate({
230
+ to: "/mail/outbox",
231
+ search: (prev) => prev,
232
+ hash: retainOpenPanels,
233
+ });
228
234
  }, [navigate]);
229
235
 
230
236
  const ctx: OutboxPaneContextValue = {
@@ -2,8 +2,6 @@ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@t
2
2
  import {
3
3
  type ClauseDraft,
4
4
  type ClauseEditState,
5
- crossAccountDestinationReason,
6
- crossAccountRuleReason,
7
5
  derivePropertyClauses,
8
6
  deriveSenderClauses,
9
7
  dominantSender,
@@ -14,6 +12,7 @@ import {
14
12
  type RuleClause,
15
13
  type RunState,
16
14
  type SearchConversion,
15
+ type SelectionRestriction,
17
16
  SelectionWizard,
18
17
  type StepId,
19
18
  searchConversionNotice,
@@ -25,6 +24,7 @@ import {
25
24
  type Verb,
26
25
  type WizardDraft,
27
26
  type WizardMessage,
27
+ wizardScopeFor,
28
28
  } from "@remit/ui";
29
29
  import { useQuery } from "@tanstack/react-query";
30
30
  import { useBlocker } from "@tanstack/react-router";
@@ -125,8 +125,12 @@ export interface SelectionWizardHostProps {
125
125
  /** Where the selection was made, so a run invalidates the listing it changed. */
126
126
  mailboxId?: string;
127
127
  selection: readonly WizardSelectionMessage[];
128
- /** The ticked rows span more than one account, so no rule can be created. */
129
- crossAccount?: boolean;
128
+ /**
129
+ * Which scope the ticked rows span more of than the folder and rule steps can
130
+ * take. The surface that resolved the selection knows which of the two it is,
131
+ * and the wizard states that one (#525).
132
+ */
133
+ selectionRestriction?: SelectionRestriction;
130
134
  /** Present when the selection is a predicate rather than the ticked rows. */
131
135
  escalated?: EscalatedSelection;
132
136
  /**
@@ -257,7 +261,7 @@ function SelectionWizardSession({
257
261
  accountId,
258
262
  mailboxId,
259
263
  selection,
260
- crossAccount = false,
264
+ selectionRestriction,
261
265
  escalated: escalatedSelection,
262
266
  escalatedProgress,
263
267
  searchConversion,
@@ -451,18 +455,16 @@ function SelectionWizardSession({
451
455
 
452
456
  // A filter, a folder, and the preview that counts a widened door all belong to
453
457
  // one account, so a selection spanning accounts reaches none of them and is
454
- // told so on the step that asks (#477 5.5, #523). The one-off scope and the
455
- // ticked rows act on the messages themselves and are unaffected.
456
- const accountScoped = !crossAccount && !!accountId;
457
- const ruleRestriction = accountScoped ? undefined : crossAccountRuleReason;
458
- const folderRestriction = accountScoped
459
- ? undefined
460
- : crossAccountDestinationReason;
458
+ // told so on the step that asks (#477 5.5, #523). A selection spanning folders
459
+ // of one account is told about folders instead, and keeps its account: the
460
+ // preview behind a widened door has something to count against. The one-off
461
+ // scope and the ticked rows act on the messages themselves and are unaffected.
462
+ const wizardScope = wizardScopeFor(accountId, selectionRestriction);
461
463
  const restrictionFor = (step: StepId): string | undefined => {
462
- if (step === "folder") return folderRestriction;
464
+ if (step === "folder") return wizardScope.destination;
463
465
  if (step !== "rule") return undefined;
464
466
  return named.scope === "standing" || named.scope === "until"
465
- ? ruleRestriction
467
+ ? wizardScope.rule
466
468
  : undefined;
467
469
  };
468
470
  const blockedReason =
@@ -940,7 +942,7 @@ function SelectionWizardSession({
940
942
  match={{
941
943
  selectedCount: selection.length,
942
944
  mode,
943
- accountId: accountScoped ? accountId : undefined,
945
+ accountId: wizardScope.accountId,
944
946
  onModeChange: changeMode,
945
947
  semanticUnavailable,
946
948
  semanticErrorDetail:
@@ -977,13 +979,13 @@ function SelectionWizardSession({
977
979
  onSelect: (moveMailboxId) =>
978
980
  setDraft((held) => ({ ...held, moveMailboxId })),
979
981
  onCreateFolder: accountId ? createFolderIn : undefined,
980
- restriction: folderRestriction,
982
+ restriction: wizardScope.destination,
981
983
  }}
982
984
  rule={{
983
985
  draft: named,
984
986
  onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
985
987
  onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
986
- restriction: ruleRestriction,
988
+ restriction: wizardScope.rule,
987
989
  }}
988
990
  name={{
989
991
  name: named.name ?? "",
@@ -1056,7 +1058,7 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
1056
1058
  accounts,
1057
1059
  ),
1058
1060
  selection: EMPTY_SELECTION,
1059
- crossAccount: false,
1061
+ selectionRestriction: undefined,
1060
1062
  escalated: undefined,
1061
1063
  searchConversion: conversion,
1062
1064
  }
@@ -10,6 +10,7 @@ import { useNavigate } from "@tanstack/react-router";
10
10
  import { useCallback, useState } from "react";
11
11
  import { toDisplayCategory } from "@/lib/display-category";
12
12
  import { formatEmailDate } from "@/lib/format";
13
+ import { retainOpenPanels } from "@/routing";
13
14
  import { MessageListItem } from "./MessageListItem";
14
15
  import { useModifierSelect } from "./useModifierSelect";
15
16
 
@@ -105,6 +106,7 @@ export const SwipeableMessageRow = ({
105
106
  messageId: thread.messageId,
106
107
  },
107
108
  search: (prev) => prev,
109
+ hash: retainOpenPanels,
108
110
  });
109
111
  }, [navigate, mailboxId, thread.threadId, thread.messageId]);
110
112
 
@@ -22,6 +22,8 @@ export const KeyboardShortcutsModal = ({
22
22
  (event: KeyboardEvent) => {
23
23
  if (event.key === "Escape") {
24
24
  event.preventDefault();
25
+ event.stopPropagation();
26
+ event.stopImmediatePropagation();
25
27
  onClose();
26
28
  }
27
29
  },
@@ -30,8 +32,11 @@ export const KeyboardShortcutsModal = ({
30
32
 
31
33
  useEffect(() => {
32
34
  if (!isOpen) return;
33
- window.addEventListener("keydown", handleKeyDown);
34
- return () => window.removeEventListener("keydown", handleKeyDown);
35
+ // Capture phase, as `ConfirmDialog` does: the sheet is the topmost surface,
36
+ // so Escape dismisses it and nothing else. Shared with the list's own
37
+ // Escape, the one keystroke also closed the conversation underneath.
38
+ window.addEventListener("keydown", handleKeyDown, true);
39
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
35
40
  }, [isOpen, handleKeyDown]);
36
41
 
37
42
  if (!isOpen) return null;
@@ -65,7 +65,6 @@ const mailContext = (
65
65
  onSearchClearQuery: () => {},
66
66
  intelligenceOpen: false,
67
67
  onToggleIntelligence: () => {},
68
- onSetIntelligenceOpen: () => {},
69
68
  });
70
69
 
71
70
  type ListPath = "/mail/brief" | "/mail/flagged";
@@ -71,16 +71,24 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
71
71
  ...prev,
72
72
  q: committedQuery || undefined,
73
73
  });
74
+ // A query is a mode of the view the reader is already in, so the panels
75
+ // they have up are not ones they navigated away from.
74
76
  if (!queryGoesActive) {
75
- navigate({ to: ".", search, replace: true });
77
+ navigate({ to: ".", search, hash: true, replace: true });
76
78
  return;
77
79
  }
78
80
  if (to === "/mail/$mailboxId") {
79
81
  if (!mailboxId) return;
80
- navigate({ to, params: { mailboxId }, search, replace: true });
82
+ navigate({
83
+ to,
84
+ params: { mailboxId },
85
+ search,
86
+ hash: true,
87
+ replace: true,
88
+ });
81
89
  return;
82
90
  }
83
- navigate({ to, search, replace: true });
91
+ navigate({ to, search, hash: true, replace: true });
84
92
  }, [
85
93
  searchInput,
86
94
  committedQuery,
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
3
  import {
4
4
  readIntelligencePref,
5
+ resolveRailOpen,
5
6
  writeIntelligencePref,
6
7
  } from "./intelligence-pref.js";
7
8
 
@@ -55,3 +56,79 @@ describe("intelligence-pref (#782)", () => {
55
56
  assert.doesNotThrow(() => writeIntelligencePref(false));
56
57
  });
57
58
  });
59
+
60
+ describe("resolveRailOpen (#722)", () => {
61
+ const withThread = { hasThread: true, isDesktop: true };
62
+
63
+ it("opens the rail with the thread where the address says nothing", () => {
64
+ assert.equal(
65
+ resolveRailOpen({ ...withThread, panels: [], prefersOpen: true }),
66
+ true,
67
+ );
68
+ });
69
+
70
+ it("leaves the rail down where the address says nothing and the reader collapsed it", () => {
71
+ assert.equal(
72
+ resolveRailOpen({ ...withThread, panels: [], prefersOpen: false }),
73
+ false,
74
+ );
75
+ });
76
+
77
+ it("never seeds the rail below the tier that has one", () => {
78
+ assert.equal(
79
+ resolveRailOpen({
80
+ panels: [],
81
+ prefersOpen: true,
82
+ isDesktop: false,
83
+ hasThread: true,
84
+ }),
85
+ false,
86
+ );
87
+ });
88
+
89
+ // Otherwise the address claims a pane the shell has nothing to put in it.
90
+ it("never seeds the rail with no conversation open", () => {
91
+ assert.equal(
92
+ resolveRailOpen({
93
+ panels: [],
94
+ prefersOpen: true,
95
+ isDesktop: true,
96
+ hasThread: false,
97
+ }),
98
+ false,
99
+ );
100
+ });
101
+
102
+ // A shared link naming another panel is an address that has spoken: the
103
+ // recipient's own preference does not get to add the rail to it.
104
+ it("hands a shared link's panels to the reader who opened it", () => {
105
+ assert.equal(
106
+ resolveRailOpen({
107
+ ...withThread,
108
+ panels: ["shortcuts"],
109
+ prefersOpen: true,
110
+ }),
111
+ false,
112
+ );
113
+ assert.equal(
114
+ resolveRailOpen({
115
+ ...withThread,
116
+ panels: ["intelligence", "shortcuts"],
117
+ prefersOpen: false,
118
+ }),
119
+ true,
120
+ );
121
+ });
122
+
123
+ it("opens the rail from an address on any tier", () => {
124
+ assert.equal(
125
+ resolveRailOpen({
126
+ panels: ["intelligence"],
127
+ prefersOpen: false,
128
+ isDesktop: false,
129
+ hasThread: true,
130
+ }),
131
+ true,
132
+ );
133
+ });
134
+ });
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Persistence for the intelligence-pane open/closed preference (#782).
2
+ * Persistence for the intelligence-pane open/closed preference (#782), and the
3
+ * precedence between it and the address (#722).
3
4
  *
4
5
  * The pane opens with the thread by default; a manual collapse sticks across
5
6
  * sessions. Storage failures (private mode / quota) fall back to the default
6
7
  * (open) rather than crashing.
7
8
  */
9
+ import type { PanelFragment } from "@/routing";
8
10
 
9
11
  export const INTELLIGENCE_PREF_KEY = "remit:intelligence-open";
10
12
 
@@ -23,3 +25,33 @@ export function writeIntelligencePref(open: boolean): void {
23
25
  // Storage unavailable — the in-memory default stands.
24
26
  }
25
27
  }
28
+
29
+ export interface RailVisibility {
30
+ /** The panels the address names. */
31
+ panels: readonly PanelFragment[];
32
+ prefersOpen: boolean;
33
+ /** The rail is a pane of the desktop shell; narrower tiers have a drawer. */
34
+ isDesktop: boolean;
35
+ /** The rail reads a conversation, so with none open there is nothing to be up. */
36
+ hasThread: boolean;
37
+ }
38
+
39
+ /**
40
+ * Whether the rail is up.
41
+ *
42
+ * An address that names any panel is the only owner of what is open, so a
43
+ * shared link showing the shortcuts sheet is not overwritten by the recipient's
44
+ * own preference. The preference speaks only where the address is silent, and
45
+ * only with a conversation open on the tier that has a rail — it opens with the
46
+ * thread there (#782), while a phone would get a full-screen drawer over a
47
+ * message nobody asked to cover.
48
+ */
49
+ export function resolveRailOpen({
50
+ panels,
51
+ prefersOpen,
52
+ isDesktop,
53
+ hasThread,
54
+ }: RailVisibility): boolean {
55
+ if (panels.length > 0) return panels.includes("intelligence");
56
+ return isDesktop && hasThread && prefersOpen;
57
+ }
@@ -47,13 +47,14 @@ export interface MailContextValue {
47
47
  onSearchClear: () => void;
48
48
  /** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
49
49
  onSearchClearQuery: () => void;
50
- /** Pane 4 (intelligence) visibility. The shared toggle starts closed; the
51
- * desktop route opens it by default with the thread, honouring the stored
52
- * preference (#782). */
50
+ /**
51
+ * Whether pane 4 (intelligence) is up. Resolved once by the `/mail` layout
52
+ * from the address and this device's preference (`resolveRailOpen`), and
53
+ * handed down as the answer — a consumer re-deriving it from the fragment
54
+ * alone would lose the preference and disagree with the shell.
55
+ */
53
56
  intelligenceOpen: boolean;
54
57
  onToggleIntelligence: () => void;
55
- /** Set the pane open/closed and persist the choice (desktop default-open). */
56
- onSetIntelligenceOpen: (open: boolean) => void;
57
58
  }
58
59
 
59
60
  export const MailContext = createContext<MailContextValue | null>(null);
@@ -77,7 +78,6 @@ export const useMailContext = (): MailContextValue => {
77
78
  onSearchClearQuery: () => {},
78
79
  intelligenceOpen: false,
79
80
  onToggleIntelligence: () => {},
80
- onSetIntelligenceOpen: () => {},
81
81
  }
82
82
  );
83
83
  };
@@ -27,13 +27,24 @@ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
27
27
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
28
28
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
29
29
  import { hostsComposeSurface } from "@/lib/compose-routes";
30
- import { writeIntelligencePref } from "@/lib/intelligence-pref";
30
+ import {
31
+ readIntelligencePref,
32
+ resolveRailOpen,
33
+ writeIntelligencePref,
34
+ } from "@/lib/intelligence-pref";
31
35
  import { MailContext } from "@/lib/mail-context";
32
36
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
33
37
  import { mailViewKey } from "@/lib/mail-route";
34
38
  import { buildAccountNameIndex } from "@/lib/search-token-index";
35
39
  import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
36
40
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
41
+ import {
42
+ isOverlayPanel,
43
+ type OverlayPanel,
44
+ useOpenPanels,
45
+ useOpenThreadPath,
46
+ useSetOpenPanels,
47
+ } from "@/routing";
37
48
  import "@/lib/client";
38
49
 
39
50
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -79,19 +90,56 @@ function MailLayout() {
79
90
  // tablet with no compose surface (compose lives in the reading pane, which
80
91
  // tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
81
92
  const isSinglePane = isSinglePaneTier(tier);
82
- const [showShortcuts, setShowShortcuts] = useState(false);
83
- const [drawerOpen, setDrawerOpen] = useState(false);
84
- // Pane 4 / the mobile details drawer share this toggle. It starts closed so
85
- // the phone never slams a full-screen intelligence drawer over a freshly
86
- // opened thread; the DESKTOP route opens it by default with the thread (the
87
- // intelligence rail is the product's core value) and honours the persisted
88
- // collapse preference there (#782). DKIM-mismatch auto-open still fires on
89
- // every tier. Explicit toggles persist the user's choice.
90
- const [intelligenceOpen, setIntelligenceOpen] = useState(false);
91
- const handleSetIntelligenceOpen = useCallback((open: boolean) => {
92
- setIntelligenceOpen(open);
93
- writeIntelligencePref(open);
94
- }, []);
93
+ // The panels the address carries (#722): the intelligence rail, the nav
94
+ // slide-over and the shortcuts sheet. The rail is a pane and the other two
95
+ // cover it, so the address holds a pane and an overlay at once — a sheet
96
+ // opening never takes the rail down while two overlays cannot both be up.
97
+ const openPanels = useOpenPanels();
98
+ const setOpenPanels = useSetOpenPanels();
99
+ const openOverlay = openPanels.find(isOverlayPanel);
100
+ const showShortcuts = openOverlay === "shortcuts";
101
+ const drawerOpen = openOverlay === "nav";
102
+ // Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
103
+ // one place the address and the stored preference meet: the address decides
104
+ // whenever it says anything at all, and the preference opens the rail with
105
+ // the thread where it is silent (#782).
106
+ const openThread = useOpenThreadPath();
107
+ // Held in state, not read back from storage each render: closing the rail
108
+ // where the address is silent changes nothing about the address, and the
109
+ // answer has to move anyway.
110
+ const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
111
+ const intelligenceOpen = resolveRailOpen({
112
+ panels: openPanels,
113
+ prefersOpen: prefersRail,
114
+ isDesktop: tier === "desktop",
115
+ hasThread: openThread !== undefined,
116
+ });
117
+ // Every write states the whole set, because it is composed from what is
118
+ // showing rather than from what the address happens to spell: the rail open
119
+ // by preference alone is still open, and an overlay must not close it.
120
+ const showPanels = useCallback(
121
+ (rail: boolean, overlay: OverlayPanel | undefined) => {
122
+ setOpenPanels([
123
+ ...(rail ? (["intelligence"] as const) : []),
124
+ ...(overlay ? [overlay] : []),
125
+ ]);
126
+ },
127
+ [setOpenPanels],
128
+ );
129
+ const handleSetIntelligenceOpen = useCallback(
130
+ (open: boolean) => {
131
+ writeIntelligencePref(open);
132
+ setPrefersRail(open);
133
+ showPanels(open, openOverlay);
134
+ },
135
+ [openOverlay, showPanels],
136
+ );
137
+ const showOverlay = useCallback(
138
+ (overlay: OverlayPanel | undefined) => {
139
+ showPanels(intelligenceOpen, overlay);
140
+ },
141
+ [intelligenceOpen, showPanels],
142
+ );
95
143
 
96
144
  // Within one view, URL `q` seeds the input and is a one-directional write
97
145
  // target: the debounced local value drives the search API and is mirrored
@@ -157,7 +205,7 @@ function MailLayout() {
157
205
  bindings: [
158
206
  {
159
207
  key: "?",
160
- handler: () => setShowShortcuts(true),
208
+ handler: () => showOverlay("shortcuts"),
161
209
  noModifiers: false, // Allow shift+/
162
210
  preventDefault: true,
163
211
  },
@@ -193,12 +241,8 @@ function MailLayout() {
193
241
  }, []);
194
242
 
195
243
  const handleToggleIntelligence = useCallback(() => {
196
- setIntelligenceOpen((open) => {
197
- const next = !open;
198
- writeIntelligencePref(next);
199
- return next;
200
- });
201
- }, []);
244
+ handleSetIntelligenceOpen(!intelligenceOpen);
245
+ }, [handleSetIntelligenceOpen, intelligenceOpen]);
202
246
 
203
247
  const accounts = config?.accounts ?? [];
204
248
  const accountIds = useMemo(
@@ -218,11 +262,12 @@ function MailLayout() {
218
262
  useStaleAccountSync(accounts);
219
263
 
220
264
  const handleMailboxSelect = useCallback(() => {
221
- // Auto-collapse the mobile drawer after the user picks an inbox
222
- // from the sidebar (#199). Desktop sidebar isn't a drawer so the
223
- // noop is fine there.
224
- setDrawerOpen(false);
225
- }, []);
265
+ // Auto-collapse the mobile drawer after the user picks an inbox from the
266
+ // sidebar (#199). Above the slide-over the sidebar is a pane, and there is
267
+ // no overlay of its own to take down.
268
+ if (openOverlay !== "nav") return;
269
+ showOverlay(undefined);
270
+ }, [openOverlay, showOverlay]);
226
271
 
227
272
  const mailContextValue = {
228
273
  accounts,
@@ -239,7 +284,6 @@ function MailLayout() {
239
284
  onSearchClearQuery: handleSearchClearQuery,
240
285
  intelligenceOpen,
241
286
  onToggleIntelligence: handleToggleIntelligence,
242
- onSetIntelligenceOpen: handleSetIntelligenceOpen,
243
287
  };
244
288
 
245
289
  // Single nav node: the kit renders it as a pane (≥1024px) or inside its
@@ -259,8 +303,8 @@ function MailLayout() {
259
303
  // top bar owns compose.
260
304
  overlay: isSinglePane ? <ComposeFab /> : undefined,
261
305
  navOpen: drawerOpen,
262
- onOpenNav: () => setDrawerOpen(true),
263
- onCloseNav: () => setDrawerOpen(false),
306
+ onOpenNav: () => showOverlay("nav"),
307
+ onCloseNav: () => showOverlay(undefined),
264
308
  };
265
309
 
266
310
  return (
@@ -284,7 +328,7 @@ function MailLayout() {
284
328
  )}
285
329
  <KeyboardShortcutsModal
286
330
  isOpen={showShortcuts}
287
- onClose={() => setShowShortcuts(false)}
331
+ onClose={() => showOverlay(undefined)}
288
332
  />
289
333
  </MailFreshnessProvider>
290
334
  </MailContext.Provider>
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ formatOpenPanels,
5
+ panelFragments,
6
+ parseOpenPanels,
7
+ retainOpenPanels,
8
+ } from "./fragment.js";
9
+
10
+ describe("parseOpenPanels", () => {
11
+ it("reads every panel the union names", () => {
12
+ for (const panel of panelFragments) {
13
+ assert.deepEqual(parseOpenPanels(panel), [panel]);
14
+ }
15
+ });
16
+
17
+ it("reads a pane and an overlay together", () => {
18
+ assert.deepEqual(parseOpenPanels("intelligence,shortcuts"), [
19
+ "intelligence",
20
+ "shortcuts",
21
+ ]);
22
+ });
23
+
24
+ it("spells one set of panels one way", () => {
25
+ assert.deepEqual(parseOpenPanels("shortcuts,intelligence,shortcuts"), [
26
+ "intelligence",
27
+ "shortcuts",
28
+ ]);
29
+ });
30
+
31
+ it("reads an empty, unknown or mis-cased fragment as no panel", () => {
32
+ assert.deepEqual(parseOpenPanels(""), []);
33
+ assert.deepEqual(parseOpenPanels("confirm-delete"), []);
34
+ assert.deepEqual(parseOpenPanels("Intelligence"), []);
35
+ assert.deepEqual(parseOpenPanels("form:input:to"), []);
36
+ });
37
+
38
+ it("keeps the panels it recognises out of a fragment carrying junk", () => {
39
+ assert.deepEqual(parseOpenPanels("filters,intelligence"), ["intelligence"]);
40
+ });
41
+ });
42
+
43
+ describe("formatOpenPanels", () => {
44
+ it("writes the address a set of panels is read back from", () => {
45
+ assert.equal(
46
+ formatOpenPanels(["shortcuts", "intelligence"]),
47
+ "intelligence,shortcuts",
48
+ );
49
+ assert.equal(formatOpenPanels([]), "");
50
+ });
51
+ });
52
+
53
+ describe("retainOpenPanels", () => {
54
+ it("carries a pane across a navigation", () => {
55
+ assert.equal(retainOpenPanels("intelligence"), "intelligence");
56
+ });
57
+
58
+ it("leaves the overlays behind, and keeps the pane under them", () => {
59
+ assert.equal(retainOpenPanels("nav"), "");
60
+ assert.equal(retainOpenPanels("shortcuts"), "");
61
+ assert.equal(retainOpenPanels("intelligence,shortcuts"), "intelligence");
62
+ });
63
+
64
+ it("drops a fragment it does not recognise rather than passing it on", () => {
65
+ assert.equal(retainOpenPanels(), "");
66
+ assert.equal(retainOpenPanels("confirm-delete"), "");
67
+ });
68
+ });
@@ -1,4 +1,5 @@
1
- import { useLocation } from "@tanstack/react-router";
1
+ import { useLocation, useNavigate } from "@tanstack/react-router";
2
+ import { useCallback, useMemo } from "react";
2
3
  import { z } from "zod";
3
4
 
4
5
  /**
@@ -7,29 +8,91 @@ import { z } from "zod";
7
8
  * independent of in-memory data, so a cold load of the URL reproduces it
8
9
  * exactly.
9
10
  */
10
- export const panelFragments = [
11
- "intelligence",
12
- "nav",
13
- "shortcuts",
14
- "filters",
15
- ] as const;
11
+ export const panelFragments = ["intelligence", "nav", "shortcuts"] as const;
16
12
 
17
13
  export type PanelFragment = (typeof panelFragments)[number];
18
14
 
15
+ /**
16
+ * The panels that cover the view rather than sit in it. The rail is a pane in
17
+ * the shell's row and the other two are modal over it, so a sheet opening can
18
+ * never take the rail down with it — the address holds the pane and the overlay
19
+ * at once. Two overlays cannot be up together, because the second one hides the
20
+ * first.
21
+ */
22
+ export const overlayPanels = ["nav", "shortcuts"] as const;
23
+
24
+ export type OverlayPanel = (typeof overlayPanels)[number];
25
+
26
+ export const isOverlayPanel = (panel: PanelFragment): panel is OverlayPanel =>
27
+ (overlayPanels as readonly PanelFragment[]).includes(panel);
28
+
19
29
  const panelFragmentSchema = z.enum(panelFragments);
20
30
 
21
31
  /**
22
- * A hand-edited or stale URL is a normal thing to receive, so an unrecognised
23
- * fragment reads as "no panel open" instead of throwing.
32
+ * The panels the address asks for, in the union's own order so one set of open
33
+ * panels has one spelling. A hand-edited or stale URL is a normal thing to
34
+ * receive, so an unrecognised name is dropped instead of throwing.
24
35
  */
25
- export function parsePanelFragment(hash: string): PanelFragment | undefined {
26
- const parsed = panelFragmentSchema.safeParse(hash);
27
- return parsed.success ? parsed.data : undefined;
36
+ export function parseOpenPanels(hash: string): readonly PanelFragment[] {
37
+ const named = new Set(
38
+ hash
39
+ .split(",")
40
+ .map((token) => panelFragmentSchema.safeParse(token))
41
+ .filter((parsed) => parsed.success)
42
+ .map((parsed) => parsed.data),
43
+ );
44
+ return panelFragments.filter((panel) => named.has(panel));
45
+ }
46
+
47
+ /** The address a set of open panels is written as. */
48
+ export function formatOpenPanels(panels: readonly PanelFragment[]): string {
49
+ return panelFragments.filter((panel) => panels.includes(panel)).join(",");
28
50
  }
29
51
 
30
- /** The panel the URL currently asks for, if any. */
31
- export function useOpenPanel(): PanelFragment | undefined {
32
- return useLocation({
33
- select: (location) => parsePanelFragment(location.hash),
52
+ /** The panels the address currently asks for. */
53
+ export function useOpenPanels(): readonly PanelFragment[] {
54
+ const hash = useLocation({
55
+ select: (location) => formatOpenPanels(parseOpenPanels(location.hash)),
34
56
  });
57
+ return useMemo(() => parseOpenPanels(hash), [hash]);
58
+ }
59
+
60
+ /**
61
+ * A destination's `hash`, for a navigation that keeps the reader where they
62
+ * are. The router drops the fragment on every navigation unless the destination
63
+ * asks for it, so this is what a link or a `navigate` call passes to state the
64
+ * rule rather than restate it: a pane is chrome a reader keeps up while they
65
+ * move from one conversation to the next, and an overlay is dismissed by going
66
+ * somewhere, which is what closing it means.
67
+ *
68
+ * The parameter is optional because the router hands a `hash` updater the
69
+ * previous fragment as `string | undefined`.
70
+ */
71
+ export function retainOpenPanels(hash = ""): string {
72
+ return formatOpenPanels(
73
+ parseOpenPanels(hash).filter((panel) => !isOverlayPanel(panel)),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Puts a set of panels up, replacing whatever was up before.
79
+ *
80
+ * `replace`, because a panel is chrome over the current view rather than a place
81
+ * to go: Back belongs to the message the reader came from, not to the rail they
82
+ * just collapsed. The fragment names panels and never an element, so it is not
83
+ * an anchor to scroll to either.
84
+ */
85
+ export function useSetOpenPanels(): (panels: readonly PanelFragment[]) => void {
86
+ const navigate = useNavigate();
87
+ return useCallback(
88
+ (panels: readonly PanelFragment[]) => {
89
+ navigate({
90
+ search: true,
91
+ hash: formatOpenPanels(panels),
92
+ replace: true,
93
+ hashScrollIntoView: false,
94
+ });
95
+ },
96
+ [navigate],
97
+ );
35
98
  }
@@ -1,8 +1,14 @@
1
1
  export {
2
+ formatOpenPanels,
3
+ isOverlayPanel,
4
+ type OverlayPanel,
5
+ overlayPanels,
2
6
  type PanelFragment,
3
7
  panelFragments,
4
- parsePanelFragment,
5
- useOpenPanel,
8
+ parseOpenPanels,
9
+ retainOpenPanels,
10
+ useOpenPanels,
11
+ useSetOpenPanels,
6
12
  } from "./fragment";
7
13
  export { NavLink, type NavLinkProps } from "./nav-link";
8
14
  export {
@@ -7,7 +7,7 @@ import type {
7
7
  } from "@tanstack/react-router";
8
8
  import { createLink } from "@tanstack/react-router";
9
9
  import type { ReactElement } from "react";
10
- import type { PanelFragment } from "./fragment";
10
+ import { type PanelFragment, retainOpenPanels } from "./fragment";
11
11
 
12
12
  const RouterNavLink = createLink(NavLinkSurface);
13
13
 
@@ -42,6 +42,10 @@ export type NavLinkProps<
42
42
  * The application's only navigation link. `createLink` keeps the router's `to` /
43
43
  * `params` / `search` inference, so a route that does not exist or a param set
44
44
  * that does not match it fails to compile instead of rendering a blank pane.
45
+ *
46
+ * A link that names no panel carries the open panes across: the rail is up
47
+ * until the reader puts it down, and every row in a list is a link. Overlays
48
+ * are left behind, because going somewhere is what dismisses one.
45
49
  */
46
50
  export function NavLink<
47
51
  TRouter extends AnyRouter = RegisteredRouter,
@@ -54,5 +58,5 @@ export function NavLink({
54
58
  fragment,
55
59
  ...props
56
60
  }: CreateLinkProps & { fragment?: PanelFragment }): ReactElement {
57
- return <RouterNavLink {...props} hash={fragment} />;
61
+ return <RouterNavLink {...props} hash={fragment ?? retainOpenPanels} />;
58
62
  }