@remit/web-client 0.0.12 → 0.0.14

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/auth/better-auth-config.ts +4 -1
  3. package/src/components/compose/ComposeForm.tsx +3 -0
  4. package/src/components/layout/ComposeFab.tsx +19 -34
  5. package/src/components/layout/MailTopBar.tsx +64 -0
  6. package/src/components/mail/BriefPane.tsx +1 -12
  7. package/src/components/mail/FlaggedPane.tsx +1 -12
  8. package/src/components/mail/IntelligencePane.tsx +10 -8
  9. package/src/components/mail/MailListHeader.tsx +9 -5
  10. package/src/components/mail/MailboxPane.tsx +0 -10
  11. package/src/components/mail/MessageActionMenu.tsx +18 -25
  12. package/src/components/mail/MessageDetail.tsx +1 -0
  13. package/src/components/mail/MessageToolbar.render.test.ts +39 -0
  14. package/src/components/mail/MessageToolbar.tsx +8 -48
  15. package/src/components/ui/ErrorBanner.tsx +6 -3
  16. package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
  17. package/src/components/ui/ErrorBannerProvider.tsx +18 -0
  18. package/src/components/ui/error-banners.ts +9 -0
  19. package/src/hooks/useComposeTarget.ts +92 -0
  20. package/src/hooks/useDeleteMessages.ts +14 -21
  21. package/src/hooks/useIntelligenceData.ts +14 -3
  22. package/src/hooks/useLayoutTier.test.ts +26 -0
  23. package/src/hooks/useMarkAsRead.ts +14 -23
  24. package/src/hooks/useMessageBodyContent.ts +2 -1
  25. package/src/hooks/useMoveMessages.ts +14 -21
  26. package/src/hooks/useStaleAccountSync.test.ts +22 -1
  27. package/src/hooks/useToggleStar.test.ts +31 -0
  28. package/src/hooks/useToggleStar.ts +27 -31
  29. package/src/hooks/useUpdateAddressFlags.ts +3 -1
  30. package/src/lib/api.ts +3 -1
  31. package/src/lib/client.ts +3 -0
  32. package/src/lib/compose-routes.test.ts +44 -0
  33. package/src/lib/compose-routes.ts +25 -0
  34. package/src/lib/error-classifier.test.ts +156 -8
  35. package/src/lib/error-classifier.ts +49 -9
  36. package/src/lib/network-error.ts +58 -0
  37. package/src/lib/query-error-handler.test.ts +6 -2
  38. package/src/lib/query-escalation.integration.test.ts +3 -2
  39. package/src/lib/route-search-query.test.ts +48 -0
  40. package/src/lib/sender-address.test.ts +60 -0
  41. package/src/lib/sender-address.ts +37 -0
  42. package/src/lib/thread-cache.test.ts +65 -0
  43. package/src/lib/thread-cache.ts +76 -0
  44. package/src/routes/mail/outbox.tsx +5 -0
  45. package/src/routes/mail.tsx +15 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,4 +1,5 @@
1
1
  import { createAuthClient } from "better-auth/react";
2
+ import { taggedFetch } from "../lib/network-error";
2
3
  import { getRuntimeConfig } from "../runtime-config";
3
4
 
