@remit/web-client 0.0.208 → 0.0.210

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.208",
3
+ "version": "0.0.210",
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": {
@@ -173,6 +173,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
173
173
 
174
174
  const actions = useThreadActions({
175
175
  thread: selectedThread,
176
+ isOpen: true,
176
177
  onAfterOptimisticRemove: handleDeselectIfRemoved,
177
178
  });
178
179
 
@@ -197,7 +198,10 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
197
198
  [briefThreads, focusedThreadId],
198
199
  );
199
200
  const triageTarget = focusedThread ?? selectedThread;
200
- const triageActions = useThreadActions({ thread: triageTarget });
201
+ const triageActions = useThreadActions({
202
+ thread: triageTarget,
203
+ isOpen: triageTarget?.messageId === selectedMessageId,
204
+ });
201
205
 
202
206
  // The toolbar answers the conversation on screen; the keyboard answers the
203
207
  // row the cursor is on, which may be one the address has not opened yet.
@@ -179,6 +179,7 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
179
179
 
180
180
  const actions = useThreadActions({
181
181
  thread: selectedThread,
182
+ isOpen: true,
182
183
  onAfterOptimisticRemove: handleDeselectIfRemoved,
183
184
  });
184
185
 
@@ -203,7 +204,10 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
203
204
  [threads, focusedThreadId],
204
205
  );
205
206
  const triageTarget = focusedThread ?? selectedThread;
206
- const triageActions = useThreadActions({ thread: triageTarget });
207
+ const triageActions = useThreadActions({
208
+ thread: triageTarget,
209
+ isOpen: triageTarget?.messageId === selectedMessageId,
210
+ });
207
211
 
208
212
  // The toolbar answers the conversation on screen; the keyboard answers the
209
213
  // row the cursor is on, which may be one the address has not opened yet.
