@remit/web-client 0.0.113 → 0.0.115

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.113",
3
+ "version": "0.0.115",
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,41 +1,20 @@
1
1
  /**
2
- * MailTopBar — the app's one search surface and its global actions.
2
+ * MailTopBar — the `/mail` shell's top bar, wired to the app.
3
3
  *
4
- * Mounted by the `/mail` shell across the top of the whole layout the nav
5
- * column, the list, the reading pane and the intelligence rail. It is the app's
6
- * search, not the list's: the list header drops its own field wherever this bar
7
- * is mounted, so exactly one search input exists on the page and the "/"
8
- * shortcut has one target.
9
- *
10
- * The actions here are the ones that belong to the app rather than to whatever
11
- * is currently listed or open — the nav toggle, compose, bug report, settings,
12
- * account. Reply, delete, move and the rest stay on the reading pane's own
13
- * toolbar, under this bar.
14
- *
15
- * The field carries one chip: the scope of the view the user navigated into
16
- * (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
17
- * searches everything. Typed `in:`/`from:` terms are not chipped here — they
18
- * are already visible as the text the user typed, and chipping them would show
19
- * the same term twice in one field; they render as chips over the result
20
- * sections instead, where the text is not repeated.
4
+ * The bar itself is `ShellTopBar` in the kit. This supplies what the kit cannot
5
+ * know: the search scope the route carries, the app's routes, its keymap and
6
+ * its signed-in session. Which actions the bar carries, in what order and with
7
+ * what wording is not decided here.
21
8
  */
22
9
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
23
- import { AppTopBar, Button, NavToggleButton, SearchBar } from "@remit/ui";
10
+ import { ShellTopBar } from "@remit/ui";
24
11
  import { useNavigate } from "@tanstack/react-router";
25
- import { Settings, SquarePen } from "lucide-react";
26
12
  import { AccountMenu } from "@/auth/AccountMenu";
27
- import { BugReportButton } from "@/components/ui/BugReportButton";
28
13
  import { useGlobalCompose } from "@/hooks/useComposeTarget";
29
14
  import { useSearchScope } from "@/hooks/useSearchScope";
30
- import { tooltipForAction } from "@/lib/keymap";
15
+ import { openBugReport } from "@/lib/bug-report";
16
+ import { shortcutHintForAction } from "@/lib/keymap";
31
17
  import { useMailContext } from "@/lib/mail-context";
32
- import type { SearchScopeState } from "@/lib/search-scope";
33
-
34
- const SEARCH_PLACEHOLDER: Record<SearchScopeState["kind"], string> = {
35
- global: "Search all mail",
36
- pending: "Search mail",
37
- scoped: "Search this folder",
38
- };
39
18
 
40
19
  interface MailTopBarProps {
41
20
  accounts: RemitImapAccountResponse[];
@@ -51,47 +30,23 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
51
30
  scope.kind === "scoped"
52
31
  ? [{ id: scope.chip.id, label: scope.chip.label, tone: "scope" as const }]
53
32
  : undefined;
54
- // Only the brief may claim to search all mail. A mailbox route whose name has
55
- // not loaded yet has no chip to show but is already narrowed, so it gets the
56
- // neutral wording rather than a placeholder that asserts the wrong scope.
57
- const placeholder = SEARCH_PLACEHOLDER[scope.kind];
58
33
 
59
34
  return (
60
- <AppTopBar
61
- leading={<NavToggleButton />}
62
- search={
63
- <SearchBar
64
- value={searchInput}
65
- onChange={onSearchChange}
66
- onClear={onSearchClear}
67
- onClearQuery={onSearchClearQuery}
68
- chips={chips}
69
- onRemoveChip={clearScope}
70
- placeholder={placeholder}
71
- />
72
- }
73
- actions={
74
- <>
75
- <Button
76
- variant="ghost"
77
- size="sm"
78
- icon={<SquarePen className="size-4" />}
79
- title={`Compose ${tooltipForAction("compose")}`}
80
- aria-label="Compose"
81
- onClick={compose}
82
- />
83
- <BugReportButton />
84
- <Button
85
- variant="ghost"
86
- size="sm"
87
- icon={<Settings className="size-4" />}
88
- title="Settings"
89
- aria-label="Settings"
90
- onClick={() => navigate({ to: "/settings/accounts" })}
91
- />
92
- <AccountMenu />
93
- </>
94
- }
35
+ <ShellTopBar
36
+ search={{
37
+ value: searchInput,
38
+ scope: scope.kind,
39
+ chips,
40
+ onChange: onSearchChange,
41
+ onClear: onSearchClear,
42
+ onClearQuery: onSearchClearQuery,
43
+ onRemoveChip: clearScope,
44
+ }}
45
+ onCompose={compose}
46
+ onReportBug={openBugReport}
47
+ onOpenSettings={() => navigate({ to: "/settings/accounts" })}
48
+ composeShortcut={shortcutHintForAction("compose")}
49
+ account={<AccountMenu />}
95
50
  />
96
51
  );
97
52
  }