4
5
  /**
@@ -51,7 +52,9 @@ const decodeExp = (token: string): number => {
51
52
  };
52
53
 
53
54
  const requestToken = async (): Promise<string | null> => {
54
- const res = await fetch("/api/auth/token", { credentials: "include" });
55
+ const res = await taggedFetch("/api/auth/token", {
56
+ credentials: "include",
57
+ });
55
58
  if (!res.ok) return null;
56
59
  const body: unknown = await res.json();
57
60
  if (
@@ -419,6 +419,7 @@ export const ComposeForm = ({
419
419
  pushError({
420
420
  title: "Couldn't save draft",
421
421
  detail: formatErrorDetail(saveError) ?? "Saving the draft failed.",
422
+ error: saveError,
422
423
  });
423
424
  }, [saveError, pushError]);
424
425
 
@@ -541,6 +542,7 @@ export const ComposeForm = ({
541
542
  pushError({
542
543
  title: "Couldn't send message",
543
544
  detail: formatErrorDetail(error) ?? "Saving the draft failed.",
545
+ error,
544
546
  });
545
547
  return null;
546
548
  });
@@ -562,6 +564,7 @@ export const ComposeForm = ({
562
564
  (createdThisAttempt
563
565
  ? "The draft was saved but the send request failed. Try again from the Outbox."
564
566
  : "The send request failed. Try again."),
567
+ error,
565
568
  });
566
569
  return null;
567
570
  });
@@ -1,16 +1,8 @@
1
- import { useLocation, useNavigate } from "@tanstack/react-router";
1
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { useLocation } from "@tanstack/react-router";
2
3
  import { Pencil } from "lucide-react";
3
- import { startTransition } from "react";
4
4
  import { useCompose } from "@/components/compose/ComposeProvider";
5
-
6
- /**
7
- * Returns true when `pathname` is `/mail/<id>` for any id other than
8
- * the reserved `outbox` segment. That route owns a `FullCompose` mount
9
- * point, so the FAB can open compose in-place. From every other route
10
- * the FAB has to send the user there first.
11
- */
12
- const isOnMailboxRoute = (pathname: string): boolean =>
13
- /^\/mail\/(?!outbox)[^/?]+/.test(pathname);
5
+ import { useGlobalCompose } from "@/hooks/useComposeTarget";
14
6
 
15
7
  /**
16
8
  * Primary mobile surfaces where the FAB belongs: anywhere under
@@ -21,30 +13,34 @@ const isOnMailboxRoute = (pathname: string): boolean =>
21
13
  const isOnPrimaryMobileRoute = (pathname: string): boolean =>
22
14
  pathname.startsWith("/mail") || pathname.startsWith("/settings");
23
15
 
16
+ interface ComposeFabProps {
17
+ accounts: RemitImapAccountResponse[];
18
+ }
19
+
24
20
  /**
25
21
  * Floating Action Button for composing a new message. Mobile-only.
26
22
  *
27
23
  * Layout follows Material 3: 56×56 surface, 16px from the right and
28
24
  * bottom edges (plus the iOS safe-area inset). Hidden when any of:
29
- * - Viewport is `≥ md` (desktop has compose in the sidebar / etc.).
25
+ * - Viewport is `≥ lg` (1024px), where the top bar owns compose. The
26
+ * `/mail` shell also stops mounting the FAB above that width; the
27
+ * `lg:hidden` class covers the pre-hydration frame.
30
28
  * - The compose surface is already open.
31
29
  * - The user is reading a thread (`?selectedMessageId=…`) — the
32
30
  * conversation's Reply/Forward action bar covers that workflow.
33
31
  * - The user is not on a primary mobile route (`/mail` or
34
32
  * `/settings`).
35
33
  *
36
- * From routes that don't host a `FullCompose` (everything except
37
- * `/mail/<mailboxId>` i.e. `/mail`, `/mail/outbox`, `/settings/*`)
38
- * tapping the FAB opens compose state and navigates to `/mail`, which
39
- * the index loader redirects to the preferred mailbox (PR #138). The
40
- * compose state survives the route transition because `ComposeProvider`
41
- * lives in `__root.tsx`, so the destination route mounts straight into
42
- * compose.
34
+ * The tap itself is `useGlobalCompose`, shared with the desktop top bar:
35
+ * it opens compose in place on routes that mount `FullCompose` and
36
+ * otherwise carries the user to a real mailbox that does. Compose state
37
+ * survives that transition because `ComposeProvider` lives in
38
+ * `__root.tsx`.
43
39
  */
