From d0daf012d558d8fd75f540a535a0c71895b258bc Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Tue, 6 Oct 2026 13:05:28 -0400 Subject: [PATCH 1/6] Let a review client reopen its shared stream at once The shared review stream waits up to 30 seconds between reconnect attempts. reconnect() starts a new attempt now, with a fresh backoff. Agent-Session: 13cd2035-0029-45cb-a2c9-65f0c01b6460 Agent-Session: e9b9e23e-de55-4291-9391-cd234f736397 Agent-Session: 5368a6f5-df0c-4411-8eec-4b9cccbfbef0 Agent-Session: f086ac47-f054-41fb-8279-be4df7aa2dee Agent-Session: 3a3376c5-3f72-46df-930c-a66d5c3bcce5 Agent-Session: 7633c50b-998d-4b1e-8ca2-ffb350a26066 Agent-Session: 9a4d92ed-7a0d-42a1-9169-574bca60095b Agent-Session: 1c093463-922b-435b-a925-664d5d5c3c0a Agent-Session: 2b51b153-90b5-4a72-a052-fccf0beb9f1c --- packages/review-protocol/src/review-api-client.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/review-protocol/src/review-api-client.ts b/packages/review-protocol/src/review-api-client.ts index 3a3b00f11..a6c85591f 100644 --- a/packages/review-protocol/src/review-api-client.ts +++ b/packages/review-protocol/src/review-api-client.ts @@ -206,11 +206,7 @@ export class ReviewApiClient { if (!connections) liveConnections.set(this.request, (connections = new Map())); - const key = JSON.stringify([ - this.connection.serverUrl, - this.connection.token, - ]); - + const key = this.liveKey(); let live = connections.get(key); if (!live) { @@ -230,6 +226,13 @@ export class ReviewApiClient { disconnected, ); } + /** Reopen the shared stream now rather than after its backoff. */ + reconnect() { + liveConnections.get(this.request)?.get(this.liveKey())?.reconnect(); + } + private liveKey() { + return JSON.stringify([this.connection.serverUrl, this.connection.token]); + } } type Result = @@ -342,7 +345,7 @@ class LiveConnection { }, restartDelayMs); } - private reconnect() { + reconnect() { this.abort.abort(); this.abort = new AbortController(); const listeners = [...this.listeners]; From 31fd15728ffb01377e3dea14f38a415735d4038f Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Tue, 6 Oct 2026 14:32:29 -0400 Subject: [PATCH 2/6] Word each remote host state once, with its next step reviewHostStatus gives every host state one short status, one label, a sentence and the action that helps: Retry, Install or Settings. The gateway uses its sentence when a down host has no detail, so the window no longer reads "alex-box is auth-failed.". The gateway also stops saying "did not answer: it did not answer", and an unknown network error reads "it failed (CODE)", not the bare code. Agent-Session: 5368a6f5-df0c-4411-8eec-4b9cccbfbef0 Agent-Session: f086ac47-f054-41fb-8279-be4df7aa2dee Agent-Session: 3a3376c5-3f72-46df-930c-a66d5c3bcce5 Agent-Session: 7633c50b-998d-4b1e-8ca2-ffb350a26066 Agent-Session: 9a4d92ed-7a0d-42a1-9169-574bca60095b Agent-Session: 1c093463-922b-435b-a925-664d5d5c3c0a Agent-Session: 2b51b153-90b5-4a72-a052-fccf0beb9f1c --- packages/review-protocol/src/index.ts | 2 + .../review-protocol/src/review-host-status.ts | 81 +++++++++++++++++++ .../src/server/review-gateway-streams.ts | 3 +- .../src/server/review-gateway-transport.ts | 3 +- packages/review/src/server/review-gateway.ts | 2 +- 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 packages/review-protocol/src/review-host-status.ts diff --git a/packages/review-protocol/src/index.ts b/packages/review-protocol/src/index.ts index 0d171294f..d720cfe1b 100644 --- a/packages/review-protocol/src/index.ts +++ b/packages/review-protocol/src/index.ts @@ -56,6 +56,8 @@ export * from "./contracts.js"; export * from "./review-api-client.js"; +export * from "./review-host-status.js"; + export { type ByCommitEntry, type ReviewAgentTraceEvent, diff --git a/packages/review-protocol/src/review-host-status.ts b/packages/review-protocol/src/review-host-status.ts new file mode 100644 index 000000000..0d4972331 --- /dev/null +++ b/packages/review-protocol/src/review-host-status.ts @@ -0,0 +1,81 @@ +import type { ReviewGatewayHostState } from "./contracts.js"; + +export interface ReviewHostStatus { + /** Short words for a chip or a list row, without the host name. */ + status: string; + /** The same with the host name, for a heading. */ + label: string; + sentence: string; + /** What helps: none while the host is busy or online. */ + action?: "retry" | "install" | "settings"; + busy?: true; +} + +const STATUS: Record< + ReviewGatewayHostState["state"], + Pick & { + status: string; + label(alias: string): string; + } +> = { + online: { status: "online", label: (alias) => alias }, + connecting: { + status: "connecting", + label: (alias) => `Connecting to ${alias}…`, + busy: true, + }, + installing: { + status: "installing", + label: (alias) => `Installing on ${alias}…`, + busy: true, + }, + offline: { + status: "offline", + label: (alias) => `${alias} offline`, + action: "retry", + }, + unreachable: { + status: "offline", + label: (alias) => `${alias} offline`, + action: "retry", + }, + incompatible: { + status: "needs an update", + label: (alias) => `${alias} needs an update`, + action: "install", + }, + "not-installed": { + status: "not installed", + label: (alias) => `Whiteboard not installed on ${alias}`, + action: "install", + }, + "auth-failed": { + status: "can't sign in", + label: (alias) => `Can't sign in to ${alias}`, + action: "settings", + }, + unsupported: { + status: "can't be used", + label: (alias) => `${alias} can't be used`, + action: "settings", + }, + duplicate: { + status: "can't be used", + label: (alias) => `${alias} can't be used`, + action: "settings", + }, +}; + +/** One wording and one next step per host state, shared by every surface. */ +export function reviewHostStatus( + host: Pick, +): ReviewHostStatus { + const { label, ...rest } = STATUS[host.state]; + const words = label(host.alias); + + return { + ...rest, + label: words, + sentence: host.detail ?? (words.endsWith("…") ? words : `${words}.`), + }; +} diff --git a/packages/review/src/server/review-gateway-streams.ts b/packages/review/src/server/review-gateway-streams.ts index 1edcb128b..2e0b1a7c3 100644 --- a/packages/review/src/server/review-gateway-streams.ts +++ b/packages/review/src/server/review-gateway-streams.ts @@ -5,6 +5,7 @@ import { type ReviewApiSummary, type ReviewGatewayHostState, parseJsonText, + reviewHostStatus, } from "@dev.fast/review-protocol"; import { coverageModeSchema } from "@review/review-api/review-progress.js"; import { z } from "zod"; @@ -47,7 +48,7 @@ export type Located = | undefined; export const downDetail = (down: ReviewGatewayHostState) => - down.detail ?? `${down.alias} is ${down.state}.`; + reviewHostStatus(down).sentence; const watchPath = (subscriptions: object[]) => `/reviews-api/watch?subscriptions=${encodeURIComponent(JSON.stringify(subscriptions))}`; diff --git a/packages/review/src/server/review-gateway-transport.ts b/packages/review/src/server/review-gateway-transport.ts index ea47c81ab..6ec8da389 100644 --- a/packages/review/src/server/review-gateway-transport.ts +++ b/packages/review/src/server/review-gateway-transport.ts @@ -182,6 +182,7 @@ const ERROR_WORDS = new Map([ ["ECONNREFUSED", "it refused the connection"], ["ECONNRESET", "it closed the connection"], ["UND_ERR_SOCKET", "it closed the connection"], + ["EPIPE", "it closed the connection"], ["ETIMEDOUT", "it did not answer"], ["UND_ERR_HEADERS_TIMEOUT", NO_ANSWER], ]); @@ -199,5 +200,5 @@ export function errorCode(cause: unknown): string { export function errorText(cause: unknown): string { const code = errorCode(cause); - return ERROR_WORDS.get(code) ?? code; + return ERROR_WORDS.get(code) ?? `it failed (${code})`; } diff --git a/packages/review/src/server/review-gateway.ts b/packages/review/src/server/review-gateway.ts index 877e06560..55a5026ab 100644 --- a/packages/review/src/server/review-gateway.ts +++ b/packages/review/src/server/review-gateway.ts @@ -266,7 +266,7 @@ export function createReviewGateway(input: { return answer(remote.alias, timedOut ? 504 : 502, { ok: false, - error: `${remote.alias} did not answer: ${reason}.`, + error: `Could not reach ${remote.alias}: ${reason}.`, }); } From 4a1335edaa011a5742302740df79f5ef2a685fe3 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Tue, 6 Oct 2026 13:05:28 -0400 Subject: [PATCH 3/6] Show a remote host's state and next step on its reviews A dropped review stream shows a yellow chip in the top bar, such as "alex-box offline", instead of the "Connection lost" banner. A click runs the step that helps: Retry, Install or Open Settings. Retry also reopens the review stream at once. A remote review that has not loaded shows the same, not a blank page. On Home, an unavailable review says why, offers the step, and opens once its host is back. Agent-Session: 5368a6f5-df0c-4411-8eec-4b9cccbfbef0 Agent-Session: f086ac47-f054-41fb-8279-be4df7aa2dee Agent-Session: 3a3376c5-3f72-46df-930c-a66d5c3bcce5 Agent-Session: 7633c50b-998d-4b1e-8ca2-ffb350a26066 Agent-Session: 9a4d92ed-7a0d-42a1-9169-574bca60095b Agent-Session: 1c093463-922b-435b-a925-664d5d5c3c0a Agent-Session: 2b51b153-90b5-4a72-a052-fccf0beb9f1c --- .../browser/parts/canvas/reviewCanvasPart.ts | 15 +- apps/review-desktop/scripts/e2e/TESTING.md | 2 +- .../scripts/e2e/journeys/remote-host.mjs | 31 ++- packages/review-protocol/src/contracts.ts | 11 +- packages/review/app/src/api-canvas.test.tsx | 169 ++++++++++++++ packages/review/app/src/api-canvas.tsx | 97 +++++--- .../review/app/src/authoring-activity.tsx | 4 + packages/review/app/src/connection-chip.tsx | 217 ++++++++++++++++++ packages/review/app/src/desktop-entry.tsx | 2 +- packages/review/app/src/remote-host-state.ts | 108 +++++++++ packages/review/app/src/review-home-styles.ts | 6 + .../app/src/review-home-view.browser.test.tsx | 52 +++-- packages/review/app/src/review-home-view.tsx | 97 +++++--- packages/review/app/src/shell-styles.ts | 4 +- 14 files changed, 709 insertions(+), 106 deletions(-) create mode 100644 packages/review/app/src/connection-chip.tsx create mode 100644 packages/review/app/src/remote-host-state.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts index 7739d1350..de5aba0ec 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts @@ -71,6 +71,7 @@ import type { ReviewDocumentWidthChoice, ReviewKeymapChoice, ReviewReadyNotificationChoice, + ReviewRemoteHostActions, ReviewRuntimeConfig, ReviewSurfaceEvent, ReviewTheme, @@ -459,6 +460,7 @@ export class ReviewCanvasEditorPane extends EditorPane { kind: "api", reviewId, ...remoteEntry(this.apiCatalog.reviews.find((review) => review.reviewId === reviewId)), + remoteHosts: this.remoteHostActions(), structuralDiffEnabled: this.currentStructuralDiffEnabled(), softwareMapEnabled: this.currentSoftwareMapEnabled(), documentWidth: this.currentDocumentWidth(), @@ -574,7 +576,7 @@ export class ReviewCanvasEditorPane extends EditorPane { this.reviewTelemetryService.capture("review_restored", { via: "home" }); return this.apiCatalog.lifecycle(uuid, "active"); }, - hostStates: () => this.desktopConnection.readRemoteHosts(), + remoteHosts: this.remoteHostActions(), openSourceTree: (uuid) => { const api = this.apiCatalog.reviews.find((review) => review.reviewId === uuid); if (api) { @@ -642,6 +644,17 @@ export class ReviewCanvasEditorPane extends EditorPane { } } + private remoteHostActions(): ReviewRemoteHostActions { + return { + states: () => this.desktopConnection.readRemoteHosts(), + retry: (alias) => this.desktopConnection.retryRemoteHost(alias), + install: (alias) => this.desktopConnection.installRemoteHost(alias), + openSettings: async () => { + await this.tabsService.openSettings(true); + }, + }; + } + async refreshComparison(): Promise { if (!(this.input instanceof ReviewCanvasEditorInput) || this.input.target.kind !== "api") return; this.readyInput = undefined; diff --git a/apps/review-desktop/scripts/e2e/TESTING.md b/apps/review-desktop/scripts/e2e/TESTING.md index fbb75eeba..7a7be589b 100644 --- a/apps/review-desktop/scripts/e2e/TESTING.md +++ b/apps/review-desktop/scripts/e2e/TESTING.md @@ -123,7 +123,7 @@ key, and the journey adds `wb-test-a` in Settings as a user would. It checks, on the DOM and on the page's requests: the host goes `online`; Home lists the container's review as `wb-test-a: wbrepo`; the document, a code peek, the Diff view and the structural diff load; an edit and a `session_create` on the -remote reach the window; a killed `ssh` master shows "Connection lost" and +remote reach the window; a killed `ssh` master shows a chip in the top bar and recovers without a reload; a stopped server is `offline` within 15 s while a laptop review still opens; another package version is `incompatible` with the install command in Settings; and removing the host takes its reviews out of diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs index c7530f55a..b0bbf8ff4 100644 --- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs +++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs @@ -454,17 +454,13 @@ async function journey(ctx, page, until) { await tab(title).click(); await canvas.getByRole("heading", { name: title }).waitFor(); await page.evaluate(() => { - const seen = (window.__remoteHostBanner = []); + const seen = (window.__remoteHostChip = []); new MutationObserver(() => { - for (const status of document.querySelectorAll( - ".review-canvas-root [role=status]", + for (const chip of document.querySelectorAll( + ".review-canvas-root .connection-chip", )) - if ( - status.textContent.startsWith("Connection lost") && - !seen.includes(status.textContent) - ) - seen.push(status.textContent); + if (!seen.includes(chip.textContent)) seen.push(chip.textContent); }).observe(document.body, { subtree: true, childList: true, @@ -477,10 +473,14 @@ async function journey(ctx, page, until) { assert.ok(master, "no ssh master for the host"); process.kill(master, "SIGKILL"); await until( - () => page.evaluate(() => window.__remoteHostBanner.length > 0), - "the Connection lost banner", + () => page.evaluate(() => window.__remoteHostChip.length > 0), + "the disconnected chip", 30000, ); + assert.equal( + await canvas.locator("[role=status]").filter({ hasText: "Connection lost" }).count(), + 0, + ); await until( async () => (await hostState())?.state === "online" && (await masterPid()) !== master, @@ -489,11 +489,8 @@ async function journey(ctx, page, until) { ); await until( async () => - (await canvas - .locator("[role=status]") - .filter({ hasText: "Connection lost" }) - .count()) === 0, - "the banner to clear", + (await canvas.locator(".connection-chip").count()) === 0, + "the chip to clear", ); await remoteApi("session_edit", { sessionId: reviewId, @@ -505,7 +502,7 @@ async function journey(ctx, page, until) { await canvas.getByText("After the reconnect.").waitFor({ timeout: 30000 }); assert.equal(await page.evaluate(() => performance.timeOrigin), timeOrigin); ctx.check( - `6. a killed ssh master showed "${await page.evaluate(() => window.__remoteHostBanner[0])}" and the review recovered in the same page`, + `6. a killed ssh master showed "${await page.evaluate(() => window.__remoteHostChip[0])}" in the top bar and the review recovered in the same page`, ); // 7. A hung server: offline within 15 s, and a laptop review still opens at once. @@ -558,7 +555,7 @@ async function journey(ctx, page, until) { for (const text of [title, second]) { await row(text).waitFor(); assert.equal(await row(text).getAttribute("data-unavailable"), ""); - assert.match(await row(text).innerText(), /incompatible/); + assert.match(await row(text).innerText(), /needs an update/); } ctx.check( diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts index 7f660e289..d58b7ea64 100644 --- a/packages/review-protocol/src/contracts.ts +++ b/packages/review-protocol/src/contracts.ts @@ -598,6 +598,14 @@ export interface ReviewCanvasSettingsContent { remoteHosts: ReviewRemoteHostsSettings; } +/** What a review or Home can do about the host a review lives on. */ +export interface ReviewRemoteHostActions { + states(): Promise; + retry(alias: string): Promise; + install(alias: string): Promise; + openSettings(): Promise; +} + export interface ReviewRemoteHostsSettings { enabled: boolean; configured: string[]; @@ -764,6 +772,7 @@ export type ReviewCanvasContent = version?: number; host?: string; available?: ReviewApiSummary["available"]; + remoteHosts?: ReviewRemoteHostActions; bridge: ReviewCanvasBridge; setTitle?(title: string): void; setSourceView?( @@ -798,7 +807,7 @@ export type ReviewCanvasContent = // Boards in Trash can be restored for 30 days or deleted. deleteReview?(uuid: string): Promise; restoreReview?(uuid: string): Promise; - hostStates?(): Promise; + remoteHosts?: ReviewRemoteHostActions; // Opens the review and pins its read-only source tree open. Absent when // the host cannot show the tree. openSourceTree?(uuid: string): void; diff --git a/packages/review/app/src/api-canvas.test.tsx b/packages/review/app/src/api-canvas.test.tsx index ffcddc1f8..a83080902 100644 --- a/packages/review/app/src/api-canvas.test.tsx +++ b/packages/review/app/src/api-canvas.test.tsx @@ -1292,6 +1292,175 @@ it("gives a review on another machine no tutorial controls, even a tutorial revi expect(tutorial).not.toHaveBeenCalledWith(true); }); +it("shows a lost remote host as a top-bar chip that retries the host and the stream", async () => { + const review = await command({ + type: "create", + title: "Remote", + target: { kind: "commits", ...pins }, + }); + + const app = new Hono().route("/reviews-api", createReviewApi(store)); + app.get("/reviews-api/:id/commits", (context) => context.json([])); + let down = false; + let watches = 0; + let cut: (() => void) | undefined; + + const bridge = testReviewBridge( + {}, + { + request: async (url, init) => { + if (!new URL(String(url)).pathname.endsWith("/watch")) + return app.request(url, init); + watches++; + + if (down) + return Response.json( + { ok: false, error: "wb-a is offline: it did not answer." }, + { status: 503 }, + ); + const response = await app.request(url, init); + + return new Response( + response.body!.pipeThrough( + new TransformStream({ + start: (controller) => { + cut = () => + controller.error( + new Error("wb-a is offline: it did not answer."), + ); + }, + }), + ), + response, + ); + }, + }, + ); + + const retry = vi.fn<(alias: string) => Promise>(async () => { + down = false; + }); + + const remoteHosts = { + states: async () => [ + down + ? { + alias: "wb-a", + state: "offline" as const, + detail: "wb-a is offline: it did not answer.", + } + : { alias: "wb-a", state: "online" as const }, + ], + retry, + install: async () => {}, + openSettings: async () => {}, + }; + + const container = document.createElement("div"); + document.body.append(container); + await act(async () => { + canvas = mount(container, { + kind: "api", + reviewId: review.reviewId, + host: "wb-a", + remoteHosts, + bridge, + setSourceView: () => {}, + }); + }); + await act(() => + vi.waitFor(() => + expect(container.querySelector("h1")?.textContent).toBe("Remote"), + ), + ); + + down = true; + await act(async () => cut?.()); + + const chip = await vi.waitFor(() => { + const found = container.querySelector( + "button.connection-chip", + ); + + expect(found?.textContent).toBe("wb-a offline"); + + return found!; + }); + + expect(container.textContent).not.toContain("Connection lost"); + expect(chip.title).toBe( + "Click to retry\nwb-a is offline: it did not answer.", + ); + + const before = watches; + await act(async () => chip.click()); + expect(retry).toHaveBeenCalledWith("wb-a"); + await act(() => + vi.waitFor(() => { + expect(container.querySelector(".connection-chip")).toBeNull(); + }), + ); + // The stream came back at once, not after its backoff. + expect(watches).toBe(before + 1); +}); + +it("shows why a remote review has not loaded, with its host's next step", async () => { + const install = vi.fn<(alias: string) => Promise>(async () => {}); + + const container = document.createElement("div"); + document.body.append(container); + await act(async () => { + canvas = mount(container, { + kind: "api", + reviewId: "11111111-1111-4111-8111-111111111111", + host: "wb-a", + remoteHosts: { + states: async () => [ + { + alias: "wb-a", + state: "incompatible", + detail: "wb-a runs Whiteboard 0.1.0; this Desktop runs 0.2.0.", + }, + ], + retry: async () => {}, + install, + openSettings: async () => {}, + }, + bridge: testReviewBridge( + {}, + { + request: async () => + Response.json( + { ok: false, error: "wb-a runs Whiteboard 0.1.0." }, + { status: 503 }, + ), + }, + ), + }); + }); + + const waiting = await vi.waitFor(() => { + const found = container.querySelector(".host-waiting"); + + expect(found?.querySelector("h2")?.textContent).toBe( + "wb-a needs an update", + ); + + return found!; + }); + + expect(waiting.textContent).toContain( + "wb-a runs Whiteboard 0.1.0; this Desktop runs 0.2.0.", + ); + expect(container.textContent).not.toContain("Connection lost"); + await act(async () => + [...waiting.querySelectorAll("button")] + .find((button) => button.textContent === "Install")! + .click(), + ); + expect(install).toHaveBeenCalledWith("wb-a"); +}); + async function mountPeekReview(content: { host?: string; available?: { sourceWindows: boolean }; diff --git a/packages/review/app/src/api-canvas.tsx b/packages/review/app/src/api-canvas.tsx index 9c344f1ff..84e03918f 100644 --- a/packages/review/app/src/api-canvas.tsx +++ b/packages/review/app/src/api-canvas.tsx @@ -40,6 +40,7 @@ import { } from "./authoring-cursor"; import { SaveMarkdown } from "./blocks"; import { CanvasQueryProvider } from "./canvas-query"; +import { ConnectionContext, HostWaiting } from "./connection-chip"; import { DisplayedReviewVersionContext } from "./displayed-review-version-context"; import { DrawQueueProvider } from "./draw-queue-provider"; import { @@ -112,11 +113,13 @@ export function ApiCanvas({ const sourceRef = useRef<{ key: string; version: number }>(undefined); const sourceVersion = sourceRef.current?.key; const [error, setError] = useState(); + const [lost, setLost] = useState(); useEffect(() => { const abort = new AbortController(); const loader = createDocumentLoader(client); setData(undefined); setTrashedAt(null); + setLost(undefined); setActivity(undefined); setCursor(null); setLensCursor(null); @@ -175,6 +178,7 @@ export function ApiCanvas({ content.reviewId, abort.signal, async (snapshot) => { + setLost(undefined); setActivity(snapshot.activity); setCursor((current) => nextCursor(current, cursorMemory, snapshot)); setLensCursor((current) => @@ -237,9 +241,7 @@ export function ApiCanvas({ return; } - setError( - `Connection lost. Reconnecting… ${cause instanceof Error ? cause.message : ""}`, - ); + setLost(cause instanceof Error ? cause.message : ""); }, ); })(); @@ -467,16 +469,39 @@ export function ApiCanvas({ ); + const { host, remoteHosts } = content; + + const connection = useMemo( + () => + lost === undefined + ? undefined + : { + host, + hosts: remoteHosts, + detail: lost, + reconnect: () => client.reconnect(), + }, + [lost, host, remoteHosts, client], + ); + // Loads are near-instant, so stay blank until there is data or an error. // Both branches root the same query provider, so its cache outlives a load. + // Without data there is no top bar for the connection chip. if (!data) return ( {trashBanner} - {error !== undefined && ( + {error === undefined && host && remoteHosts ? ( + client.reconnect()} + lost={lost} + /> + ) : (error ?? lost) !== undefined ? ( <>

- {error} + {error ?? `Connection lost. Reconnecting… ${lost}`}

{version !== undefined && ( )} - )} + ) : null}
); @@ -525,41 +550,43 @@ export function ApiCanvas({

)} {trashBanner} - - + - - - -
- -
-
-
-
+ + +
+ +
+
+
+ +
- -
+ + diff --git a/packages/review/app/src/authoring-activity.tsx b/packages/review/app/src/authoring-activity.tsx index 3edaf32c8..3ae0b096c 100644 --- a/packages/review/app/src/authoring-activity.tsx +++ b/packages/review/app/src/authoring-activity.tsx @@ -7,6 +7,7 @@ import { useContext, useState } from "react"; import { agentColor } from "./agent-colors"; import { AuthoringActivityContext } from "./authoring-activity-context"; import { surfaceOf } from "./authoring-cursor"; +import { ConnectionChip, ConnectionContext } from "./connection-chip"; import { AuthoringCursorContext, LensCursorContext, @@ -35,11 +36,14 @@ export function AuthoringActivityBadge({ onLocate?(view: "review" | "diff"): void; }) { const activity = useContext(AuthoringActivityContext); + const lost = useContext(ConnectionContext) !== undefined; const tooltip = useTooltip( "Activity updates stopped. This does not mean the agent finished.", ); + if (lost) return ; + if (!activity) return null; // The class is a marker for tests. diff --git a/packages/review/app/src/connection-chip.tsx b/packages/review/app/src/connection-chip.tsx new file mode 100644 index 000000000..167cc9650 --- /dev/null +++ b/packages/review/app/src/connection-chip.tsx @@ -0,0 +1,217 @@ +import { fontSize, fontWeight, motion, radius } from "@canvas/scale.stylex"; +import { Button } from "@canvas/ui/button"; +import { + type ReviewRemoteHostActions, + reviewHostStatus, +} from "@dev.fast/review-protocol"; +import * as stylex from "@stylexjs/stylex"; +import { createContext, useContext } from "react"; + +import { RefreshIcon } from "./icons"; +import { + ACTION_WORDS, + useHostAction, + useRemoteHostState, +} from "./remote-host-state"; +import { withClass } from "./stylex-props"; +import { tokens } from "./tokens.stylex"; +import { useTooltip } from "./use-tooltip"; + +export interface LostConnection { + host?: string; + hosts?: ReviewRemoteHostActions; + detail: string; + /** Reopen the review stream after a retry. */ + reconnect(): void; +} + +export const ConnectionContext = createContext( + undefined, +); + +/** Shown while the review stream is down; on a remote review it offers the host's next step. */ +export function ConnectionChip() { + const connection = useContext(ConnectionContext); + const { host, hosts } = connection ?? {}; + const state = useRemoteHostState(hosts, host, connection !== undefined); + + const status = + state && state.state !== "online" ? reviewHostStatus(state) : undefined; + + const action = status ? status.action : host && hosts ? "retry" : undefined; + + const { run, pending, failure } = useHostAction( + hosts, + host, + connection?.reconnect, + ); + + const tooltip = useTooltip( + action ? `Click to ${ACTION_WORDS[action].toLowerCase()}` : "Reconnecting…", + { detail: failure ?? status?.sentence ?? connection?.detail }, + ); + + if (!connection) return null; + + const label = + status?.label ?? (host ? `${host} disconnected` : "Disconnected"); + + const spinning = pending || status?.busy; + + if (!action) + return ( + + {label} + {spinning ? ( + + ) : null} + + ); + + return ( + + ); +} + +/** A remote review that has not loaded: what its host is doing, and the next step. */ +export function HostWaiting({ + host, + hosts, + reconnect, + lost, +}: { + host: string; + hosts: ReviewRemoteHostActions; + reconnect(): void; + lost?: string; +}) { + const state = useRemoteHostState(hosts, host, true); + const { run, pending, failure } = useHostAction(hosts, host, reconnect); + + if (!state || state.state === "online") + return lost === undefined ? null : ( +

+ Connection lost. Reconnecting… {lost} +

+ ); + + const status = reviewHostStatus(state); + const { action } = status; + + return ( +
+

{status.label}

+

+ {failure ?? status.sentence} +

+ {action ? ( + + ) : null} +
+ ); +} + +const REDUCED = "@media (prefers-reduced-motion: reduce)"; + +const spin = stylex.keyframes({ + to: { transform: "rotate(360deg)" }, +}); + +const styles = stylex.create({ + chip: { + display: "inline-flex", + flex: "0 1 auto", + minWidth: 0, + maxWidth: "320px", + alignItems: "center", + gap: "6px", + height: tokens.chromeControlHeight, + marginInline: "4px", + padding: "0 10px", + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.warningOutline, + borderRadius: radius.pill, + backgroundColor: tokens.warningWash, + color: tokens.changeModified, + font: `${fontWeight.semibold} ${fontSize.small} ${tokens.fontMono}`, + whiteSpace: "nowrap", + }, + // A long alias ends in an ellipsis; the tooltip has it all. + text: { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + }, + button: { + paddingInlineEnd: "7px", + cursor: { default: "pointer", ":disabled": "progress" }, + filter: { default: null, ":hover:not(:disabled)": "brightness(1.15)" }, + outline: { + default: null, + ":focus-visible": `1px solid ${tokens.changeModified}`, + }, + outlineOffset: { default: null, ":focus-visible": "1px" }, + }, + waiting: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "12px", + maxWidth: "72ch", + margin: "32px auto", + padding: "0 24px", + font: `${fontSize.reading}/1.6 ${tokens.fontDisplay}`, + }, + waitingLabel: { + margin: 0, + color: tokens.changeModified, + font: `${fontWeight.semibold} ${fontSize.reading} ${tokens.fontMono}`, + }, + waitingSentence: { + margin: 0, + color: tokens.inkMuted, + }, + action: { + textDecorationLine: "underline", + textUnderlineOffset: "2px", + }, + icon: { + flexShrink: 0, + width: "13px", + height: "13px", + }, + spinning: { + animationName: { default: spin, [REDUCED]: "none" }, + animationDuration: { default: motion.pulse, [REDUCED]: motion.instant }, + animationTimingFunction: "linear", + animationIterationCount: "infinite", + }, +}); diff --git a/packages/review/app/src/desktop-entry.tsx b/packages/review/app/src/desktop-entry.tsx index 27c8be728..f57499ab7 100644 --- a/packages/review/app/src/desktop-entry.tsx +++ b/packages/review/app/src/desktop-entry.tsx @@ -129,7 +129,7 @@ function Home({ onRestore={ restoreReview ? (review) => restoreReview(review.reviewId) : undefined } - hostStates={content.hostStates} + remoteHosts={content.remoteHosts} onboarding={content.onboarding} onOpenTutorial={content.openTutorial} onOpenSettings={content.openSettings} diff --git a/packages/review/app/src/remote-host-state.ts b/packages/review/app/src/remote-host-state.ts new file mode 100644 index 000000000..a682d2af0 --- /dev/null +++ b/packages/review/app/src/remote-host-state.ts @@ -0,0 +1,108 @@ +import type { + ReviewGatewayHostState, + ReviewHostStatus, + ReviewRemoteHostActions, +} from "@dev.fast/review-protocol"; +import { type RefObject, useEffect, useState } from "react"; + +export const STATES_EVERY_MS = 3000; + +export function useRemoteHostState( + hosts: ReviewRemoteHostActions | undefined, + alias: string | undefined, + watch: boolean, +) { + const [state, setState] = useState(); + + useEffect(() => { + if (!hosts || !alias || !watch) return; + let live = true; + + const read = () => { + if (document.hidden) return; + void hosts + .states() + .then((states) => { + if (live) setState(states.find((host) => host.alias === alias)); + }) + .catch(() => undefined); + }; + + read(); + const timer = setInterval(read, STATES_EVERY_MS); + + return () => { + live = false; + clearInterval(timer); + }; + }, [hosts, alias, watch]); + + return watch ? state : undefined; +} + +export const ACTION_WORDS: Record< + NonNullable, + string +> = { retry: "Retry", install: "Install", settings: "Open Settings" }; + +/** Runs a host's next step; Settings opens scrolled to Remote hosts. */ +export function useHostAction( + hosts: ReviewRemoteHostActions | undefined, + alias: string | undefined, + retried?: () => void, +) { + const [pending, setPending] = useState(false); + const [failure, setFailure] = useState(); + + const run = (action: NonNullable) => { + if (!hosts || !alias) return; + setPending(true); + setFailure(undefined); + + const step = + action === "retry" + ? hosts.retry(alias).then(retried) + : action === "install" + ? hosts.install(alias) + : hosts + .openSettings() + .then(() => revealSettingsSection(REMOTE_HOSTS)); + + void step + .catch((error: Error) => setFailure(error.message)) + .finally(() => setPending(false)); + }; + + return { run, pending, failure }; +} + +export const REMOTE_HOSTS = "remote-hosts"; + +// Every canvas tab shares this module, so a review can ask the Settings tab, +// open or about to mount, to scroll. +let requested: string | undefined; + +const reveals = new EventTarget(); + +function revealSettingsSection(section: string) { + requested = section; + reveals.dispatchEvent(new Event("reveal")); +} + +export function useSettingsReveal( + section: string, + ref: RefObject, +) { + useEffect(() => { + const reveal = () => { + if (requested !== section || !ref.current) return; + requested = undefined; + ref.current.scrollIntoView({ block: "start" }); + }; + + reveal(); + reveals.addEventListener("reveal", reveal); + + return () => reveals.removeEventListener("reveal", reveal); + }, [section, ref]); +} diff --git a/packages/review/app/src/review-home-styles.ts b/packages/review/app/src/review-home-styles.ts index 7b7d7e02c..e1b8be461 100644 --- a/packages/review/app/src/review-home-styles.ts +++ b/packages/review/app/src/review-home-styles.ts @@ -316,6 +316,12 @@ export const styles = stylex.create({ textAlign: "left", }, unavailableRow: { opacity: 0.55 }, + hostNotice: { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: "12px", + }, time: { color: tokens.inkFaint, fontVariantNumeric: "tabular-nums", diff --git a/packages/review/app/src/review-home-view.browser.test.tsx b/packages/review/app/src/review-home-view.browser.test.tsx index fc45a138b..86bfeef83 100644 --- a/packages/review/app/src/review-home-view.browser.test.tsx +++ b/packages/review/app/src/review-home-view.browser.test.tsx @@ -1,6 +1,7 @@ import type { ReviewApiSummary, ReviewGatewayHostState, + ReviewRemoteHostActions, } from "@dev.fast/review-protocol"; import { act } from "react"; import { type Root, createRoot } from "react-dom/client"; @@ -905,23 +906,23 @@ describe("Home interactions", () => { expect(titles()).toEqual(["One"]); }); it.each([ - ["offline", "offline"], - ["connecting", "connecting"], - ["not-installed", "not installed"], + ["offline", "offline", "devbox offline"], + ["connecting", "connecting", "Connecting to devbox…"], + ["not-installed", "not installed", "Whiteboard not installed on devbox"], ] as const)( "draws a %s host's review as unavailable, and opening it shows the host's detail", - async (hostState, words) => { + async (hostState, words, label) => { const onOpen = vi.fn<(review: ReviewApiSummary) => void>(); await render([remote("Remote", { hostState })], { onOpen, - hostStates: async () => [ + remoteHosts: hostsOf(async () => [ { alias: "devbox", state: hostState, detail: "devbox is offline: it did not answer within 3 seconds.", }, - ], + ]), }); const row = container.querySelector("[data-home-row]")!; @@ -933,27 +934,29 @@ describe("Home interactions", () => { button("Open Remote") .querySelector("span[title]")! .getAttribute("title"), - ).toBe(`devbox is ${words}`); + ).toBe(label); await click("Open Remote"); expect(onOpen).not.toHaveBeenCalled(); - expect(alert()).toBe( - "devbox is offline: it did not answer within 3 seconds.", + await vi.waitFor(() => + expect(alert()).toContain( + "devbox is offline: it did not answer within 3 seconds.", + ), ); }, ); - it("says only the state while the host's detail loads or when it has none", async () => { + it("says the state and offers its next step while the host's detail loads or when it has none", async () => { const answer = Promise.withResolvers(); await render([remote("Remote", { hostState: "offline" })], { - hostStates: () => answer.promise, + remoteHosts: hostsOf(() => answer.promise), }); await click("Open Remote"); - expect(alert()).toBe("devbox is offline."); + expect(alert()).toBe("devbox offline.Retry"); await act(async () => answer.resolve([{ alias: "devbox", state: "offline" }]), ); - expect(alert()).toBe("devbox is offline."); + expect(alert()).toBe("devbox offline.Retry"); }); - it("keeps the message of the last unavailable review opened, and drops it when the list changes", async () => { + it("keeps the message of the last unavailable review opened, and opens that review once its host is back", async () => { const answers = [ Promise.withResolvers(), Promise.withResolvers(), @@ -972,14 +975,18 @@ describe("Home interactions", () => { { alias: "other", state: "offline" as const, detail: "other detail" }, ]; - await render(reviews, { hostStates }); + const onOpen = vi.fn<(review: ReviewApiSummary) => void>(); + const remoteHosts = hostsOf(hostStates); + await render(reviews, { onOpen, remoteHosts }); await click("Open One"); await click("Open Two"); await act(async () => answers[1]!.resolve(states)); await act(async () => answers[0]!.resolve(states)); - expect(alert()).toBe("other detail"); - await render([{ ...reviews[1]!, hostState: "online" }], { hostStates }); + expect(alert()).toBe("other detailRetry"); + const back = { ...reviews[1]!, hostState: "online" as const }; + await render([reviews[0]!, back], { onOpen, remoteHosts }); expect(alert()).toBeUndefined(); + expect(onOpen).toHaveBeenCalledExactlyOnceWith(back); }); it("draws an online remote review as available", async () => { const onOpen = vi.fn<(review: ReviewApiSummary) => void>(); @@ -994,6 +1001,17 @@ describe("Home interactions", () => { expect(onOpen).toHaveBeenCalledWith(review); }); + function hostsOf( + states: () => Promise, + ): ReviewRemoteHostActions { + return { + states, + retry: async () => {}, + install: async () => {}, + openSettings: async () => {}, + }; + } + function alert() { return container.querySelector('[role="alert"]')?.textContent; } diff --git a/packages/review/app/src/review-home-view.tsx b/packages/review/app/src/review-home-view.tsx index 9c130962d..373bdbb53 100644 --- a/packages/review/app/src/review-home-view.tsx +++ b/packages/review/app/src/review-home-view.tsx @@ -2,8 +2,9 @@ import { REVIEW_DISCORD_URL, type ReviewApiSummary, type ReviewCanvasOnboarding, - type ReviewGatewayHostState, + type ReviewRemoteHostActions, homeSessions, + reviewHostStatus, } from "@dev.fast/review-protocol"; import { fuzzySegments } from "@review/fuzzy-match"; import * as stylex from "@stylexjs/stylex"; @@ -41,6 +42,11 @@ import { } from "./icons"; import { homeGroupMarker, homeRowMarker } from "./markers.stylex"; import { useNotify } from "./notify"; +import { + ACTION_WORDS, + useHostAction, + useRemoteHostState, +} from "./remote-host-state"; import type { ReviewFindHost } from "./review-find"; import { HomeFilterPicker, @@ -88,7 +94,7 @@ interface ReviewHomeProps { onDelete?(review: ReviewApiSummary): Promise; onTrash?(review: ReviewApiSummary): Promise; onRestore?(review: ReviewApiSummary): Promise; - hostStates?(): Promise; + remoteHosts?: ReviewRemoteHostActions; onboarding?: ReviewCanvasOnboarding; onOpenTutorial?(): void; onOpenSettings?(): void; @@ -138,7 +144,7 @@ export function ReviewHome({ onDelete, onTrash, onRestore, - hostStates, + remoteHosts, onboarding, onOpenTutorial, onOpenSettings, @@ -182,33 +188,29 @@ export function ReviewHome({ const [busy, setBusy] = useState(false); const busyRef = useRef(false); const [error, setError] = useState(""); - const [hostMessage, setHostMessage] = useState(); - const hostMessageGeneration = useRef(0); + // The unavailable review last opened; it opens once its host is back. + const [waiting, setWaiting] = useState(); useEffect(() => { - hostMessageGeneration.current++; - setHostMessage(undefined); - }, [reviews]); + if (!waiting) return; - const open = useCallback( - async (review: ReviewApiSummary) => { - const generation = ++hostMessageGeneration.current; + const current = reviews.find( + (review) => review.reviewId === waiting.reviewId, + ); - if (!unavailable(review)) { - setHostMessage(undefined); - onOpen(review); + if (current && unavailable(current)) return setWaiting(current); + setWaiting(undefined); - return; - } - - setHostMessage(`${review.host} is ${hostStateWords(review)}.`); - const states = await hostStates?.().catch(() => undefined); - const detail = states?.find((host) => host.alias === review.host)?.detail; + if (current) onOpen(current); + }, [reviews]); - if (detail && generation === hostMessageGeneration.current) - setHostMessage(detail); + const open = useCallback( + (review: ReviewApiSummary) => { + if (unavailable(review)) return setWaiting(review); + setWaiting(undefined); + onOpen(review); }, - [onOpen, hostStates], + [onOpen], ); const [notice, setNotice] = useState(null); @@ -1064,10 +1066,12 @@ export function ReviewHome({ ) : null} {error &&

{error}

} - {hostMessage ? ( -

- -

+ {waiting?.host && waiting.hostState ? ( + ) : null} {!visible.length && ( - {hostStateWords(review)} + {hostStatus(review).status} ); @@ -1542,8 +1546,37 @@ function unavailable(review: ReviewApiSummary): boolean { return review.hostState !== undefined && review.hostState !== "online"; } -function hostStateWords(review: ReviewApiSummary): string { - return review.hostState?.replace("-", " ") ?? ""; +function hostStatus(review: ReviewApiSummary) { + return reviewHostStatus({ + alias: review.host ?? "", + state: review.hostState ?? "online", + }); +} + +function HostNotice({ + alias, + state, + hosts, +}: { + alias: string; + state: NonNullable; + hosts?: ReviewRemoteHostActions; +}) { + const live = useRemoteHostState(hosts, alias, true); + const { run, pending, failure } = useHostAction(hosts, alias); + const status = reviewHostStatus(live ?? { alias, state }); + const { action } = status; + + return ( +

+ + {action && hosts ? ( + + ) : null} +

+ ); } function PullRequestPill({ diff --git a/packages/review/app/src/shell-styles.ts b/packages/review/app/src/shell-styles.ts index 3909a8f7d..530acf27f 100644 --- a/packages/review/app/src/shell-styles.ts +++ b/packages/review/app/src/shell-styles.ts @@ -247,9 +247,10 @@ export const shellStyles = stylex.create({ }, }, // The scratchpad has no source tree or pins to set apart, so no rule. + // Shrinks before the action row scrolls, so its badges end in an ellipsis. topbarContext: { display: "flex", - flex: "0 0 auto", + flex: "0 1 auto", minWidth: 0, alignItems: "center", marginRight: "auto", @@ -266,6 +267,7 @@ export const shellStyles = stylex.create({ // the viewport: an open side panel narrows the column without resizing // the window. In a narrow column this ghost Button drops its label. openSourceTree: { + flexShrink: 0, justifyContent: { default: null, [narrowContent]: "center" }, width: { default: null, [narrowContent]: tokens.chromeControlHeight }, padding: { default: "0 10px", [narrowContent]: 0 }, From ebab7bd72160d893794292d4723e9eed759677ef Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Tue, 6 Oct 2026 14:32:30 -0400 Subject: [PATCH 4/6] Offer Retry to offline hosts in Settings, and show its result An offline host now gets Retry, and Retry and Install show progress and any error. A host's line shows its detail alone, without the state name before it. Open Settings from a review scrolls to Remote hosts. Agent-Session: 5368a6f5-df0c-4411-8eec-4b9cccbfbef0 Agent-Session: f086ac47-f054-41fb-8279-be4df7aa2dee Agent-Session: 3a3376c5-3f72-46df-930c-a66d5c3bcce5 Agent-Session: 7633c50b-998d-4b1e-8ca2-ffb350a26066 Agent-Session: 9a4d92ed-7a0d-42a1-9169-574bca60095b Agent-Session: 1c093463-922b-435b-a925-664d5d5c3c0a Agent-Session: 2b51b153-90b5-4a72-a052-fccf0beb9f1c --- .../scripts/e2e/journeys/remote-host.mjs | 15 ++- .../scripts/e2e/journeys/remote-install.mjs | 9 +- .../review-protocol/src/review-host-status.ts | 49 ++++---- packages/review/app/src/connection-chip.tsx | 42 +++++-- packages/review/app/src/remote-host-state.ts | 65 +++++----- .../src/remote-hosts-section.browser.test.tsx | 28 ++--- .../review/app/src/remote-hosts-section.tsx | 113 ++++++++++-------- packages/review/app/src/review-home-view.tsx | 16 +-- 8 files changed, 187 insertions(+), 150 deletions(-) diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs index b0bbf8ff4..d50bd9cf8 100644 --- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs +++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs @@ -478,7 +478,10 @@ async function journey(ctx, page, until) { 30000, ); assert.equal( - await canvas.locator("[role=status]").filter({ hasText: "Connection lost" }).count(), + await canvas + .locator("[role=status]") + .filter({ hasText: "Connection lost" }) + .count(), 0, ); await until( @@ -488,8 +491,7 @@ async function journey(ctx, page, until) { 60000, ); await until( - async () => - (await canvas.locator(".connection-chip").count()) === 0, + async () => (await canvas.locator(".connection-chip").count()) === 0, "the chip to clear", ); await remoteApi("session_edit", { @@ -543,8 +545,11 @@ async function journey(ctx, page, until) { section = settings.getByRole("region", { name: "Remote hosts" }); await until( - async () => /incompatible/.test(await hostRow().innerText()), - "the Settings row to read incompatible", + async () => + /runs Whiteboard .+; this Desktop runs/.test( + await hostRow().innerText(), + ), + "the Settings row to name both versions", ); assert.match( await hostRow().locator("code").innerText(), diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs index 52d339074..a381a2fba 100644 --- a/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs +++ b/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs @@ -511,17 +511,14 @@ async function steps(ctx, until, watch, timings, manifestPath) { assert.doesNotMatch(first.text, /\/home\//, "the question names a path"); const settingsSteps = (await pageRecord(ctx)).rows[fresh].filter((text) => - text.startsWith("installing · "), + STEP_ORDER.some((step) => text.startsWith(step)), ); assert.ok( settingsSteps.length >= 2, `Settings showed ${settingsSteps.length} install step(s): ${settingsSteps}`, ); - assertStepOrder( - settingsSteps.map((text) => text.slice("installing · ".length)), - "Settings", - ); + assertStepOrder(settingsSteps, "Settings"); await onRemote( fresh, `test -x ${ROOT}/versions/${version}/node_modules/@dev.fast/diffr-linux-x64/diffr`, @@ -563,7 +560,7 @@ async function steps(ctx, until, watch, timings, manifestPath) { ); await until( async () => - /unsupported · .*glibc 2\.31/.test( + /glibc 2\.31; Whiteboard needs 2\.34/.test( await hostRow(oldSection, old).innerText(), ), "the Settings row to read unsupported", diff --git a/packages/review-protocol/src/review-host-status.ts b/packages/review-protocol/src/review-host-status.ts index 0d4972331..da35e70aa 100644 --- a/packages/review-protocol/src/review-host-status.ts +++ b/packages/review-protocol/src/review-host-status.ts @@ -11,13 +11,24 @@ export interface ReviewHostStatus { busy?: true; } -const STATUS: Record< - ReviewGatewayHostState["state"], - Pick & { - status: string; - label(alias: string): string; - } -> = { +type Wording = Pick & { + status: string; + label(alias: string): string; +}; + +const OFFLINE: Wording = { + status: "offline", + label: (alias) => `${alias} offline`, + action: "retry", +}; + +const UNUSABLE: Wording = { + status: "can't be used", + label: (alias) => `${alias} can't be used`, + action: "settings", +}; + +const STATUS: Record = { online: { status: "online", label: (alias) => alias }, connecting: { status: "connecting", @@ -29,16 +40,8 @@ const STATUS: Record< label: (alias) => `Installing on ${alias}…`, busy: true, }, - offline: { - status: "offline", - label: (alias) => `${alias} offline`, - action: "retry", - }, - unreachable: { - status: "offline", - label: (alias) => `${alias} offline`, - action: "retry", - }, + offline: OFFLINE, + unreachable: OFFLINE, incompatible: { status: "needs an update", label: (alias) => `${alias} needs an update`, @@ -54,16 +57,8 @@ const STATUS: Record< label: (alias) => `Can't sign in to ${alias}`, action: "settings", }, - unsupported: { - status: "can't be used", - label: (alias) => `${alias} can't be used`, - action: "settings", - }, - duplicate: { - status: "can't be used", - label: (alias) => `${alias} can't be used`, - action: "settings", - }, + unsupported: UNUSABLE, + duplicate: UNUSABLE, }; /** One wording and one next step per host state, shared by every surface. */ diff --git a/packages/review/app/src/connection-chip.tsx b/packages/review/app/src/connection-chip.tsx index 167cc9650..f9d32da44 100644 --- a/packages/review/app/src/connection-chip.tsx +++ b/packages/review/app/src/connection-chip.tsx @@ -1,6 +1,7 @@ import { fontSize, fontWeight, motion, radius } from "@canvas/scale.stylex"; import { Button } from "@canvas/ui/button"; import { + type ReviewHostStatus, type ReviewRemoteHostActions, reviewHostStatus, } from "@dev.fast/review-protocol"; @@ -94,6 +95,25 @@ export function ConnectionChip() { ); } +/** A host's next step as a button; `failure` says why the last one failed. */ +export function useHostActionButton( + hosts: ReviewRemoteHostActions | undefined, + alias: string | undefined, + action: ReviewHostStatus["action"], + retried?: () => void, +) { + const { run, pending, failure } = useHostAction(hosts, alias, retried); + + const button = + action && hosts ? ( + + ) : null; + + return { failure, button }; +} + /** A remote review that has not loaded: what its host is doing, and the next step. */ export function HostWaiting({ host, @@ -107,18 +127,24 @@ export function HostWaiting({ lost?: string; }) { const state = useRemoteHostState(hosts, host, true); - const { run, pending, failure } = useHostAction(hosts, host, reconnect); - if (!state || state.state === "online") + const status = + state && state.state !== "online" ? reviewHostStatus(state) : undefined; + + const { failure, button } = useHostActionButton( + hosts, + host, + status?.action, + reconnect, + ); + + if (!status) return lost === undefined ? null : (

Connection lost. Reconnecting… {lost}

); - const status = reviewHostStatus(state); - const { action } = status; - return (
{failure ?? status.sentence}

- {action ? ( - - ) : null} + {button}
); } diff --git a/packages/review/app/src/remote-host-state.ts b/packages/review/app/src/remote-host-state.ts index a682d2af0..ca5954fba 100644 --- a/packages/review/app/src/remote-host-state.ts +++ b/packages/review/app/src/remote-host-state.ts @@ -5,27 +5,31 @@ import type { } from "@dev.fast/review-protocol"; import { type RefObject, useEffect, useState } from "react"; -export const STATES_EVERY_MS = 3000; +const STATES_EVERY_MS = 3000; -export function useRemoteHostState( - hosts: ReviewRemoteHostActions | undefined, - alias: string | undefined, - watch: boolean, +export function useRemoteHostStates( + hosts: Pick | undefined, + watch = true, ) { - const [state, setState] = useState(); + const [states, setStates] = useState(); useEffect(() => { - if (!hosts || !alias || !watch) return; + if (!hosts || !watch) return; let live = true; + let reading = false; const read = () => { - if (document.hidden) return; + if (document.hidden || reading) return; + reading = true; void hosts .states() - .then((states) => { - if (live) setState(states.find((host) => host.alias === alias)); + .then((next) => { + if (live) setStates(next); }) - .catch(() => undefined); + .catch(() => undefined) + .finally(() => { + reading = false; + }); }; read(); @@ -35,9 +39,19 @@ export function useRemoteHostState( live = false; clearInterval(timer); }; - }, [hosts, alias, watch]); + }, [hosts, watch]); - return watch ? state : undefined; + return watch ? states : undefined; +} + +export function useRemoteHostState( + hosts: ReviewRemoteHostActions | undefined, + alias: string | undefined, + watch: boolean, +) { + return useRemoteHostStates(alias ? hosts : undefined, watch)?.find( + (host) => host.alias === alias, + ); } export const ACTION_WORDS: Record< @@ -64,9 +78,7 @@ export function useHostAction( ? hosts.retry(alias).then(retried) : action === "install" ? hosts.install(alias) - : hosts - .openSettings() - .then(() => revealSettingsSection(REMOTE_HOSTS)); + : hosts.openSettings().then(revealRemoteHosts); void step .catch((error: Error) => setFailure(error.message)) @@ -76,27 +88,22 @@ export function useHostAction( return { run, pending, failure }; } -export const REMOTE_HOSTS = "remote-hosts"; - // Every canvas tab shares this module, so a review can ask the Settings tab, -// open or about to mount, to scroll. -let requested: string | undefined; +// open or about to mount, to scroll to Remote hosts. +let requested = false; const reveals = new EventTarget(); -function revealSettingsSection(section: string) { - requested = section; +function revealRemoteHosts() { + requested = true; reveals.dispatchEvent(new Event("reveal")); } -export function useSettingsReveal( - section: string, - ref: RefObject, -) { +export function useSettingsReveal(ref: RefObject) { useEffect(() => { const reveal = () => { - if (requested !== section || !ref.current) return; - requested = undefined; + if (!requested || !ref.current) return; + requested = false; ref.current.scrollIntoView({ block: "start" }); }; @@ -104,5 +111,5 @@ export function useSettingsReveal( reveals.addEventListener("reveal", reveal); return () => reveals.removeEventListener("reveal", reveal); - }, [section, ref]); + }, [ref]); } diff --git a/packages/review/app/src/remote-hosts-section.browser.test.tsx b/packages/review/app/src/remote-hosts-section.browser.test.tsx index 3f7e5d709..b4a78eb8b 100644 --- a/packages/review/app/src/remote-hosts-section.browser.test.tsx +++ b/packages/review/app/src/remote-hosts-section.browser.test.tsx @@ -221,9 +221,9 @@ test("lists an online host's language groups after its language features, each w expect(rows()[1]).not.toContain("rust"); }); -test("offers Retry to a host that failed to authenticate, is unreachable, not installed or unsupported", async () => { +test("offers Retry to a host that failed to authenticate, is offline or unreachable, not installed or unsupported", async () => { const hosts = remoteHosts( - ["devbox", "box2", "box3", "box4", "box5"], + ["devbox", "box2", "box3", "box4", "box5", "box6"], [ { alias: "devbox", state: "auth-failed", detail: "Permission denied" }, { alias: "box2", state: "unreachable", detail: "timed out" }, @@ -234,6 +234,7 @@ test("offers Retry to a host that failed to authenticate, is unreachable, not in detail: "This host runs glibc 2.31; Whiteboard needs 2.34 or newer.", }, { alias: "box5", state: "installing" }, + { alias: "box6", state: "offline" }, ], ); @@ -243,7 +244,6 @@ test("offers Retry to a host that failed to authenticate, is unreachable, not in page.getByRole("button", { name: "Retry devbox" }).elements(), ).toHaveLength(1), ); - expect(rows()[3]).toContain("unsupported"); expect(rows()[3]).toContain("glibc 2.31"); expect(rows()[4]).toContain("installing"); expect( @@ -251,7 +251,13 @@ test("offers Retry to a host that failed to authenticate, is unreachable, not in .getByRole("button", { name: /^Retry / }) .elements() .map((button) => button.getAttribute("aria-label")), - ).toEqual(["Retry devbox", "Retry box2", "Retry box3", "Retry box4"]); + ).toEqual([ + "Retry devbox", + "Retry box2", + "Retry box3", + "Retry box4", + "Retry box6", + ]); await page.getByRole("button", { name: "Retry box2" }).click(); expect(hosts.retry).toHaveBeenCalledWith("box2"); await page.getByRole("button", { name: "Retry box3" }).click(); @@ -292,9 +298,7 @@ test("shows the install step, offers Install to a declined host, and Retry after await render(hosts); await vi.waitFor(() => - expect(rows()[0]).toContain( - "installing · Installing the Whiteboard package.", - ), + expect(rows()[0]).toContain("Installing the Whiteboard package."), ); expect( page @@ -480,13 +484,9 @@ test("a failed host's detail copies on click; a healthy one does not", async () ), ); - await vi.waitFor(() => - expect(rows()[1]).toContain("unreachable · ssh: timed out"), - ); + await vi.waitFor(() => expect(rows()[1]).toContain("ssh: timed out")); expect(page.getByRole("button", { name: "online" }).query()).toBeNull(); - await page - .getByRole("button", { name: "unreachable · ssh: timed out" }) - .click(); - expect(writeText).toHaveBeenCalledWith("unreachable · ssh: timed out"); + await page.getByRole("button", { name: "ssh: timed out" }).click(); + expect(writeText).toHaveBeenCalledWith("ssh: timed out"); }); diff --git a/packages/review/app/src/remote-hosts-section.tsx b/packages/review/app/src/remote-hosts-section.tsx index 375bfb079..5950b6e1b 100644 --- a/packages/review/app/src/remote-hosts-section.tsx +++ b/packages/review/app/src/remote-hosts-section.tsx @@ -6,21 +6,23 @@ import { type ReviewGatewayHostState, type ReviewRemoteAgent, type ReviewRemoteHostsSettings, + reviewHostStatus, } from "@dev.fast/review-protocol"; import * as stylex from "@stylexjs/stylex"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CopyableText } from "./copy-text"; +import { useRemoteHostStates, useSettingsReveal } from "./remote-host-state"; import { settingsStyles as styles } from "./settings-styles"; import { tokens } from "./tokens.stylex"; -const STATES_EVERY_MS = 3000; - const RETRIED = new Set([ "auth-failed", "incompatible", "not-installed", + "offline", "unreachable", + "duplicate", "unsupported", ]); @@ -30,13 +32,15 @@ export function RemoteHostsSection({ hosts: ReviewRemoteHostsSettings; }) { const [configured, setConfigured] = useState(hosts.configured); - const [states, setStates] = useState(); + const states = useRemoteHostStates(hosts); const [suggestions, setSuggestions] = useState([]); const [alias, setAlias] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(); const [removing, setRemoving] = useState(); const [uninstall, setUninstall] = useState(false); + const section = useRef(null); + useSettingsReveal(section); useEffect(() => { void hosts @@ -45,33 +49,6 @@ export function RemoteHostsSection({ .catch(() => undefined); }, [hosts]); - useEffect(() => { - let live = true; - let reading = false; - - const read = () => { - if (document.hidden || reading) return; - reading = true; - void hosts - .states() - .then((next) => { - if (live) setStates(next); - }) - .catch(() => undefined) - .finally(() => { - reading = false; - }); - }; - - read(); - const timer = setInterval(read, STATES_EVERY_MS); - - return () => { - live = false; - clearInterval(timer); - }; - }, [hosts]); - const save = async (next: string[]) => { setBusy(true); setError(undefined); @@ -113,7 +90,11 @@ export function RemoteHostsSection({ }; return ( -
+

Remote hosts

{configured.map((name) => { const state = states?.find((host) => host.alias === name); @@ -125,7 +106,12 @@ export function RemoteHostsSection({ {states ? ( ) : null} {state?.state === "online" ? ( @@ -162,22 +148,18 @@ export function RemoteHostsSection({
{state?.declined ? ( - + hosts.install(name)} + /> ) : null} {state && RETRIED.has(state.state) ? ( - + hosts.retry(name)} + /> ) : null} + + ); +} + function Detail({ text, failed, diff --git a/packages/review/app/src/review-home-view.tsx b/packages/review/app/src/review-home-view.tsx index 373bdbb53..e3441c26b 100644 --- a/packages/review/app/src/review-home-view.tsx +++ b/packages/review/app/src/review-home-view.tsx @@ -20,6 +20,7 @@ import { useState, } from "react"; +import { useHostActionButton } from "./connection-chip"; import { CopyableText, copyText } from "./copy-text"; import { countLabel, formatRelativeTime } from "./format"; import { homeStyles } from "./home-styles"; @@ -42,11 +43,7 @@ import { } from "./icons"; import { homeGroupMarker, homeRowMarker } from "./markers.stylex"; import { useNotify } from "./notify"; -import { - ACTION_WORDS, - useHostAction, - useRemoteHostState, -} from "./remote-host-state"; +import { useRemoteHostState } from "./remote-host-state"; import type { ReviewFindHost } from "./review-find"; import { HomeFilterPicker, @@ -1563,18 +1560,13 @@ function HostNotice({ hosts?: ReviewRemoteHostActions; }) { const live = useRemoteHostState(hosts, alias, true); - const { run, pending, failure } = useHostAction(hosts, alias); const status = reviewHostStatus(live ?? { alias, state }); - const { action } = status; + const { failure, button } = useHostActionButton(hosts, alias, status.action); return (

- {action && hosts ? ( - - ) : null} + {button}

); } From d5f0362db82365b0cbdd49a256397bf3497f57c9 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 14:10:22 -0400 Subject: [PATCH 5/6] Reuse the host action hook and status wording in Settings Retry shows for exactly the states reviewHostStatus gives a next step, and the Settings buttons run through useHostAction. --- packages/review/app/src/remote-host-state.ts | 7 ++- .../review/app/src/remote-hosts-section.tsx | 55 ++++++------------- 2 files changed, 23 insertions(+), 39 deletions(-) diff --git a/packages/review/app/src/remote-host-state.ts b/packages/review/app/src/remote-host-state.ts index ca5954fba..7c37d9b94 100644 --- a/packages/review/app/src/remote-host-state.ts +++ b/packages/review/app/src/remote-host-state.ts @@ -61,7 +61,10 @@ export const ACTION_WORDS: Record< /** Runs a host's next step; Settings opens scrolled to Remote hosts. */ export function useHostAction( - hosts: ReviewRemoteHostActions | undefined, + hosts: + | (Pick & + Partial>) + | undefined, alias: string | undefined, retried?: () => void, ) { @@ -78,7 +81,7 @@ export function useHostAction( ? hosts.retry(alias).then(retried) : action === "install" ? hosts.install(alias) - : hosts.openSettings().then(revealRemoteHosts); + : Promise.resolve(hosts.openSettings?.()).then(revealRemoteHosts); void step .catch((error: Error) => setFailure(error.message)) diff --git a/packages/review/app/src/remote-hosts-section.tsx b/packages/review/app/src/remote-hosts-section.tsx index 5950b6e1b..de5bc1659 100644 --- a/packages/review/app/src/remote-hosts-section.tsx +++ b/packages/review/app/src/remote-hosts-section.tsx @@ -3,7 +3,6 @@ import { Checkbox } from "@canvas/ui/checkbox"; import { TextField } from "@canvas/ui/text-field"; import { REVIEW_CLI_INSTALL_TARGET_LABELS, - type ReviewGatewayHostState, type ReviewRemoteAgent, type ReviewRemoteHostsSettings, reviewHostStatus, @@ -12,20 +11,15 @@ import * as stylex from "@stylexjs/stylex"; import { useEffect, useRef, useState } from "react"; import { CopyableText } from "./copy-text"; -import { useRemoteHostStates, useSettingsReveal } from "./remote-host-state"; +import { + ACTION_WORDS, + useHostAction, + useRemoteHostStates, + useSettingsReveal, +} from "./remote-host-state"; import { settingsStyles as styles } from "./settings-styles"; import { tokens } from "./tokens.stylex"; -const RETRIED = new Set([ - "auth-failed", - "incompatible", - "not-installed", - "offline", - "unreachable", - "duplicate", - "unsupported", -]); - export function RemoteHostsSection({ hosts, }: { @@ -98,6 +92,7 @@ export function RemoteHostsSection({

Remote hosts

{configured.map((name) => { const state = states?.find((host) => host.alias === name); + const failed = !!state && !!reviewHostStatus(state).action; return (
@@ -105,7 +100,7 @@ export function RemoteHostsSection({ {name} {states ? (
{state?.declined ? ( - hosts.install(name)} - /> + ) : null} - {state && RETRIED.has(state.state) ? ( - hosts.retry(name)} - /> + {failed ? ( + ) : null} From 08940d1174f2541ff30456d741a9a4629bd90506 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Wed, 14 Oct 2026 14:10:22 -0400 Subject: [PATCH 6/6] Drop checks for the removed Connection lost banner --- apps/review-desktop/scripts/e2e/journeys/remote-host.mjs | 7 ------- packages/review/app/src/api-canvas.test.tsx | 2 -- packages/review/app/src/review-home-view.browser.test.tsx | 6 ++++-- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs index d50bd9cf8..7f0c1fc92 100644 --- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs +++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs @@ -477,13 +477,6 @@ async function journey(ctx, page, until) { "the disconnected chip", 30000, ); - assert.equal( - await canvas - .locator("[role=status]") - .filter({ hasText: "Connection lost" }) - .count(), - 0, - ); await until( async () => (await hostState())?.state === "online" && (await masterPid()) !== master, diff --git a/packages/review/app/src/api-canvas.test.tsx b/packages/review/app/src/api-canvas.test.tsx index a83080902..7804078e9 100644 --- a/packages/review/app/src/api-canvas.test.tsx +++ b/packages/review/app/src/api-canvas.test.tsx @@ -1387,7 +1387,6 @@ it("shows a lost remote host as a top-bar chip that retries the host and the str return found!; }); - expect(container.textContent).not.toContain("Connection lost"); expect(chip.title).toBe( "Click to retry\nwb-a is offline: it did not answer.", ); @@ -1452,7 +1451,6 @@ it("shows why a remote review has not loaded, with its host's next step", async expect(waiting.textContent).toContain( "wb-a runs Whiteboard 0.1.0; this Desktop runs 0.2.0.", ); - expect(container.textContent).not.toContain("Connection lost"); await act(async () => [...waiting.querySelectorAll("button")] .find((button) => button.textContent === "Install")! diff --git a/packages/review/app/src/review-home-view.browser.test.tsx b/packages/review/app/src/review-home-view.browser.test.tsx index 86bfeef83..82dde9b03 100644 --- a/packages/review/app/src/review-home-view.browser.test.tsx +++ b/packages/review/app/src/review-home-view.browser.test.tsx @@ -950,11 +950,13 @@ describe("Home interactions", () => { remoteHosts: hostsOf(() => answer.promise), }); await click("Open Remote"); - expect(alert()).toBe("devbox offline.Retry"); + expect(alert()).toContain("devbox offline."); + expect(button("Retry")).toBeTruthy(); await act(async () => answer.resolve([{ alias: "devbox", state: "offline" }]), ); - expect(alert()).toBe("devbox offline.Retry"); + expect(alert()).toContain("devbox offline."); + expect(button("Retry")).toBeTruthy(); }); it("keeps the message of the last unavailable review opened, and opens that review once its host is back", async () => { const answers = [