@@ -227,6 +227,8 @@ interface MailboxPaneContextValue {
227
227
  onReply: ((mode: ReplyMode) => void) | undefined;
228
228
  onToolbarDelete: () => void;
229
229
  onToolbarStar: () => void;
230
+ /** Whether the open message is starred, as the conversation reports it. */
231
+ isStarred: boolean | undefined;
230
232
  onToolbarMove: (destMailboxId: string) => void;
231
233
  // Phone actions
232
234
  onBack: () => void;
@@ -515,6 +517,7 @@ function MailboxPaneProvider({
515
517
 
516
518
  const toolbarActions = useThreadActions({
517
519
  thread: selectedThread,
520
+ isOpen: true,
518
521
  mailboxId,
519
522
  accountId: mailboxAccountId,
520
523
  onAfterOptimisticRemove: handleDeselectIfRemoved,
@@ -806,6 +809,7 @@ function MailboxPaneProvider({
806
809
  onReply: replyToOpenThread,
807
810
  onToolbarDelete: toolbarActions.deleteThread,
808
811
  onToolbarStar: toolbarActions.toggleStar,
812
+ isStarred: toolbarActions.isStarred,
809
813
  onToolbarMove: toolbarActions.moveThread,
810
814
  onBack: goBack,
811
815
  nextThread: adjacentThread(nextMessageId),
@@ -1080,6 +1084,7 @@ function MailboxReading() {
1080
1084
  onReply,
1081
1085
  onToolbarDelete,
1082
1086
  onToolbarStar,
1087
+ isStarred,
1083
1088
  onToolbarMove,
1084
1089
  handleDeselectIfRemoved,
1085
1090
  intelligenceRef,
@@ -1137,7 +1142,7 @@ function MailboxReading() {
1137
1142
  onForward={onReply ? () => onReply("forward") : undefined}
1138
1143
  onDelete={hasThread ? onToolbarDelete : undefined}
1139
1144
  onToggleStar={hasThread ? onToolbarStar : undefined}
1140
- isStarred={selectedThread?.hasStars}
1145
+ isStarred={isStarred}
1141
1146
  moveContext={
1142
1147
  hasThread && mailboxAccountId
1143
1148
  ? {
@@ -16,13 +16,20 @@ import { act, createElement, type ReactNode } from "react";
16
16
  import { SelfUpdateOverlay } from "../components/self-update/SelfUpdateOverlay";
17
17
  import { AdvancedNavIcon } from "../components/settings/AdvancedNavIcon";
18
18
  import { SelfUpdatePanel } from "../components/settings/SelfUpdatePanel";
19
+ import { __resetFatalError, getCurrentFatalError } from "../lib/fatal-error";
19
20
  import {
20
21
  APPLY_BUDGET_SECONDS,
21
22
  NEVER_CAME_BACK_MARGIN_SECONDS,
22
23
  } from "../lib/self-update-state";
23
24
  import { createDomHarness, type DomHarness } from "../test-support/dom";
24
- import { type HttpMock, httpError, mockFetch } from "../test-support/http";
25
25
  import {
26
+ type HttpCall,
27
+ type HttpMock,
28
+ httpError,
29
+ mockFetch,
30
+ } from "../test-support/http";
31
+ import {
32
+ CHECK_ANSWER_BUDGET_MS,
26
33
  type SelfUpdateApi,
27
34
  SelfUpdateProvider,
28
35
  useSelfUpdate,
@@ -36,9 +43,11 @@ let http: HttpMock | undefined;
36
43
  beforeEach(() => {
37
44
  globalThis.localStorage = globalThis.window.localStorage;
38
45
  localStorage.clear();
46
+ __resetFatalError();
39
47
  });
40
48
 
41
49
  afterEach(() => {
50
+ __resetFatalError();
42
51
  http?.restore();
43
52
  http = undefined;
44
53
  harness?.close();
@@ -105,12 +114,12 @@ async function settle(dom: DomHarness): Promise<void> {
105
114
  * what the real seam returns the moment it accepts the request.
106
115
  */
107
116
  function mountApi(
108
- getResponse: () => unknown,
117
+ getResponse: (call: HttpCall) => unknown,
109
118
  children: ReactNode = null,
110
119
  ): { dom: DomHarness; api: () => SelfUpdateApi } {
111
120
  http = mockFetch((call) => {
112
121
  if (call.path.endsWith("/system/update") && call.method === "GET") {
113
- return getResponse();
122
+ return getResponse(call);
114
123
  }
115
124
  return {
116
125
  currentVersion: "0.9.3",
@@ -148,6 +157,15 @@ async function startUpdate(
148
157
  });
149
158
  }
150
159
 
160
+ async function press(dom: DomHarness, api: () => SelfUpdateApi): Promise<void> {
161
+ await act(async () => {
162
+ api().onCheck();
163
+ await dom.flush();
164
+ await dom.wait(1);
165
+ await dom.flush();
166
+ });
167
+ }
168
+
151
169
  async function renderSurface(
152
170
  getResponse: () => unknown,
153
171
  children: ReactNode,
@@ -428,7 +446,23 @@ describe("useSystemUpdate — actions", () => {
428
446
  assert.equal(after.status === "ready" && after.section.status, "upToDate");
429
447
  });
430
448
 
431
- test("checking and retrying re-poll the surface", async () => {
449
+ test("the press asks the updater for a check, not another read of the same file (#599)", async () => {
450
+ // The whole of #599: a plain re-read answers with the verdict it already
451
+ // had. Only refresh=true records a request the updater will act on.
452
+ const { dom, api } = mountApi(() => available);
453
+ await settle(dom);
454
+ const before = http?.calls.length ?? 0;
455
+
456
+ await press(dom, api);
457
+
458
+ const refreshes = (http?.calls ?? [])
459
+ .slice(before)
460
+ .filter((call) => call.url.includes("refresh=true"));
461
+ assert.equal(refreshes.length, 1);
462
+ assert.equal(refreshes[0].method, "GET");
463
+ });
464
+
465
+ test("retrying the connection re-polls the surface", async () => {
432
466
  let calls = 0;
433
467
  const { dom, api } = mountApi(() => {
434
468
  calls += 1;
@@ -437,10 +471,6 @@ describe("useSystemUpdate — actions", () => {
437
471
  await settle(dom);
438
472
  const afterMount = calls;
439
473
 
440
- await act(async () => {
441
- api().onCheck();
442
- await dom.flush();
443
- });
444
474
  await act(async () => {
445
475
  api().onRetryConnection();
446
476
  await dom.flush();
@@ -448,4 +478,57 @@ describe("useSystemUpdate — actions", () => {
448
478
 
449
479
  assert.equal(calls > afterMount, true);
450
480
  });
481
+
482
+ test("a press the updater never answers ends loudly, not in a spinner (#599)", async () => {
483
+ // The poll keeps answering with the same bytes, so nothing the hook reads
484
+ // changes and no render would notice the wait running out on its own.
485
+ const { dom, api } = mountApi(
486
+ () => available,
487
+ createElement(SelfUpdatePanel),
488
+ );
489
+ await settle(dom);
490
+
491
+ const realNow = Date.now;
492
+ try {
493
+ await act(async () => {
494
+ api().onCheck();
495
+ // The press is stamped off the real clock; the wait it then arms is
496
+ // measured against one already past the budget, so the deadline the
497
+ // hook sets for itself lands on the next tick, not in half a minute.
498
+ Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
499
+ await dom.flush();
500
+ });
501
+ // The effects have run and the deadline is armed — let it fire.
502
+ await dom.wait(5);
503
+ await dom.flush();
504
+
505
+ assert.match(dom.html(), /The updater did not answer/);
506
+ assert.match(dom.html(), /remit logs updater/);
507
+ assert.doesNotMatch(dom.html(), /Looking for a newer version/);
508
+ } finally {
509
+ Date.now = realNow;
510
+ }
511
+ });
512
+
513
+ test("a refused refresh is shown, never swallowed back into the old verdict (#599)", async () => {
514
+ let refused = false;
515
+ const { dom, api } = mountApi(
516
+ (call) =>
517
+ refused && call.url.includes("refresh=true")
518
+ ? httpError(500)
519
+ : available,
520
+ createElement(SelfUpdatePanel),
521
+ );
522
+ await settle(dom);
523
+
524
+ refused = true;
525
+ await press(dom, api);
526
+
527
+ assert.match(dom.html(), /could not be requested/);
528
+ assert.match(dom.html(), /answered 500/);
529
+ assert.doesNotMatch(dom.html(), /Install 0\.9\.4/);
530
+ // The press is a raw SDK call, outside the cache the global sink watches:
531
+ // a 5xx escalates from here or from nowhere at all.
532
+ assert.equal(getCurrentFatalError()?.error !== undefined, true);
533
+ });
451
534
  });
@@ -12,13 +12,15 @@
12
12
  * nothing and takes the server's answer as it finds it.
13
13
  *
14
14
  * Polling follows the run: every 30 seconds while idle, every 5 seconds while a
15
- * run is in flight or this page is waiting on one it started.
15
+ * run is in flight, this page is waiting on one it started, or it is waiting on
16
+ * a check it pressed for.
16
17
  */
17
18
  import {
18
19
  systemOperationsApplySystemUpdateMutation,
19
20
  systemOperationsGetSystemUpdateOptions,
20
21
  systemOperationsGetSystemUpdateQueryKey,
21
22
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
23
+ import { systemOperationsGetSystemUpdate } from "@remit/api-http-client/sdk.gen.ts";
22
24
  import type {
23
25
  RemitImapSystemUpdateResponse,
24
26
  RemitImapSystemUpdateRun,
@@ -35,8 +37,13 @@ import {
35
37
  useRef,
36
38
  useState,
37
39
  } from "react";
40
+ import { shouldEscalate, softErrorMeta } from "@/lib/error-classifier";
41
+ import { reportFatalError } from "@/lib/fatal-error";
38
42
  import {
39
43
  appliesSchemaMigration,
44
+ type CheckPress,
45
+ checkAnswered,
46
+ checkRequestFailureReason,
40
47
  deriveUpdateSurface,
41
48
  type HeldRun,
42
49
  isSurfaceAbsent,
@@ -48,6 +55,19 @@ import {
48
55
  const IDLE_POLL_MS = 30_000;
49
56
  const RUN_POLL_MS = 5_000;
50
57
 
58
+ /**
59
+ * How long a pressed check waits for the updater before the pane calls it a
60
+ * failure. The backend only records the request; the updater picks it up on a
61
+ * five-second watch tick, so half a minute is several ticks — patient enough for
62
+ * a busy box, short enough that a press against a dead updater is answered
63
+ * rather than left spinning for good.
64
+ */
65
+ export const CHECK_ANSWER_BUDGET_MS = 30_000;
66
+
67
+ /** The press was recorded and nothing came back. Names the process and the log. */
68
+ export const UPDATER_SILENT_REASON =
69
+ "The updater did not answer. Run `remit logs updater` to see why.";
70
+
51
71
  export interface SelfUpdateApi {
52
72
  surface: UpdateSurface;
53
73
  /** Whether the pending release runs a schema migration during the window. */
@@ -55,7 +75,7 @@ export interface SelfUpdateApi {
55
75
  currentVersion: string | undefined;
56
76
  /** The available release, for the consent dialog. */
57
77
  release: ReleaseInfo | undefined;
58
- /** Refetch the surface, showing a `checking` pane until it settles. */
78
+ /** Ask the updater for a fresh check, showing a `checking` pane until it answers. */
59
79
  onCheck: () => void;
60
80
  /** Request a specific release — consent has been given. */
61
81
  install: (targetVersion: string) => void;
@@ -69,10 +89,13 @@ function pollInterval(
69
89
  error: unknown,
70
90
  run: RemitImapSystemUpdateRun | null,
71
91
  hasHeldRun: boolean,
92
+ hasPress: boolean,
72
93
  ): number | false {
73
94
  if (isSurfaceAbsent(error) && !hasHeldRun) return false;
74
95
  const inFlight = run !== null && run.outcome === null;
75
- if (hasHeldRun || inFlight) return RUN_POLL_MS;
96
+ // A press is waiting on the updater's next watch tick (#599), so it polls at
97
+ // the run cadence — and only until the wait ends, which drops the press.
98
+ if (hasHeldRun || inFlight || hasPress) return RUN_POLL_MS;
76
99
  return IDLE_POLL_MS;
77
100
  }
78
101
 
@@ -80,10 +103,13 @@ export function useSystemUpdate(): SelfUpdateApi {
80
103
  const queryClient = useQueryClient();
81
104
  const [held, setHeld] = useState<HeldRun | null>(null);
82
105
  const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
83
- const [checkRequested, setCheckRequested] = useState(false);
106
+ const [checkPress, setCheckPress] = useState<CheckPress | null>(null);
107
+ const [checkFailure, setCheckFailure] = useState<string | null>(null);
84
108
 
85
109
  const heldRef = useRef(held);
86
110
  heldRef.current = held;
111
+ const pressRef = useRef(checkPress);
112
+ pressRef.current = checkPress;
87
113
 
88
114
  const query = useQuery({
89
115
  ...systemOperationsGetSystemUpdateOptions(),
@@ -94,17 +120,21 @@ export function useSystemUpdate(): SelfUpdateApi {
94
120
  query.state.error,
95
121
  query.state.data?.run ?? null,
96
122
  heldRef.current !== null,
123
+ pressRef.current !== null,
97
124
  ),
98
125
  });
99
126
 
127
+ const dataRef = useRef(query.data);
128
+ dataRef.current = query.data;
129
+
100
130
  const derived = deriveUpdateSurface({
101
131
  data: query.data,
102
132
  isError: query.isError,
103
133
  error: query.error,
104
- isFetching: query.isFetching,
105
134
  held,
106
135
  dismissedRunId,
107
- checkRequested,
136
+ checkPress,
137
+ checkFailure,
108
138
  now: Date.now(),
109
139
  });
110
140
 
@@ -120,15 +150,62 @@ export function useSystemUpdate(): SelfUpdateApi {
120
150
  if (releaseHeld) setHeld((current) => (current === null ? current : null));
121
151
  }, [releaseHeld]);
122
152
 
153
+ // The updater answered the press: let go of it, so the next poll renders the
154
+ // verdict rather than the spinner.
123
155
  useEffect(() => {
124
- if (checkRequested && !query.isFetching) setCheckRequested(false);
125
- }, [checkRequested, query.isFetching]);
156
+ if (checkPress !== null && checkAnswered(checkPress, query.data))
157
+ setCheckPress(null);
158
+ }, [checkPress, query.data]);
159
+
160
+ // The wait has to end itself. A poll that answers with the same bytes changes
161
+ // nothing this hook reads, so nothing would re-render to notice the budget had
162
+ // run out, and the spinner would sit there for good (#599).
163
+ useEffect(() => {
164
+ if (checkPress === null) return;
165
+ const remaining =
166
+ checkPress.pressedAt + CHECK_ANSWER_BUDGET_MS - Date.now();
167
+ const timer = setTimeout(
168
+ () => {
169
+ setCheckPress(null);
170
+ setCheckFailure(UPDATER_SILENT_REASON);
171
+ },
172
+ Math.max(0, remaining),
173
+ );
174
+ return () => clearTimeout(timer);
175
+ }, [checkPress]);
126
176
 
127
177
  const { refetch } = query;
128
178
 
129
179
  const onCheck = useCallback(() => {
130
- setCheckRequested(true);
131
- void refetch();
180
+ // A plain refetch only re-reads state.json, so the answer would be exactly
181
+ // as old as it was before the press. refresh=true has the backend record a
182
+ // check request for the updater; the wait is this page's to keep, against
183
+ // the `lastCheckedAt` the server had when the control was pressed (#599).
184
+ setCheckFailure(null);
185
+ setCheckPress({
186
+ pressedAt: Date.now(),
187
+ since: dataRef.current?.check.lastCheckedAt,
188
+ });
189
+
190
+ void systemOperationsGetSystemUpdate({
191
+ query: { refresh: true },
192
+ throwOnError: true,
193
+ })
194
+ .then(() => {
195
+ void refetch();
196
+ })
197
+ .catch((error: unknown) => {
198
+ // The request never reached the seam, so nothing is coming. Say so
199
+ // where the press was made instead of reverting to the old verdict.
200
+ // This is a raw SDK call, outside the query cache that feeds the
201
+ // global sink, so the 5xx the seam answers when the control volume
202
+ // is unwritable escalates from here or from nowhere.
203
+ if (shouldEscalate(error, softErrorMeta, "user")) {
204
+ reportFatalError(error);
205
+ }
206
+ setCheckPress(null);
207
+ setCheckFailure(checkRequestFailureReason(error));
208
+ });
132
209
  }, [refetch]);
133
210
 
134
211
  const onRetryConnection = useCallback(() => {
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Regression cover for issue #602 — the reading pane's star did not toggle and
3
+ * never showed the open message as starred.
4
+ *
5
+ * Both symptoms were one cause: the control answered from the list row that
6
+ * represented the conversation, not from the conversation. A row is a copy that
7
+ * stops being refreshed the moment the mail no longer matches the browsed
8
+ * predicate, so the star read unstarred however many times it had been set, and
9
+ * every press asked the server to star a message that already was.
10
+ *
11
+ * Each case mounts the real toolbar over a mocked server and lets the
12
+ * conversation's own listing disagree with the stale list row.
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import { afterEach, describe, it } from "node:test";
16
+ import { threadDetailOperationsListThreadMessagesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
17
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
18
+ import { createElement } from "react";
19
+ import { MessageToolbar } from "@/components/mail/MessageToolbar";
20
+ import { useThreadActions } from "@/hooks/useThreadActions";
21
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
22
+ import { makeConfig, makeThreadMessage } from "../test-support/fixtures";
23
+ import { type HttpMock, mockFetch } from "../test-support/http";
24
+
25
+ const MESSAGE_ID = "msg-1";
26
+ const THREAD_ID = "thread-1";
27
+
28
+ let harness: DomHarness | undefined;
29
+ let http: HttpMock | undefined;
30
+
31
+ afterEach(() => {
32
+ harness?.close();
33
+ harness = undefined;
34
+ http?.restore();
35
+ http = undefined;
36
+ });
37
+
38
+ interface ServerState {
39
+ starred: boolean;
40
+ }
41
+
42
+ const conversationKey = threadDetailOperationsListThreadMessagesQueryKey({
43
+ path: { threadId: THREAD_ID },
44
+ });
45
+
46
+ const conversationRow = (starred: boolean) =>
47
+ makeThreadMessage({
48
+ messageId: MESSAGE_ID,
49
+ threadId: THREAD_ID,
50
+ hasStars: starred,
51
+ });
52
+
53
+ /**
54
+ * The message as the conversation endpoint serves it, and the list row as the
55
+ * mailbox listing served it — which is deliberately allowed to disagree.
56
+ */
57
+ const mountToolbar = async (options: {
58
+ server: ServerState;
59
+ row: Partial<RemitImapThreadMessageResponse>;
60
+ /** Defaults to the reading pane's case: this thread is the one on screen. */
61
+ isOpen?: boolean;
62
+ }) => {
63
+ const { server, isOpen = true } = options;
64
+ http = mockFetch((call) => {
65
+ if (call.method === "PATCH") {
66
+ const body = call.body as { isStarred?: boolean } | undefined;
67
+ server.starred = body?.isStarred === true;
68
+ return {
69
+ messageId: MESSAGE_ID,
70
+ isRead: true,
71
+ isStarred: server.starred,
72
+ };
73
+ }
74
+ if (call.path.includes("/config")) return makeConfig([]);
75
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
76
+ return { items: [conversationRow(server.starred)] };
77
+ }
78
+ return { items: [] };
79
+ });
80
+
81
+ const thread = makeThreadMessage({
82
+ messageId: MESSAGE_ID,
83
+ threadId: THREAD_ID,
84
+ ...options.row,
85
+ });
86
+
87
+ const Harness = () => {
88
+ const actions = useThreadActions({ thread, isOpen });
89
+ return createElement(MessageToolbar, {
90
+ hasThread: true,
91
+ intelligenceOpen: false,
92
+ canToggleIntelligence: false,
93
+ onToggleIntelligence: () => undefined,
94
+ onToggleStar: actions.toggleStar,
95
+ isStarred: actions.isStarred,
96
+ });
97
+ };
98
+
99
+ harness = createDomHarness();
100
+ // The conversation as the server has it, in the cache before the first
101
+ // render — the pane answers from this, not from the list row.
102
+ harness.queryClient.setQueryData(conversationKey, {
103
+ items: [conversationRow(server.starred)],
104
+ });
105
+ harness.renderApp(createElement(Harness));
106
+ await harness.flush();
107
+ await harness.wait(20);
108
+ await harness.flush();
109
+ return harness;
110
+ };
111
+
112
+ const starButton = (dom: DomHarness) => dom.byLabel("Star");
113
+
114
+ const rendersStarred = (dom: DomHarness): boolean =>
115
+ starButton(dom).getAttribute("aria-pressed") === "true";
116
+
117
+ const conversationRequests = (): number =>
118
+ http?.to(`/threads/${THREAD_ID}/messages`).length ?? 0;
119
+
120
+ const starRequests = (): Array<boolean | undefined> =>
121
+ (http?.calls ?? [])
122
+ .filter((call) => call.method === "PATCH")
123
+ .map(
124
+ (call) => (call.body as { isStarred?: boolean } | undefined)?.isStarred,
125
+ );
126
+
127
+ describe("the reading pane's star follows the open message (#602)", () => {
128
+ it("shows the message starred when the conversation says so, though its list row does not", async () => {
129
+ const dom = await mountToolbar({
130
+ server: { starred: true },
131
+ // What the mailbox listing last said about this message, from before
132
+ // it was starred — the state the pane used to answer from (#602).
133
+ row: { hasStars: false },
134
+ });
135
+
136
+ assert.equal(
137
+ rendersStarred(dom),
138
+ true,
139
+ "the star answers from the conversation, not the list row",
140
+ );
141
+ });
142
+
143
+ it("asks for the star to come off when a lit star is pressed", async () => {
144
+ const server: ServerState = { starred: true };
145
+ const dom = await mountToolbar({ server, row: { hasStars: false } });
146
+
147
+ dom.click(starButton(dom));
148
+ await dom.flush();
149
+ assert.deepEqual(
150
+ starRequests(),
151
+ [false],
152
+ "pressing a lit star unstars the message",
153
+ );
154
+ });
155
+
156
+ it("stars, then unstars, on consecutive presses", async () => {
157
+ const server: ServerState = { starred: false };
158
+ const dom = await mountToolbar({ server, row: { hasStars: false } });
159
+ assert.equal(rendersStarred(dom), false);
160
+
161
+ dom.click(starButton(dom));
162
+ await dom.flush();
163
+ assert.equal(server.starred, true);
164
+ assert.deepEqual(starRequests(), [true]);
165
+
166
+ // Nothing is put into the cache by hand here: the settle-invalidate has
167
+ // to re-read the conversation, which the mock now serves starred. Seeding
168
+ // it would make the second press pass even if the refresh never happened.
169
+ await dom.wait(20);
170
+ await dom.flush();
171
+ assert.equal(
172
+ rendersStarred(dom),
173
+ true,
174
+ "the star lights from the refreshed conversation",
175
+ );
176
+
177
+ dom.click(starButton(dom));
178
+ await dom.flush();
179
+ assert.equal(server.starred, false, "the second press unstars");
180
+ assert.deepEqual(starRequests(), [true, false]);
181
+ });
182
+
183
+ it("asks for no conversation for a thread the pane has not opened", async () => {
184
+ // The triage cursor moves a row at a time. A conversation pulled for the
185
+ // row under the cursor would be a request per keystroke, so a target that
186
+ // is not the open thread answers from its own listing row.
187
+ const dom = await mountToolbar({
188
+ server: { starred: false },
189
+ row: { hasStars: true },
190
+ isOpen: false,
191
+ });
192
+
193
+ assert.equal(conversationRequests(), 0);
194
+ assert.equal(
195
+ rendersStarred(dom),
196
+ true,
197
+ "the cursor target answers from the row it came from",
198
+ );
199
+ });
200
+ });
@@ -15,10 +15,22 @@ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
15
15
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
16
16
  import { useMoveMessages } from "@/hooks/useMoveMessages";
17
17
  import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
18
+ import { useThreadConversation } from "@/hooks/useThreadRow";
18
19
  import { useToggleStar } from "@/hooks/useToggleStar";
19
20
 
20
21
  interface UseThreadActionsOptions {
21
22
  thread: RemitImapThreadMessageResponse | undefined;
23
+ /**
24
+ * Whether the pane has this thread open, so its conversation is on screen
25
+ * and already fetched. The star then answers from that conversation: a list
26
+ * row is a copy that survives its listing, and once the mail no longer
27
+ * matches the browsed predicate the row stops being refreshed, so a star
28
+ * read off it is whatever was true when it was last listed (#602).
29
+ *
30
+ * A cursor target is not open — it answers from its own live listing row,
31
+ * because pulling a conversation for it would be a request per keystroke.
32
+ */
33
+ isOpen?: boolean;
22
34
  /** Mailbox whose listings the mutations patch. Defaults to the thread's own. */
23
35
  mailboxId?: string;
24
36
  /** Account the move picker offers folders from. Defaults to the thread's own. */
@@ -37,6 +49,7 @@ export interface ThreadActions {
37
49
 
38
50
  export const useThreadActions = ({
39
51
  thread,
52
+ isOpen = false,
40
53
  mailboxId,
41
54
  accountId,
42
55
  onAfterOptimisticRemove,
@@ -67,9 +80,22 @@ export const useThreadActions = ({
67
80
  onAfterOptimisticRemove,
68
81
  });
69
82
 
83
+ // The conversation the pane has open, off the entry `ConversationView` and
84
+ // `useThreadRow` already hold, so the toolbar and the message card answer
85
+ // from the same row for no extra request.
86
+ const conversation = useThreadConversation(
87
+ isOpen ? thread?.threadId : undefined,
88
+ );
89
+
90
+ const openMessage = conversation.find(
91
+ (message) => message.messageId === thread?.messageId,
92
+ );
93
+ const isStarred = openMessage?.hasStars ?? thread?.hasStars;
94
+
70
95
  const { toggleStar: toggleStarFor } = useToggleStar({
71
96
  threadId: thread?.threadId ?? "",
72
97
  mailboxId: resolvedMailboxId ?? "",
98
+ messages: conversation,
73
99
  });
74
100
 
75
101
  const deleteThread = useCallback(() => {
@@ -87,13 +113,13 @@ export const useThreadActions = ({
87
113
 
88
114
  const toggleStar = useCallback(() => {
89
115
  if (!thread) return;
90
- toggleStarFor(thread.messageId, thread.hasStars);
91
- }, [thread, toggleStarFor]);
116
+ toggleStarFor(thread.messageId, isStarred ?? false);
117
+ }, [thread, isStarred, toggleStarFor]);
92
118
 
93
119
  return {
94
120
  mailboxId: resolvedMailboxId,
95
121
  accountId: resolvedAccountId,
96
- isStarred: thread?.hasStars,
122
+ isStarred,
97
123
  deleteThread,
98
124
  moveThread,
99
125
  toggleStar,
@@ -2,6 +2,28 @@ import { threadDetailOperationsListThreadMessagesOptions } from "@remit/api-http
2
2
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
 
5
+ const NO_MESSAGES: RemitImapThreadMessageResponse[] = [];
6
+
7
+ /**
8
+ * The messages a conversation holds, on the key the reading pane already uses.
9
+ *
10
+ * The single owner of `GET /threads/{threadId}/messages` for the client's
11
+ * hooks: everything that needs the conversation resolves off this one cache
12
+ * entry rather than a round trip of its own. A `threadId` of `undefined` asks
13
+ * for nothing.
14
+ */
15
+ export const useThreadConversation = (
16
+ threadId: string | undefined,
17
+ ): RemitImapThreadMessageResponse[] => {
18
+ const { data } = useQuery({
19
+ ...threadDetailOperationsListThreadMessagesOptions({
20
+ path: { threadId: threadId ?? "" },
21
+ }),
22
+ enabled: Boolean(threadId),
23
+ });
24
+ return data?.items ?? NO_MESSAGES;
25
+ };
26
+
5
27
  /**
6
28
  * A thread's own row for the message the reader pointed at.
7
29
  *
@@ -9,8 +31,7 @@ import { useQuery } from "@tanstack/react-query";
9
31
  * cross-folder search hit, or an address pasted into a fresh tab.
10
32
  * `GET /threads/{threadId}/messages` returns the same row shape a listing does,
11
33
  * `mailboxId` included, so the thread id is the whole address even though the
12
- * mail is filed in some folder. The reading pane already makes that request, so
13
- * this resolves off the same cache entry rather than a round trip of its own.
34
+ * mail is filed in some folder.
14
35
  *
15
36
  * A thread spans folders — the reader's own reply sits in Sent — so `messageId`
16
37
  * picks the row they pointed at. With none, the newest message answers.
@@ -19,14 +40,7 @@ export const useThreadRow = (
19
40
  threadId: string | undefined,
20
41
  messageId: string | undefined,
21
42
  ): RemitImapThreadMessageResponse | undefined => {
22
- const { data } = useQuery({
23
- ...threadDetailOperationsListThreadMessagesOptions({
24
- path: { threadId: threadId ?? "" },
25
- }),
26
- enabled: Boolean(threadId),
27
- });
28
-
29
- const items = data?.items ?? [];
43
+ const items = useThreadConversation(threadId);
30
44
  const pointedAt = messageId
31
45
  ? items.find((item) => item.messageId === messageId)
32
46
  : undefined;
@@ -8,6 +8,7 @@ import { ApiError } from "./api";
8
8
  import {
9
9
  APPLY_BUDGET_SECONDS,
10
10
  appliesSchemaMigration,
11
+ checkRequestFailureReason,
11
12
  type DeriveInput,
12
13
  deriveUpdateSurface,
13
14
  type HeldRun,
@@ -67,10 +68,10 @@ function input(overrides: Partial<DeriveInput> = {}): DeriveInput {
67
68
  data: undefined,
68
69
  isError: false,
69
70
  error: undefined,
70
- isFetching: false,
71
71
  held: null,
72
72
  dismissedRunId: null,
73
- checkRequested: false,
73
+ checkPress: null,
74
+ checkFailure: null,
74
75
  now: NOW,
75
76
  ...overrides,
76
77
  };
@@ -229,12 +230,16 @@ describe("deriveUpdateSurface — the surface without a run", () => {
229
230
  );
230
231
  });
231
232
 
232
- test("a pressed check shows checking while the refetch is in flight", () => {
233
+ test("a press keeps checking while the server still reports the answer it had (#599)", () => {
234
+ const stale = {
235
+ status: "ok" as const,
236
+ updateAvailable: false,
237
+ lastCheckedAt: "2026-07-20T11:00:00.000Z",
238
+ };
233
239
  const result = deriveUpdateSurface(
234
240
  input({
235
- data: response(),
236
- checkRequested: true,
237
- isFetching: true,
241
+ data: response({ check: stale }),
242
+ checkPress: { pressedAt: NOW - 5_000, since: stale.lastCheckedAt },
238
243
  }),
239
244
  );
240
245
  assert.equal(
@@ -242,6 +247,52 @@ describe("deriveUpdateSurface — the surface without a run", () => {
242
247
  "checking",
243
248
  );
244
249
  });
250
+
251
+ test("a press settles the moment the server reports a newer lastCheckedAt (#599)", () => {
252
+ const result = deriveUpdateSurface(
253
+ input({
254
+ data: response({
255
+ check: {
256
+ status: "ok",
257
+ updateAvailable: false,
258
+ lastCheckedAt: "2026-07-20T11:59:50.000Z",
259
+ },
260
+ }),
261
+ checkPress: {
262
+ pressedAt: NOW - 5_000,
263
+ since: "2026-07-20T11:00:00.000Z",
264
+ },
265
+ }),
266
+ );
267
+ assert.equal(
268
+ result.surface.status === "ready" && result.surface.section.status,
269
+ "upToDate",
270
+ );
271
+ });
272
+
273
+ test("a failed press is reported verbatim, never swallowed into the old verdict (#599)", () => {
274
+ const result = deriveUpdateSurface(
275
+ input({
276
+ data: response({
277
+ check: {
278
+ status: "ok",
279
+ updateAvailable: true,
280
+ latestVersion: "0.9.4",
281
+ lastCheckedAt: "2026-07-20T11:00:00.000Z",
282
+ },
283
+ }),
284
+ checkFailure: checkRequestFailureReason(new ApiError("boom", 500)),
285
+ }),
286
+ );
287
+ assert.equal(result.surface.status, "ready");
288
+ if (result.surface.status !== "ready") return;
289
+ const section = result.surface.section;
290
+ assert.equal(section.status, "checkFailed");
291
+ if (section.status !== "checkFailed") return;
292
+ assert.match(section.reason, /answered 500/);
293
+ // The age of the answer it is still showing survives the failure.
294
+ assert.equal(section.lastCheckedAt, Date.parse("2026-07-20T11:00:00.000Z"));
295
+ });
245
296
  });
246
297
 
247
298
  describe("deriveUpdateSurface — check and run stay independent", () => {
@@ -57,6 +57,44 @@ export interface HeldRun {
57
57
  startedAt: number;
58
58
  }
59
59
 
60
+ /**
61
+ * The check this page asked for, held for as long as the page that asked lives.
62
+ * The server records the request and answers with the state it already had, so
63
+ * the wait is the client's to keep: `since` is `check.lastCheckedAt` as the
64
+ * server reported it at the moment of the press, and the answer has landed once
65
+ * the server reports a different one. Comparing against that stored value rather
66
+ * than against the press's own clock keeps the rule honest on a box whose clock
67
+ * differs from the browser's.
68
+ */
69
+ export interface CheckPress {
70
+ /** Epoch millis when the control was pressed, for the bounded wait. */
71
+ pressedAt: number;
72
+ since: string | undefined;
73
+ }
74
+
75
+ export function checkAnswered(
76
+ press: CheckPress,
77
+ data: RemitImapSystemUpdateResponse | undefined,
78
+ ): boolean {
79
+ const lastCheckedAt = data?.check.lastCheckedAt;
80
+ return lastCheckedAt !== undefined && lastCheckedAt !== press.since;
81
+ }
82
+
83
+ /**
84
+ * Why the request the press fired never reached the seam. The status is named
85
+ * because a 500 here is a real fault on the box — the control volume unwritable
86
+ * is the one seen in the wild — and a press that quietly re-served the old
87
+ * verdict is exactly how #599 stayed invisible for an hour.
88
+ */
89
+ export function checkRequestFailureReason(error: unknown): string {
90
+ const status = getErrorStatus(error);
91
+ const cause =
92
+ status === undefined
93
+ ? "the server did not answer"
94
+ : `the server answered ${status}`;
95
+ return `The check could not be requested — ${cause}. Run \`remit logs backend\` to see why.`;
96
+ }
97
+
60
98
  export type UpdateOverlay =
61
99
  | { kind: "none" }
62
100
  | {
@@ -82,10 +120,12 @@ export interface DeriveInput {
82
120
  data: RemitImapSystemUpdateResponse | undefined;
83
121
  isError: boolean;
84
122
  error: unknown;
85
- isFetching: boolean;
86
123
  held: HeldRun | null;
87
124
  dismissedRunId: string | null;
88
- checkRequested: boolean;
125
+ /** The check this page pressed for, or null when it is not waiting on one. */
126
+ checkPress: CheckPress | null;
127
+ /** Why the request that press fired failed, or null when it did not. */
128
+ checkFailure: string | null;
89
129
  now: number;
90
130
  }
91
131
 
@@ -238,27 +278,49 @@ function terminalSection(
238
278
 
239
279
  function checkSection(
240
280
  data: RemitImapSystemUpdateResponse,
241
- isChecking: boolean,
281
+ press: CheckPress | null,
282
+ failure: string | null,
242
283
  now: number,
243
284
  ): SelfUpdateState {
244
285
  const check = data.check;
245
- if (isChecking) return { status: "checking", version: data.currentVersion };
286
+ const lastCheckedAt = parseIso(check.lastCheckedAt);
287
+
288
+ if (failure !== null) {
289
+ return {
290
+ status: "checkFailed",
291
+ version: data.currentVersion,
292
+ reason: failure,
293
+ lastCheckedAt,
294
+ };
295
+ }
296
+
297
+ // The press is recorded and the updater has not answered it: the pane waits on
298
+ // a genuinely newer `lastCheckedAt` rather than re-serving the verdict it
299
+ // already had (#599). The caller drops the press when the wait runs out, which
300
+ // arrives here as a failure.
301
+ if (press !== null && !checkAnswered(press, data)) {
302
+ return { status: "checking", version: data.currentVersion };
303
+ }
246
304
 
247
305
  if (check.status === "failed") {
248
306
  return {
249
307
  status: "checkFailed",
250
308
  version: data.currentVersion,
251
309
  reason: check.error ?? "Remit could not reach the update service.",
252
- lastCheckedAt: parseIso(check.lastCheckedAt),
310
+ lastCheckedAt,
253
311
  };
254
312
  }
255
313
 
256
314
  // A configured surface that has not run its first check yet is never-checked,
257
315
  // not checking: the updater runs the check on a cadence and has not written a
258
316
  // result. A spinner here would run forever, since nothing on this poll is in
259
- // flight. Only a genuine refetch (handled above) shows the spinner.
317
+ // flight. Only a press this page is still waiting on shows the spinner.
260
318
  if (check.status === "disabled") {
261
- return { status: "neverChecked", version: data.currentVersion };
319
+ return {
320
+ status: "neverChecked",
321
+ version: data.currentVersion,
322
+ lastCheckedAt,
323
+ };
262
324
  }
263
325
 
264
326
  const release = releaseFromCheck(data, now);
@@ -412,7 +474,7 @@ function deriveHeld(
412
474
  }
413
475
 
414
476
  function displayFromData(input: DeriveInput): UpdateSurface {
415
- const { data, isError, isFetching, dismissedRunId, checkRequested, now } =
477
+ const { data, isError, dismissedRunId, checkPress, checkFailure, now } =
416
478
  input;
417
479
 
418
480
  if (isError) {
@@ -464,7 +526,7 @@ function displayFromData(input: DeriveInput): UpdateSurface {
464
526
 
465
527
  return {
466
528
  status: "ready",
467
- section: checkSection(data, checkRequested && isFetching, now),
529
+ section: checkSection(data, checkPress, checkFailure, now),
468
530
  overlay: { kind: "none" },
469
531
  };
470
532
  }