@@ -47,7 +47,7 @@ export function MailNav({ accounts, onMailboxSelect }: MailNavProps) {
47
47
  <Settings className="size-4 shrink-0" />
48
48
  <span className="flex-1 truncate text-left">Settings</span>
49
49
  </Link>
50
- <BugReportButton variant="drawer" />
50
+ <BugReportButton />
51
51
  <AccountSession>
52
52
  {({ signOut }) => (
53
53
  <button
@@ -1,45 +1,20 @@
1
- import { Button } from "@remit/ui";
2
1
  import { Bug } from "lucide-react";
3
- import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
4
-
5
- interface BugReportButtonProps {
6
- /**
7
- * `icon` (default) is the compact ghost icon button used in the desktop
8
- * message toolbar. `drawer` renders a full-width labeled row to sit beside
9
- * Settings in the mobile drawer footer, where an icon-only control would be
10
- * unreachable/ambiguous (#685).
11
- */
12
- variant?: "icon" | "drawer";
13
- }
14
-
15
- const openBugReport = () => {
16
- const ctx = buildBugReportContext();
17
- const url = buildGitHubIssueUrl(ctx);
18
- window.open(url, "_blank", "noopener,noreferrer");
19
- };
20
-
21
- export function BugReportButton({ variant = "icon" }: BugReportButtonProps) {
22
- if (variant === "drawer") {
23
- return (
24
- <button
25
- type="button"
26
- onClick={openBugReport}
27
- className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
28
- >
29
- <Bug className="size-4 shrink-0" />
30
- <span className="flex-1 truncate text-left">Report a bug</span>
31
- </button>
32
- );
33
- }
2
+ import { openBugReport } from "@/lib/bug-report";
34
3
 
4
+ /**
5
+ * The mobile drawer's bug-report row — a full-width labeled control beside
6
+ * Settings, where an icon-only button would be unreachable and ambiguous
7
+ * (#685). Above the drawer, the shell's top bar carries this action.
8
+ */
9
+ export function BugReportButton() {
35
10
  return (
36
- <Button
37
- variant="ghost"
38
- size="sm"
39
- icon={<Bug className="size-4" />}
40
- title="Report a bug"
41
- aria-label="Report a bug"
11
+ <button
12
+ type="button"
42
13
  onClick={openBugReport}
43
- />
14
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
15
+ >
16
+ <Bug className="size-4 shrink-0" />
17
+ <span className="flex-1 truncate text-left">Report a bug</span>
18
+ </button>
44
19
  );
45
20
  }
@@ -236,6 +236,28 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
236
236
  assert.match(dom.html(), /has not answered since the restart/);
237
237
  assert.match(dom.html(), /remit logs/);
238
238
  });
239
+
240
+ test("a poll that never answers gives up at the budget too", async () => {
241
+ saveHeldRun({
242
+ runId: "upd_1",
243
+ attemptedVersion: "0.9.4",
244
+ previousVersion: "0.9.3",
245
+ startedAt: Date.now() - 60 * 60_000,
246
+ });
247
+ http = mockFetch(() => new Promise(() => {}));
248
+ harness = createDomHarness();
249
+ harness.renderApp(
250
+ createElement(SelfUpdateProvider, null, createElement(SelfUpdateOverlay)),
251
+ );
252
+ await harness.flush();
253
+ await harness.wait(1);
254
+ await harness.flush();
255
+
256
+ assert.doesNotMatch(harness.html(), /Installing Remit 0\.9\.4/);
257
+ assert.match(harness.html(), /has not answered since the restart/);
258
+ assert.match(harness.html(), /remit logs/);
259
+ assert.equal(loadHeldRun(), null);
260
+ });
239
261
  });
240
262
 