44
- export const ComposeFab = () => {
45
- const { state, openCompose } = useCompose();
40
+ export const ComposeFab = ({ accounts }: ComposeFabProps) => {
41
+ const { state } = useCompose();
46
42
  const location = useLocation();
47
- const navigate = useNavigate();
43
+ const compose = useGlobalCompose(accounts);
48
44
 
49
45
  const search = location.search as Record<string, unknown> | undefined;
50
46
  const isReadingThread =
@@ -58,21 +54,10 @@ export const ComposeFab = () => {
58
54
  )
59
55
  return null;
60
56
 
61
- const handleClick = () => {
62
- // Wrap in startTransition so the suspension that ComposeForm
63
- // triggers on first mount (lazy chunks, queries) doesn't
64
- // snap the surrounding chrome into a route-level fallback.
65
- startTransition(() => {
66
- openCompose({ mode: "new" });
67
- });
68
- if (isOnMailboxRoute(location.pathname)) return;
69
- navigate({ to: "/mail" });
70
- };
71
-
72
57
  return (
73
58
  <button
74
59
  type="button"
75
- onClick={handleClick}
60
+ onClick={compose}
76
61
  aria-label="Compose new message"
77
62
  className="lg:hidden fixed right-4 z-30 h-14 w-14 rounded-full bg-accent text-accent-fg shadow-lg flex items-center justify-center hover:opacity-90 active:scale-95 transition-all"
78
63
  style={{
@@ -0,0 +1,64 @@
1
+ /**
2
+ * MailTopBar — the app's one search surface and its global actions.
3
+ *
4
+ * Mounted above every pane by the `/mail` shell, so the field spans the nav,
5
+ * the list and the reading pane. It is the app's search, not the list's: the
6
+ * list header drops its own field wherever this bar is mounted, so exactly one
7
+ * search input exists on the page and the "/" shortcut has one target.
8
+ *
9
+ * The actions here are the ones that belong to the app rather than to whatever
10
+ * is currently listed or open — compose, bug report, account. Reply, delete,
11
+ * move and the rest stay on the reading pane's own toolbar, under this bar.
12
+ */
13
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
14
+ import { AppTopBar, Button, SearchBar } from "@remit/ui";
15
+ import { SquarePen } from "lucide-react";
16
+ import { AccountMenu } from "@/auth/AccountMenu";
17
+ import { BugReportButton } from "@/components/ui/BugReportButton";
18
+ import { useGlobalCompose } from "@/hooks/useComposeTarget";
19
+ import { tooltipForAction } from "@/lib/keymap";
20
+ import { useMailContext } from "@/lib/mail-context";
21
+
22
+ interface MailTopBarProps {
23
+ accounts: RemitImapAccountResponse[];
24
+ }
25
+
26
+ export function MailTopBar({ accounts }: MailTopBarProps) {
27
+ const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
28
+ useMailContext();
29
+ const compose = useGlobalCompose(accounts);
30
+
31
+ return (
32
+ <AppTopBar
33
+ leading={
34
+ <span className="px-1 text-sm font-semibold tracking-tight text-fg">
35
+ remit
36
+ </span>
37
+ }
38
+ search={
39
+ <SearchBar
40
+ value={searchInput}
41
+ onChange={onSearchChange}
42
+ onClear={onSearchClear}
43
+ onClearQuery={onSearchClearQuery}
44
+ placeholder="Search all mail"
45
+ size="lg"
46
+ />
47
+ }
48
+ actions={
49
+ <>
50
+ <Button
51
+ variant="ghost"
52
+ size="sm"
53
+ icon={<SquarePen className="size-4" />}
54
+ title={`Compose ${tooltipForAction("compose")}`}
55
+ aria-label="Compose"
56
+ onClick={compose}
57
+ />
58
+ <BugReportButton />
59
+ <AccountMenu />
60
+ </>
61
+ }
62
+ />
63
+ );
64
+ }
@@ -184,26 +184,15 @@ function BriefList() {
184
184
  */
185
185
  function BriefReading() {
186
186
  const { conversation } = useBriefPane();
187
- const {
188
- onToggleIntelligence,
189
- searchInput,
190
- onSearchChange,
191
- onSearchClear,
192
- onSearchClearQuery,
193
- } = useMailContext();
187
+ const { onToggleIntelligence } = useMailContext();
194
188
 
195
189
  return (
196
190
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
197
191
  <MessageToolbar
198
192
  hasThread={Boolean(conversation)}
199
- onCompose={() => undefined}
200
193
  intelligenceOpen={false}
201
194
  showIntelligenceToggle={false}
202
195
  onToggleIntelligence={onToggleIntelligence}
203
- searchValue={searchInput}
204
- onSearchChange={onSearchChange}
205
- onSearchClear={onSearchClear}
206
- onSearchClearQuery={onSearchClearQuery}
207
196
  />
208
197
  <div className="min-h-0 flex-1 overflow-hidden">
209
198
  {conversation ? (
@@ -128,26 +128,15 @@ function FlaggedListSlot() {
128
128
  */
129
129
  function FlaggedReading() {
130
130
  const { selectedThread } = useFlaggedPane();
131
- const {
132
- onToggleIntelligence,
133
- searchInput,
134
- onSearchChange,
135
- onSearchClear,
136
- onSearchClearQuery,
137
- } = useMailContext();
131
+ const { onToggleIntelligence } = useMailContext();
138
132
 
139
133
  return (
140
134
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
141
135
  <MessageToolbar
142
136
  hasThread={Boolean(selectedThread)}
143
- onCompose={() => undefined}
144
137
  intelligenceOpen={false}
145
138
  showIntelligenceToggle={false}
146
139
  onToggleIntelligence={onToggleIntelligence}
147
- searchValue={searchInput}
148
- onSearchChange={onSearchChange}
149
- onSearchClear={onSearchClear}
150
- onSearchClearQuery={onSearchClearQuery}
151
140
  />
152
141
  <div className="min-h-0 flex-1 overflow-hidden">
153
142
  {selectedThread ? (
@@ -313,15 +313,17 @@ function WiredPanel({
313
313
  [updateFlags],
314
314
  );
315
315
 
316
- // Per-sender flag toggles are always wired. They stay active even before the
317
- // address record resolves: `updateFlags` surfaces feedback when `addressId`
318
- // is missing rather than letting the button look active but do nothing.
316
+ // Every per-sender flag toggle PATCHes the sender's address row, so none of
317
+ // them can be serviced until that row resolves. Leave them unwired until it
318
+ // does: the panel renders an unwired quick action as disabled, so the flow is
319
+ // never reachable in a state where a click can only fail (issue #51).
320
+ const canUpdateFlags = addressId !== undefined;
319
321
  const actions: IntelligenceQuickActions = {
320
- onToggleVip: handleToggleVip,
321
- onToggleMute: handleToggleMute,
322
- onToggleBlock: handleToggleBlock,
323
- onToggleUnsubscribe: handleToggleUnsubscribe,
324
- onReclassify: () => setReclassifyOpen(true),
322
+ onToggleVip: canUpdateFlags ? handleToggleVip : undefined,
323
+ onToggleMute: canUpdateFlags ? handleToggleMute : undefined,
324
+ onToggleBlock: canUpdateFlags ? handleToggleBlock : undefined,
325
+ onToggleUnsubscribe: canUpdateFlags ? handleToggleUnsubscribe : undefined,
326
+ onReclassify: canUpdateFlags ? () => setReclassifyOpen(true) : undefined,
325
327
  onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
326
328
  onMarkSpam: spamAction === "markSpam" ? handleMarkSpam : undefined,
327
329
  };
@@ -31,7 +31,7 @@ import {
31
31
  useAppShellLayout,
32
32
  } from "@remit/ui";
33
33
  import { type ReactNode, useState } from "react";
34
- import { useLayoutTier } from "@/hooks/useLayoutTier";
34
+ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
35
35
  import { useMailContext } from "@/lib/mail-context";
36
36
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
37
37
  import {
@@ -168,11 +168,15 @@ export function MailListHeader({
168
168
  <MailHeader
169
169
  title={title}
170
170
  unreadCount={unreadCount}
171
- // The list pane is narrow even on desktop, and the reading-pane
172
- // toolbar owns the wide search; keep the header's search compact (a
173
- // magnifier) at every width. On phone the magnifier opens the
174
- // full-screen takeover above; on tablet it expands over the title.
171
+ // Desktop mounts the app top bar, which owns search for the whole
172
+ // shell the list header shows no field there, so the page never
173
+ // has two search inputs competing for "/" and for focus. Below
174
+ // desktop the header keeps a compact magnifier: on phone it opens
175
+ // the full-screen takeover above, on tablet it expands over the
176
+ // title. `isSinglePaneTier` is the same predicate the shell gates
177
+ // the top bar on, so the two cannot drift into zero or two fields.
175
178
  isDesktop={false}
179
+ showSearch={isSinglePaneTier(tier)}
176
180
  onMenuClick={() => layout?.openNav()}
177
181
  searchValue={searchInput}
178
182
  onSearchChange={onSearchChange}
@@ -202,7 +202,6 @@ interface MailboxPaneContextValue {
202
202
  onToolbarDiscardDraft: () => void;
203
203
  onToolbarMove: (destMailboxId: string) => void;
204
204
  composeState: ReturnType<typeof useCompose>["state"];
205
- openCompose: () => void;
206
205
  closeCompose: () => void;
207
206
  hasRemitDraftOpen: boolean;
208
207
  // Phone actions
@@ -843,7 +842,6 @@ function MailboxPaneProvider({
843
842
  onToolbarDiscardDraft: handleToolbarDiscardDraft,
844
843
  onToolbarMove: handleToolbarMove,
845
844
  composeState,
846
- openCompose: handleNewCompose,
847
845
  closeCompose,
848
846
  hasRemitDraftOpen,
849
847
  onBack: goBack,
@@ -1044,10 +1042,7 @@ function MailboxReading() {
1044
1042
  onToolbarDiscardDraft,
1045
1043
  onToolbarMove,
1046
1044
  composeState,
1047
- openCompose,
1048
1045
  } = useMailboxPane();
1049
- const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
1050
- useMailContext();
1051
1046
  const tier = useLayoutTier();
1052
1047
  const isDesktop = tier === "desktop";
1053
1048
  const hasThread = Boolean(conversation);
@@ -1078,14 +1073,9 @@ function MailboxReading() {
1078
1073
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1079
1074
  <MessageToolbar
1080
1075
  hasThread={hasThread}
1081
- onCompose={openCompose}
1082
1076
  intelligenceOpen={showIntelligence}
1083
1077
  showIntelligenceToggle={isDesktop && hasThread}
1084
1078
  onToggleIntelligence={onToggleIntelligence}
1085
- searchValue={searchInput}
1086
- onSearchChange={onSearchChange}
1087
- onSearchClear={onSearchClear}
1088
- onSearchClearQuery={onSearchClearQuery}
1089
1079
  onReply={hasThread ? onToolbarReply : undefined}
1090
1080
  onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1091
1081
  onForward={hasThread ? onToolbarForward : undefined}
@@ -27,6 +27,7 @@ import { formatErrorDetail } from "@/components/ui/error-banners";
27
27
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
28
28
  import { useMoveMessages } from "@/hooks/useMoveMessages";
29
29
  import { useToggleTrusted } from "@/hooks/useToggleTrusted";
30
+ import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
30
31
  import { MoveToTrigger } from "./MoveToTrigger";
31
32
 
32
33
  interface ThreadMessagesData {
@@ -34,15 +35,12 @@ interface ThreadMessagesData {
34
35
  [key: string]: unknown;
35
36
  }
36
37
 
37
- interface ThreadsListPage {
38
- items: RemitImapThreadMessageResponse[];
39
- [key: string]: unknown;
40
- }
41
-
42
- interface ThreadsListData {
43
- pages: ThreadsListPage[];
44
- pageParams: Array<string | undefined>;
45
- }
38
+ /**
39
+ * Either shape a thread list/search query caches — the infinite mailbox list
40
+ * or a single-shot page. `setQueriesData` matches by key prefix, so the
41
+ * optimistic updater sees both.
42
+ */
43
+ type ThreadsListData = ThreadListCache;
46
44
 
47
45
  interface SnapshotEntry<T> {
48
46
  queryKey: readonly unknown[];
@@ -169,26 +167,20 @@ export const MessageActionMenu = ({
169
167
  },
170
168
  );
171
169
 
172
- const patchListData = (old: ThreadsListData | undefined) => {
173
- if (!old) return old;
174
- return {
175
- ...old,
176
- pages: old.pages.map((page) => ({
177
- ...page,
178
- items: page.items.map((item) =>
179
- targetIds.has(item.messageId)
180
- ? { ...item, isRead: isReadNext }
181
- : item,
182
- ),
183
- })),
184
- };
185
- };
170
+ const patchListData = (old: unknown) =>
171
+ patchThreadListCache(old, (items) =>
172
+ items.map((item) =>
173
+ targetIds.has(item.messageId)
174
+ ? { ...item, isRead: isReadNext }
175
+ : item,
176
+ ),
177
+ );
186
178
 
187
- queryClient.setQueriesData<ThreadsListData>(
179
+ queryClient.setQueriesData(
188
180
  { queryKey: threadsListPrefix },
189
181
  patchListData,
190
182
  );
191
- queryClient.setQueriesData<ThreadsListData>(
183
+ queryClient.setQueriesData(
192
184
  { queryKey: threadsSearchPrefix },
193
185
  patchListData,
194
186
  );
@@ -214,6 +206,7 @@ export const MessageActionMenu = ({
214
206
  pushError({
215
207
  title: isReadNext ? "Couldn't mark as read" : "Couldn't mark as unread",
216
208
  detail: formatErrorDetail(err),
209
+ error: err,
217
210
  });
218
211
  },
219
212
  onSettled: (_data, _err, _vars, context) => {
@@ -74,6 +74,7 @@ export const MessageDetail = ({ messageId }: MessageDetailProps) => {
74
74
  pushError({
75
75
  title: "Couldn't mark message as read",
76
76
  detail: formatErrorDetail(error),
77
+ error,
77
78
  });
78
79
  },
79
80
  });
@@ -12,6 +12,7 @@ import { describe, it } from "node:test";
12
12
  import { MailActionToolbar } from "@remit/ui";
13
13
  import React, { createElement } from "react";
14
14
  import { renderToString } from "react-dom/server";
15
+ import { MessageToolbar } from "./MessageToolbar";
15
16
 
16
17
  // The node test loader transpiles remit-ui's `.tsx` (resolved through
17
18
  // node_modules) with the classic JSX runtime, which references a global
@@ -55,3 +56,41 @@ describe("MailActionToolbar never disables its action buttons (#799)", () => {
55
56
  assert.doesNotMatch(html, /aria-label="Archive"/);
56
57
  });
57
58
  });
59
+
60
+ /**
61
+ * The reading-pane toolbar carries message context only (#49). Search, compose,
62
+ * bug report and the account menu moved up into the app top bar, which spans
63
+ * every pane. Two search fields on one screen fight over "/" and over focus, so
64
+ * the absence of one here is the thing worth pinning.
65
+ */
66
+ describe("MessageToolbar carries message context only (#49)", () => {
67
+ const render = (hasThread: boolean): string =>
68
+ renderToString(
69
+ createElement(MessageToolbar, {
70
+ hasThread,
71
+ intelligenceOpen: false,
72
+ showIntelligenceToggle: true,
73
+ onToggleIntelligence: () => undefined,
74
+ }) as never,
75
+ );
76
+
77
+ it("mounts no search field", () => {
78
+ for (const hasThread of [false, true]) {
79
+ assert.doesNotMatch(render(hasThread), /aria-label="Search mail"/);
80
+ }
81
+ });
82
+
83
+ it("carries no global actions — compose, bug report, account", () => {
84
+ const html = render(true);
85
+ assert.doesNotMatch(html, /aria-label="Compose"/);
86
+ assert.doesNotMatch(html, /aria-label="Report a bug"/);
87
+ assert.doesNotMatch(html, /aria-label="Account menu"/);
88
+ });
89
+
90
+ it("keeps the message verbs and the intelligence toggle", () => {
91
+ const html = render(true);
92
+ assert.match(html, /aria-label="Reply"/);
93
+ assert.match(html, /aria-label="Star"/);
94
+ assert.match(html, /intelligence sidebar/);
95
+ });
96
+ });
@@ -1,22 +1,17 @@
1
- import {
2
- Button,
3
- type MailAction,
4
- MailActionToolbar,
5
- SearchBar,
6
- } from "@remit/ui";
7
- import { Info, SquarePen } from "lucide-react";
1
+ import { Button, type MailAction, MailActionToolbar } from "@remit/ui";
2
+ import { Info } from "lucide-react";
8
3
  import { useState } from "react";
9
- import { AccountMenu } from "@/auth/AccountMenu";
10
- import { BugReportButton } from "@/components/ui/BugReportButton";
11
4
  import { tooltipForAction } from "@/lib/keymap";
12
5
  import { MoveToTrigger } from "./MoveToTrigger";
13
6
 
14
7
  /**
15
8
  * Message action toolbar on the pane-header datum (40px, the shared
16
- * `--spacing-pane-header`). The reading pane's verbs reply / reply-all /
17
- * forward, then delete / move / flag — Apple-Mail-style ghost icon buttons,
18
- * then the search field top-right (still filters the current list), compose
19
- * (✎) and the intelligence toggle (#422).
9
+ * `--spacing-pane-header`). Everything here acts on the open message: reply /
10
+ * reply-all / forward, then delete / move / flag — Apple-Mail-style ghost icon
11
+ * buttons — and the intelligence toggle (#422).
12
+ *
13
+ * Search, compose, bug report and the account menu are not message context;
14
+ * they live in the app top bar above every pane (`MailTopBar`, #49).
20
15
  *
21
16
  * Buttons are always pressable (never `disabled`): with no thread open a press
22
17
  * is a no-op that surfaces a one-line inline explanation rather than greying
@@ -24,7 +19,6 @@ import { MoveToTrigger } from "./MoveToTrigger";
24
19
  */
25
20
  export interface MessageToolbarProps {
26
21
  hasThread: boolean;
27
- onCompose: () => void;
28
22
  intelligenceOpen: boolean;
29
23
  /**
30
24
  * Whether the intelligence toggle is shown at all. The rail is contextual
@@ -33,12 +27,6 @@ export interface MessageToolbarProps {
33
27
  */
34
28
  showIntelligenceToggle: boolean;
35
29
  onToggleIntelligence: () => void;
36
- searchValue: string;
37
- onSearchChange: (value: string) => void;
38
- /** Full clear (X button): drops the query and any selected thread (#538). */
39
- onSearchClear: () => void;
40
- /** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
41
- onSearchClearQuery?: () => void;
42
30
 
43
31
  /* ---- wired action callbacks (omit to keep the no-op-explain behaviour) ---- */
44
32
  onReply?: () => void;
@@ -71,14 +59,9 @@ const OPEN_FIRST = "Open a message first";
71
59
 
72
60
  export const MessageToolbar = ({
73
61
  hasThread,
74
- onCompose,
75
62
  intelligenceOpen,
76
63
  showIntelligenceToggle,
77
64
  onToggleIntelligence,
78
- searchValue,
79
- onSearchChange,
80
- onSearchClear,
81
- onSearchClearQuery,
82
65
  onReply,
83
66
  onReplyAll,
84
67
  onForward,
@@ -120,27 +103,6 @@ export const MessageToolbar = ({
120
103
  ) : undefined
121
104
  }
122
105
  >
123
- {/* Apple Mail geometry: search sits top-right over the message area
124
- but still filters the current list. Reuses the wired SearchBar
125
- (the global "/" focus shortcut + Escape handling live in it). */}
126
- <div className="w-64 min-w-40 shrink">
127
- <SearchBar
128
- value={searchValue}
129
- onChange={onSearchChange}
130
- onClear={onSearchClear}
131
- onClearQuery={onSearchClearQuery}
132
- placeholder="Search mail"
133
- />
134
- </div>
135
- <span className="mx-1 h-4 w-px bg-line" aria-hidden />
136
- <Button
137
- variant="ghost"
138
- size="sm"
139
- icon={<SquarePen className="size-4" />}
140
- title={`Compose ${tooltipForAction("compose")}`}
141
- aria-label="Compose"
142
- onClick={onCompose}
143
- />
144
106
  {showIntelligenceToggle && (
145
107
  <Button
146
108
  variant="ghost"
@@ -159,8 +121,6 @@ export const MessageToolbar = ({
159
121
  }
160
122
  />
161
123
  )}
162
- <BugReportButton />
163
- <AccountMenu />
164
124
  </MailActionToolbar>
165
125
  );
166
126
  };
@@ -15,12 +15,12 @@ const SEVERITY_STYLES: Record<
15
15
  { container: string; icon: string; title: string }
16
16
  > = {
17
17
  error: {
18
- container: "border-danger/50 bg-danger/10 dark:bg-danger/20",
18
+ container: "border-danger/50 bg-danger-soft",
19
19
  icon: "text-danger",
20
20
  title: "text-danger",
21
21
  },
22
22
  warning: {
23
- container: "border-warning/50 bg-warning/10",
23
+ container: "border-warning/50 bg-warning-soft",
24
24
  icon: "text-warning",
25
25
  title: "text-warning",
26
26
  },
@@ -61,7 +61,10 @@ export const ErrorBanner = ({
61
61
  role={severity === "error" ? "alert" : "status"}
62
62
  aria-live={severity === "error" ? "assertive" : "polite"}
63
63
  className={cn(
64
- "pointer-events-auto flex items-start gap-3 rounded-md border bg-canvas/80 px-3 py-2 shadow-md backdrop-blur",
64
+ // Opaque, not translucent: a banner overlaps the toolbar and the
65
+ // message list, and see-through text on top of see-through text is
66
+ // unreadable (issue #55).
67
+ "pointer-events-auto flex items-start gap-3 rounded-md border bg-canvas px-3 py-2 shadow-lg",
65
68
  styles.container,
66
69
  )}
67
70
  >