@remit/web-client 0.0.15 → 0.0.16

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.15",
3
+ "version": "0.0.16",
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": {
@@ -14,7 +14,11 @@
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 { ReadingPaneEmpty, type SearchResult } from "@remit/ui";
17
+ import {
18
+ ReadingPaneEmpty,
19
+ type SearchResult,
20
+ useAppShellLayout,
21
+ } from "@remit/ui";
18
22
  import { useQuery } from "@tanstack/react-query";
19
23
  import { useNavigate, useSearch } from "@tanstack/react-router";
20
24
  import {
@@ -184,14 +188,20 @@ function BriefList() {
184
188
  */
185
189
  function BriefReading() {
186
190
  const { conversation } = useBriefPane();
187
- const { onToggleIntelligence } = useMailContext();
191
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
192
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
193
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
194
+ // open that cannot happen.
195
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
196
+ const hasThread = Boolean(conversation);
197
+ const canToggleIntelligence = railFits && hasThread;
188
198
 
189
199
  return (
190
200
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
191
201
  <MessageToolbar
192
- hasThread={Boolean(conversation)}
193
- intelligenceOpen={false}
194
- showIntelligenceToggle={false}
202
+ hasThread={hasThread}
203
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
204
+ canToggleIntelligence={canToggleIntelligence}
195
205
  onToggleIntelligence={onToggleIntelligence}
196
206
  />
197
207
  <div className="min-h-0 flex-1 overflow-hidden">
@@ -211,6 +221,24 @@ function BriefReading() {
211
221
  );
212
222
  }
213
223
 
224
+ /**
225
+ * Intelligence pane: IntelligencePane for the thread open in the brief.
226
+ * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
227
+ */
228
+ function BriefIntelligence() {
229
+ const { selectedThread } = useBriefPane();
230
+ const { onToggleIntelligence } = useMailContext();
231
+
232
+ return (
233
+ <IntelligencePane
234
+ onClose={onToggleIntelligence}
235
+ thread={selectedThread}
236
+ mailboxId={selectedThread?.mailboxId}
237
+ accountId={selectedThread?.accountId}
238
+ />
239
+ );
240
+ }
241
+
214
242
  /**
215
243
  * Phone view: ConversationView when thread is open, or the DailyBrief list.
216
244
  */
@@ -272,6 +300,7 @@ function BriefPhone() {
272
300
  const BriefPane = Object.assign(BriefPaneProvider, {
273
301
  List: BriefList,
274
302
  Reading: BriefReading,
303
+ Intelligence: BriefIntelligence,
275
304
  Phone: BriefPhone,
276
305
  });
277
306
 
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
19
19
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
20
- import { ReadingPaneEmpty } from "@remit/ui";
20
+ import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
21
21
  import { useQuery } from "@tanstack/react-query";
22
22
  import { useNavigate } from "@tanstack/react-router";
23
23
  import {
@@ -128,14 +128,20 @@ function FlaggedListSlot() {
128
128
  */
129
129
  function FlaggedReading() {
130
130
  const { selectedThread } = useFlaggedPane();
131
- const { onToggleIntelligence } = useMailContext();
131
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
132
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
133
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
134
+ // open that cannot happen.
135
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
136
+ const hasThread = Boolean(selectedThread);
137
+ const canToggleIntelligence = railFits && hasThread;
132
138
 
133
139
  return (
134
140
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
135
141
  <MessageToolbar
136
- hasThread={Boolean(selectedThread)}
137
- intelligenceOpen={false}
138
- showIntelligenceToggle={false}
142
+ hasThread={hasThread}
143
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
144
+ canToggleIntelligence={canToggleIntelligence}
139
145
  onToggleIntelligence={onToggleIntelligence}
140
146
  />
141
147
  <div className="min-h-0 flex-1 overflow-hidden">
@@ -154,6 +160,24 @@ function FlaggedReading() {
154
160
  );
155
161
  }
156
162
 
163
+ /**
164
+ * Intelligence pane: IntelligencePane for the open thread.
165
+ * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
166
+ */
167
+ function FlaggedIntelligence() {
168
+ const { selectedThread } = useFlaggedPane();
169
+ const { onToggleIntelligence } = useMailContext();
170
+
171
+ return (
172
+ <IntelligencePane
173
+ onClose={onToggleIntelligence}
174
+ thread={selectedThread}
175
+ mailboxId={selectedThread?.mailboxId}
176
+ accountId={selectedThread?.accountId}
177
+ />
178
+ );
179
+ }
180
+
157
181
  /** Phone view: ConversationView when a thread is open, else the flat list. */
158
182
  function FlaggedPhone() {
159
183
  const { selectedThread, onCloseThread } = useFlaggedPane();
@@ -200,6 +224,7 @@ function FlaggedPhone() {
200
224
  const FlaggedPane = Object.assign(FlaggedPaneProvider, {
201
225
  List: FlaggedListSlot,
202
226
  Reading: FlaggedReading,
227
+ Intelligence: FlaggedIntelligence,
203
228
  Phone: FlaggedPhone,
204
229
  });
205
230
 
@@ -32,6 +32,7 @@ import {
32
32
  ReadingPaneEmpty,
33
33
  type RescueCandidate,
34
34
  type SearchResult,
35
+ useAppShellLayout,
35
36
  } from "@remit/ui";
36
37
  import {
37
38
  keepPreviousData,
@@ -179,7 +180,6 @@ interface MailboxPaneContextValue {
179
180
  onSelectFilterCategory: (id: string) => void;
180
181
  onToggleFilterAttribute: (id: string) => void;
181
182
  onClearFilters: () => void;
182
- showIntelligence: boolean;
183
183
  intelligenceOpen: boolean;
184
184
  onToggleIntelligence: () => void;
185
185
  // List actions
@@ -782,13 +782,11 @@ function MailboxPaneProvider({
782
782
  }
783
783
  }, [normalizedSearchQuery, mailboxType, telemetry]);
784
784
 
785
- const hasThread = Boolean(selectedThread);
786
785
  const hasRemitDraftOpen =
787
786
  isDraftsMailbox &&
788
787
  composeState.isOpen &&
789
788
  !!composeState.outboxMessageId &&
790
789
  !selectedThread;
791
- const showIntelligence = isDesktop && intelligenceOpen && hasThread;
792
790
 
793
791
  useTriageKeyboard({
794
792
  // A modal owns the keyboard outright. Suspending the layer is what keeps a
@@ -868,7 +866,6 @@ function MailboxPaneProvider({
868
866
  onSelectFilterCategory,
869
867
  onToggleFilterAttribute,
870
868
  onClearFilters,
871
- showIntelligence,
872
869
  intelligenceOpen,
873
870
  onToggleIntelligence,
874
871
  onDeleteMessages: handleDeleteMessages,
@@ -1081,7 +1078,7 @@ function MailboxReading() {
1081
1078
  selectedThread,
1082
1079
  conversation,
1083
1080
  hasRemitDraftOpen,
1084
- showIntelligence,
1081
+ intelligenceOpen,
1085
1082
  onToggleIntelligence,
1086
1083
  toolbarComposeRequest,
1087
1084
  onToolbarReply,
@@ -1094,9 +1091,12 @@ function MailboxReading() {
1094
1091
  onToolbarMove,
1095
1092
  composeState,
1096
1093
  } = useMailboxPane();
1097
- const tier = useLayoutTier();
1098
- const isDesktop = tier === "desktop";
1094
+ // The rail's own width gate, not the shell tier: between 1024 and 1280 the
1095
+ // reading pane is mounted but the rail is not, so "enabled" would promise an
1096
+ // open that cannot happen.
1097
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
1099
1098
  const hasThread = Boolean(conversation);
1099
+ const canToggleIntelligence = railFits && hasThread;
1100
1100
 
1101
1101
  const detailPane =
1102
1102
  composeState.isOpen && !conversation ? (
@@ -1124,8 +1124,8 @@ function MailboxReading() {
1124
1124
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1125
1125
  <MessageToolbar
1126
1126
  hasThread={hasThread}
1127
- intelligenceOpen={showIntelligence}
1128
- showIntelligenceToggle={isDesktop && hasThread}
1127
+ intelligenceOpen={canToggleIntelligence && intelligenceOpen}
1128
+ canToggleIntelligence={canToggleIntelligence}
1129
1129
  onToggleIntelligence={onToggleIntelligence}
1130
1130
  onReply={hasThread ? onToolbarReply : undefined}
1131
1131
  onReplyAll={hasThread ? onToolbarReplyAll : undefined}
@@ -69,7 +69,7 @@ describe("MessageToolbar carries message context only (#49)", () => {
69
69
  createElement(MessageToolbar, {
70
70
  hasThread,
71
71
  intelligenceOpen: false,
72
- showIntelligenceToggle: true,
72
+ canToggleIntelligence: true,
73
73
  onToggleIntelligence: () => undefined,
74
74
  }) as never,
75
75
  );
@@ -94,3 +94,31 @@ describe("MessageToolbar carries message context only (#49)", () => {
94
94
  assert.match(html, /intelligence sidebar/);
95
95
  });
96
96
  });
97
+
98
+ /**
99
+ * The toggle holds its slot whatever the view and the selection are (#52); it
100
+ * reports "cannot act" by being disabled, not by leaving the bar.
101
+ */
102
+ describe("MessageToolbar keeps the intelligence toggle in place (#52)", () => {
103
+ const render = (canToggleIntelligence: boolean): string =>
104
+ renderToString(
105
+ createElement(MessageToolbar, {
106
+ hasThread: false,
107
+ intelligenceOpen: false,
108
+ canToggleIntelligence,
109
+ onToggleIntelligence: () => undefined,
110
+ }) as never,
111
+ );
112
+
113
+ it("renders the toggle when it cannot open a rail", () => {
114
+ assert.match(render(false), /aria-label="Show intelligence sidebar"/);
115
+ });
116
+
117
+ it("disables it rather than dropping it from the bar", () => {
118
+ assert.match(render(false), /\sdisabled(=""|\s|>)/);
119
+ });
120
+
121
+ it("leaves it pressable once a rail can open", () => {
122
+ assert.equal(/\sdisabled(=""|\s|>)/.test(render(true)), false);
123
+ });
124
+ });
@@ -1,5 +1,8 @@
1
- import { Button, type MailAction, MailActionToolbar } from "@remit/ui";
2
- import { Info } from "lucide-react";
1
+ import {
2
+ IntelligenceToggle,
3
+ type MailAction,
4
+ MailActionToolbar,
5
+ } from "@remit/ui";
3
6
  import { useState } from "react";
4
7
  import { tooltipForAction } from "@/lib/keymap";
5
8
  import { MoveToTrigger } from "./MoveToTrigger";
@@ -13,19 +16,21 @@ import { MoveToTrigger } from "./MoveToTrigger";
13
16
  * Search, compose, bug report and the account menu are not message context;
14
17
  * they live in the app top bar above every pane (`MailTopBar`, #49).
15
18
  *
16
- * Buttons are always pressable (never `disabled`): with no thread open a press
17
- * is a no-op that surfaces a one-line inline explanation rather than greying
18
- * out (`doc/rules/ux.md`).
19
+ * The control set is fixed: every button occupies the same slot on every view
20
+ * and in every selection state (#52). The mail verbs stay pressable with no
21
+ * thread open — a press is a no-op that surfaces a one-line inline explanation
22
+ * (`doc/rules/ux.md`). The intelligence toggle greys out instead, because it
23
+ * has nothing to explain: it opens a rail, and there is no rail to open.
19
24
  */
20
25
  export interface MessageToolbarProps {
21
26
  hasThread: boolean;
22
27
  intelligenceOpen: boolean;
23
28
  /**
24
- * Whether the intelligence toggle is shown at all. The rail is contextual
25
- * to an open message, so the toggle only appears once a thread is selected
26
- * (matches the remit-ui AppShell reference) it never opens an empty rail.
29
+ * Whether pressing the intelligence toggle would open a rail: the view has
30
+ * one, the width allows it, and a thread is selected. The button renders
31
+ * either way disabled when false, never absent (#52).
27
32
  */
28
- showIntelligenceToggle: boolean;
33
+ canToggleIntelligence: boolean;
29
34
  onToggleIntelligence: () => void;
30
35
 
31
36
  /* ---- wired action callbacks (omit to keep the no-op-explain behaviour) ---- */
@@ -60,7 +65,7 @@ const OPEN_FIRST = "Open a message first";
60
65
  export const MessageToolbar = ({
61
66
  hasThread,
62
67
  intelligenceOpen,
63
- showIntelligenceToggle,
68
+ canToggleIntelligence,
64
69
  onToggleIntelligence,
65
70
  onReply,
66
71
  onReplyAll,
@@ -103,24 +108,11 @@ export const MessageToolbar = ({
103
108
  ) : undefined
104
109
  }
105
110
  >
106
- {showIntelligenceToggle && (
107
- <Button
108
- variant="ghost"
109
- size="sm"
110
- icon={<Info className="size-4" />}
111
- title="Intelligence"
112
- aria-label={
113
- intelligenceOpen
114
- ? "Hide intelligence sidebar"
115
- : "Show intelligence sidebar"
116
- }
117
- aria-pressed={intelligenceOpen}
118
- onClick={onToggleIntelligence}
119
- className={
120
- intelligenceOpen ? "bg-accent-2-soft text-accent-2" : undefined
121
- }
122
- />
123
- )}
111
+ <IntelligenceToggle
112
+ open={intelligenceOpen}
113
+ enabled={canToggleIntelligence}
114
+ onToggle={onToggleIntelligence}
115
+ />
124
116
  </MailActionToolbar>
125
117
  );
126
118
  };
@@ -298,7 +298,8 @@ function MailLayout() {
298
298
  />
299
299
  </div>
300
300
  ) : onBriefRoute ? (
301
- // Daily brief (/mail/) — no mailboxId param; 2-pane layout (no intelligence).
301
+ // Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
302
+ // mailbox: an open message has an intelligence rail here too (#52).
302
303
  <BriefPane selectedMessageId={mobileSelectedMessageId}>
303
304
  {isSinglePane ? (
304
305
  <AppShellSlotted
@@ -316,7 +317,9 @@ function MailLayout() {
316
317
  topBar={topBar}
317
318
  list={<BriefPane.List />}
318
319
  reading={<BriefPane.Reading />}
320
+ intelligence={<BriefPane.Intelligence />}
319
321
  intelligenceOpen={intelligenceOpen}
322
+ hasThread={Boolean(mobileSelectedMessageId)}
320
323
  overlay={overlayContent}
321
324
  skeleton={<AppShellSkeleton />}
322
325
  isLoading={isLoading || hasNoAccounts}
@@ -326,7 +329,7 @@ function MailLayout() {
326
329
  </BriefPane>
327
330
  ) : onFlaggedRoute ? (
328
331
  // Flagged virtual mailbox (/mail/flagged) — flat starred list across
329
- // accounts; 2-pane layout (no intelligence), like the brief.
332
+ // accounts; same slots as the brief, intelligence rail included.
330
333
  <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
331
334
  {isSinglePane ? (
332
335
  <AppShellSlotted
@@ -344,7 +347,9 @@ function MailLayout() {
344
347
  topBar={topBar}
345
348
  list={<FlaggedPane.List />}
346
349
  reading={<FlaggedPane.Reading />}
350
+ intelligence={<FlaggedPane.Intelligence />}
347
351
  intelligenceOpen={intelligenceOpen}
352
+ hasThread={Boolean(mobileSelectedMessageId)}
348
353
  overlay={overlayContent}
349
354
  skeleton={<AppShellSkeleton />}
350
355
  isLoading={isLoading || hasNoAccounts}
@@ -376,6 +381,7 @@ function MailLayout() {
376
381
  reading={<MailboxPane.Reading />}
377
382
  intelligence={<MailboxPane.Intelligence />}
378
383
  intelligenceOpen={intelligenceOpen}
384
+ hasThread={Boolean(mobileSelectedMessageId)}
379
385
  overlay={overlayContent}
380
386
  skeleton={<AppShellSkeleton />}
381
387
  isLoading={isLoading || hasNoAccounts}