241
263
  describe("useSystemUpdate — actions", () => {
@@ -310,3 +310,8 @@ export function buildGitHubIssueUrl(ctx: BugReportContext): string {
310
310
 
311
311
  return issueUrl(withoutComponentStack, overrides);
312
312
  }
313
+
314
+ export function openBugReport(): void {
315
+ const url = buildGitHubIssueUrl(buildBugReportContext());
316
+ window.open(url, "_blank", "noopener,noreferrer");
317
+ }
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
- import { KEY_HINT_GROUPS, keysForAction, tooltipForAction } from "./keymap.ts";
3
+ import {
4
+ KEY_HINT_GROUPS,
5
+ keysForAction,
6
+ shortcutHintForAction,
7
+ tooltipForAction,
8
+ } from "./keymap.ts";
4
9
 
5
10
  describe("keymap module", () => {
6
11
  test("exposes the documented groups in reading order", () => {
@@ -35,6 +40,20 @@ describe("keymap module", () => {
35
40
  assert.strictEqual(tooltipForAction("compose"), "(c)");
36
41
  });
37
42
 
43
+ test("shortcutHintForAction renders the binding without the parens", () => {
44
+ assert.strictEqual(shortcutHintForAction("reply"), "r");
45
+ assert.strictEqual(shortcutHintForAction("goBrief"), "g then b");
46
+ assert.strictEqual(shortcutHintForAction("compose"), "c");
47
+ });
48
+
49
+ test("an action with no binding gets no hint and no empty parens", () => {
50
+ const unbound = "totallyMissing" as Parameters<
51
+ typeof shortcutHintForAction
52
+ >[0];
53
+ assert.strictEqual(shortcutHintForAction(unbound), "");
54
+ assert.strictEqual(tooltipForAction(unbound), "");
55
+ });
56
+
38
57
  test("every hint's action is a non-empty key list", () => {
39
58
  for (const group of KEY_HINT_GROUPS) {
40
59
  for (const hint of group.hints) {
package/src/lib/keymap.ts CHANGED
@@ -188,14 +188,23 @@ export function keysForAction(action: TriageAction): string[] | undefined {
188
188
  }
189
189
 
190
190
  /**
191
- * Render an action's binding as a compact tooltip suffix, e.g. "(r)" or
192
- * "(g then b)". Returns "" when the action has no hint.
191
+ * Render an action's binding as it reads to a user, e.g. "r" or "g then b".
192
+ * Returns "" when the action has no hint.
193
193
  */
194
- export function tooltipForAction(action: TriageAction): string {
194
+ export function shortcutHintForAction(action: TriageAction): string {
195
195
  const keys = keysForAction(action);
196
196
  if (!keys || keys.length === 0) return "";
197
- if (keys.length === 1) return `(${keys[0]})`;
197
+ if (keys.length === 1) return keys[0] ?? "";
198
198
  // Sequence (go-to) keys read as "g then b"; modifier combos as "⌘N".
199
- if (keys[0] === "g") return `(${keys.join(" then ")})`;
200
- return `(${keys.join("")})`;
199
+ if (keys[0] === "g") return keys.join(" then ");
200
+ return keys.join("");
201
+ }
202
+
203
+ /**
204
+ * Render an action's binding as a compact tooltip suffix, e.g. "(r)" or
205
+ * "(g then b)". Returns "" when the action has no hint.
206
+ */
207
+ export function tooltipForAction(action: TriageAction): string {
208
+ const hint = shortcutHintForAction(action);
209
+ return hint === "" ? "" : `(${hint})`;
201
210
  }
@@ -470,6 +470,30 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
470
470
  assert.equal(result.releaseHeld, true);
471
471
  });
472
472
 
473
+ test("a first poll still in flight keeps applying inside the budget", () => {
474
+ const result = deriveUpdateSurface(
475
+ input({ held: held({ startedAt: NOW - 20_000 }) }),
476
+ );
477
+ if (
478
+ result.surface.status !== "ready" ||
479
+ result.surface.overlay.kind !== "applying"
480
+ ) {
481
+ assert.fail("expected the applying overlay");
482
+ }
483
+ assert.equal(result.surface.overlay.phase, "preparing");
484
+ assert.equal(result.clearStoredRun, false);
485
+ });
486
+
487
+ test("a poll that never answers gives up at the budget", () => {
488
+ const result = deriveUpdateSurface(
489
+ input({ held: held({ startedAt: NOW - BUDGET_MS - 60_000 }) }),
490
+ );
491
+ assert.equal(result.surface.status, "ready");
492
+ if (result.surface.status !== "ready") return;
493
+ assert.equal(result.surface.overlay.kind, "neverCameBack");
494
+ assert.equal(result.clearStoredRun, true);
495
+ });
496
+
473
497
  test("an early server answer with no run yet keeps applying", () => {
474
498
  const result = deriveUpdateSurface(
475
499
  input({ data: response({ run: null }), held: held() }),
@@ -9,9 +9,10 @@
9
9
  *
10
10
  * The design constraints (RFC 037 Interface, issue #135) live here:
11
11
  * - A held run id turns a failed request into `applying`, never `unreachable`.
12
- * - Once the apply budget plus a margin has passed with no answer, the same
13
- * failure becomes "the server never came back" a state that never claims
14
- * the rollback ran, because from a dead connection the client cannot know.
12
+ * - Once the apply budget plus a margin has passed with no answer a failed
13
+ * request or one still in flightthat silence becomes "the server never
14
+ * came back", a state that never claims the rollback ran, because from a
15
+ * dead connection the client cannot know.
15
16
  * - The check block and the run block are independent: a check that cannot
16
17
  * reach the update source is a failed check, never a failed update.
17
18
  */
@@ -325,6 +326,35 @@ function ready(
325
326
  };
326
327
  }
327
328
 
329
+ /**
330
+ * The client gave up waiting. The stored token goes so a reload cannot resume
331
+ * the same dead wait, while the in-memory hold stays: the screen has to sit
332
+ * still, and its retry has to keep polling, until the server answers for itself.
333
+ */
334
+ function neverCameBack(held: HeldRun, elapsedSeconds: number): DeriveResult {
335
+ return {
336
+ surface: {
337
+ status: "ready",
338
+ section: applyingSection(
339
+ held.runId,
340
+ held.previousVersion,
341
+ held.attemptedVersion,
342
+ "reconnecting",
343
+ elapsedSeconds,
344
+ ),
345
+ overlay: {
346
+ kind: "neverCameBack",
347
+ attemptedVersion: held.attemptedVersion,
348
+ previousVersion: held.previousVersion,
349
+ elapsedSeconds,
350
+ logsCommand: FALLBACK_LOGS_COMMAND,
351
+ },
352
+ },
353
+ clearStoredRun: true,
354
+ releaseHeld: false,
355
+ };
356
+ }
357
+
328
358
  /**
329
359
  * A held run resolves to one of: still applying, gave up ("never came back"),
330
360
  * recovered but unaccountable, or — returning `null` — resolved terminally on
@@ -360,32 +390,17 @@ function deriveHeld(
360
390
  }
361
391
 
362
392
  if (isError) {
363
- const section = applyingSection(
364
- held.runId,
365
- held.previousVersion,
366
- held.attemptedVersion,
367
- "reconnecting",
368
- elapsedSeconds,
369
- );
370
393
  if (elapsedSeconds > budgetLimitSeconds()) {
371
- return {
372
- surface: {
373
- status: "ready",
374
- section,
375
- overlay: {
376
- kind: "neverCameBack",
377
- attemptedVersion: held.attemptedVersion,
378
- previousVersion: held.previousVersion,
379
- elapsedSeconds,
380
- logsCommand: FALLBACK_LOGS_COMMAND,
381
- },
382
- },
383
- clearStoredRun: true,
384
- releaseHeld: false,
385
- };
394
+ return neverCameBack(held, elapsedSeconds);
386
395
  }
387
396
  return ready(
388
- section,
397
+ applyingSection(
398
+ held.runId,
399
+ held.previousVersion,
400
+ held.attemptedVersion,
401
+ "reconnecting",
402
+ elapsedSeconds,
403
+ ),
389
404
  {
390
405
  kind: "applying",
391
406
  target: held.attemptedVersion,
@@ -397,9 +412,14 @@ function deriveHeld(
397
412
  );
398
413
  }
399
414
 
400
- // No answer yet — the resume request is still in flight. Stay applying; only
401
- // a real error or a real answer moves off it, never a pending first poll.
415
+ // No answer yet — the resume request is still in flight. A request that is
416
+ // pending looks exactly like one that will never settle, so the budget bounds
417
+ // this wait too: within it the client stays applying, past it it stops
418
+ // claiming an install is running and says what it cannot account for.
402
419
  if (data === undefined) {
420
+ if (elapsedSeconds > budgetLimitSeconds()) {
421
+ return neverCameBack(held, elapsedSeconds);
422
+ }
403
423
  return ready(
404
424
  applyingSection(
405
425
  held.runId,
@@ -22,7 +22,8 @@ type Responder = (call: HttpCall) => unknown;
22
22
 
23
23
  /**
24
24
  * Answer every request with `responder`'s return value as JSON. Throwing from
25
- * the responder, or returning a `Response`, is how a test drives a failure.
25
+ * the responder, or returning a `Response`, is how a test drives a failure;
26
+ * returning a promise that never settles is how it drives a request that hangs.
26
27
  */
27
28
  export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
28
29
  const original = globalThis.fetch;
@@ -43,7 +44,7 @@ export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
43
44
  };
44
45
  calls.push(call);
45
46
 
46
- const result = responder(call);
47
+ const result = await responder(call);
47
48
  if (result instanceof Response) return result;
48
49
  return new Response(JSON.stringify(result ?? {}), {
49
50
  status: 200,