diff --git a/apps/review-desktop/UPSTREAM b/apps/review-desktop/UPSTREAM
index 5a35951ab..842cc0b53 100644
--- a/apps/review-desktop/UPSTREAM
+++ b/apps/review-desktop/UPSTREAM
@@ -369,6 +369,10 @@ one of these entries:
fatal either: it ends that connection only. Isolation is unconditional, so a
remote Source window gets no upstream reload dialog; it reconnects through
the fork's resolver and shows its host state in the status bar.
+- `src/vs/platform/window/common/window.ts` and
+ `src/vs/platform/native/electron-main/nativeHostMainService.ts`: the
+ `openWindow` option `reviewReferencesToShow` runs Find References in the
+ opened window.
- `extensions/package.json` and the extension `package.json`/`package-lock.json`
files: `@vscode/extension-telemetry` is hoisted to the shared extensions
install (github keeps its own ^1.0.0 copy; the shared copy is ^0.9.8). The
@@ -614,7 +618,8 @@ one of these entries:
profile, extension host status, lifetime and extensions scanner services,
and the `logger`, `userDataProfiles`, `remoteextensionsenvironment`,
`remoteExtensionsScanner`, `remoteFilesystem` and extension host debug
- broadcast channels. `telemetry` is a no-op channel and `ITelemetryService`
+ broadcast channels. `telemetry` is a no-op channel, `extensions` and
+ `extensionGalleryManifest` list no extensions, and `ITelemetryService`
is `NullTelemetryService`. Cuts requests, telemetry appenders and machine
ids, the gallery and extension installs, language packs, terminals, the
agent host, MCP and the sandbox helper.
diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewApiEditorSerializer.test.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewApiEditorSerializer.test.ts
index 0a474ab7c..8c1635fa0 100644
--- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewApiEditorSerializer.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewApiEditorSerializer.test.ts
@@ -26,12 +26,15 @@ test("opening historical source requests its version and opens a separate native
{ async getConnection() { return { serverUrl: "http://localhost", token: "test" }; } } as never,
{ async openWindow(...args: unknown[]) { opened.push(args); } } as never,
{ warn() {} } as never,
+ {} as never,
+ {} as never,
);
t.after(() => tabs.dispose());
await tabs.openApiSource({ reviewId: "review-a", kind: "version", version: 7 }, "Historical Review");
assert.equal(requests[0], "http://localhost/reviews-api/review-a/navigator?version=7");
assert.equal(opened.length, 1);
- assert.deepEqual(opened[0]![1], { forceNewWindow: true });
+ assert.equal((opened[0]![0] as { workspaceUri: { fsPath: string } }[])[0].workspaceUri.fsPath, "/pinned/review.code-workspace");
+ assert.equal((opened[0]![1] as { remoteAuthority?: string }).remoteAuthority, undefined);
ok = false;
await assert.rejects(tabs.openApiSource({ reviewId: "review-a", kind: "current" }, "Review"));
assert.equal(opened.length, 1);
@@ -60,7 +63,7 @@ test("native group restoration preserves both reviews, order and pinned source v
};
const groupService = { groups, mainPart: { activeGroup: undefined as EditorGroupModel | undefined } };
const createTabs = () =>
- new ReviewCanvasEditorTabsService(instantiation as never, editors as never, groupService as never, {} as never, {} as never, { warn() {} } as never);
+ new ReviewCanvasEditorTabsService(instantiation as never, editors as never, groupService as never, {} as never, {} as never, { warn() {} } as never, {} as never, {} as never);
tabs = createTabs();
const registry = Registry.as(EditorExtensions.EditorFactory);
registry.start({ get: () => instantiation } as never);
@@ -127,7 +130,7 @@ test("current Source tabs retain identity and main version tabs still restore",
},
invokeFunction(fn: (accessor: { get(): ReviewCanvasEditorTabsService }) => unknown) { return fn({ get: () => tabs }); },
};
- tabs = new ReviewCanvasEditorTabsService(instantiation as never, { onDidCloseEditor: Event.None } as never, {} as never, {} as never, {} as never, { warn() {} } as never);
+ tabs = new ReviewCanvasEditorTabsService(instantiation as never, { onDidCloseEditor: Event.None } as never, {} as never, {} as never, {} as never, { warn() {} } as never, {} as never, {} as never);
try {
const serializer = new ReviewApiEditorSerializer();
const restored = serializer.deserialize(instantiation as never, JSON.stringify({ kind: "api-source", reviewId: "a", title: "A", selection: { reviewId: "a", kind: "current" } }));
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 86d019584..1f312fb33 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,9 +71,11 @@ import type {
ReviewDocumentWidthChoice,
ReviewKeymapChoice,
ReviewReadyNotificationChoice,
+ ReviewRemoteHostActions,
ReviewRuntimeConfig,
ReviewSurfaceEvent,
ReviewTheme,
+ ReviewVerbResponse,
TutorialProgressV1,
TutorialStepId,
} from "../../../common/reviewProtocol.js";
@@ -86,6 +88,7 @@ import {
} from "../../../common/reviewProtocol.js";
import { IReviewVerbsService } from "../../../contrib/verbs/reviewVerbs.js";
import { vetoStoppingAskAgents } from "../../reviewAskShutdown.js";
+import { notifyOpenFailure } from "./reviewHostFailure.js";
import { confirmReviewDeletion } from "../../reviewDeleteConfirmation.js";
import { showReviewNotification } from "../../reviewNotification.js";
import { showReviewCanvasMenu } from "../../reviewCanvasMenu.js";
@@ -459,6 +462,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(),
@@ -487,20 +491,18 @@ export class ReviewCanvasEditorPane extends EditorPane {
request: requestReviewApi,
post: async (request) => {
if (request.name === "openSourceTree") {
- await this.tabsService.openApiSource(sourceSelection, input.getName());
- return { ok: true };
+ return this.openSource(() => this.tabsService.openApiSource(sourceSelection, input.getName()));
}
if (request.name === "reveal") {
- const range = { startLine: request.args.startLine, endLine: request.args.endLine };
- await this.apiSource.open(
- { view: reviewSourceAnchor(sourceView, request.args.pins), file: request.args.path, side: request.args.side ?? "head" },
- range,
- );
- return { ok: true };
+ const { args } = request;
+ return this.openSource(() => this.apiSource.open(
+ { view: reviewSourceAnchor(sourceView, args.pins), file: args.path, side: args.side ?? "head" },
+ { startLine: args.startLine, endLine: args.endLine },
+ ));
}
if (request.name === "openDiff") {
- await this.apiSource.openDiff(sourceView, request.args.path);
- return { ok: true };
+ const { path } = request.args;
+ return this.openSource(() => this.apiSource.openDiff(sourceView, path));
}
return this.verbs.dispatch(request);
},
@@ -574,11 +576,11 @@ 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) {
- void this.tabsService.openApiSource({ reviewId: api.reviewId, kind: "current" }, api.title).catch(error => this.notificationService.error(error));
+ void this.tabsService.openApiSource({ reviewId: api.reviewId, kind: "current" }, api.title).catch(error => this.notifyOpenFailure(error));
return;
}
},
@@ -642,6 +644,32 @@ export class ReviewCanvasEditorPane extends EditorPane {
}
}
+ private notifyOpenFailure(error: unknown): void {
+ notifyOpenFailure(this.notificationService, (alias) => this.desktopConnection.retryRemoteHost(alias), error);
+ }
+
+ /** Answers the canvas instead of rejecting, once the failure is shown. */
+ private async openSource(open: () => Promise): Promise {
+ try {
+ await open();
+ return { ok: true };
+ } catch (error) {
+ this.notifyOpenFailure(error);
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ }
+
+ 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/code-oss/src/vs/review/browser/parts/canvas/reviewHostFailure.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewHostFailure.ts
new file mode 100644
index 000000000..19530ddea
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewHostFailure.ts
@@ -0,0 +1,28 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import Severity from "../../../../base/common/severity.js";
+import type { INotificationService } from "../../../../platform/notification/common/notification.js";
+import { reviewHostStatus, type ReviewGatewayHostState } from "../../../common/reviewProtocol.js";
+
+type HostDown = Pick;
+
+export class ReviewHostDown extends Error {
+ constructor(readonly host: HostDown) {
+ super(reviewHostStatus(host).sentence);
+ }
+}
+
+export function notifyOpenFailure(notifications: INotificationService, retry: (alias: string) => Promise, error: unknown): void {
+ if (!(error instanceof ReviewHostDown)) {
+ notifications.error(error instanceof Error ? error : String(error));
+ return;
+ }
+ const { alias } = error.host;
+ const choices = reviewHostStatus(error.host).action === "retry"
+ ? [{ label: "Retry", run: () => void retry(alias).catch(retryError => notifications.error(retryError)) }]
+ : [];
+ notifications.prompt(Severity.Warning, error.message, choices);
+}
diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.test.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.test.ts
index 1fc09d905..fb7c920c6 100644
--- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.test.ts
@@ -4,7 +4,7 @@ import test from "node:test";
import type { ReviewApiSummary } from "../../../common/reviewProtocol.js";
import { remoteEntry, withRemoteEntry } from "./reviewRemoteEntry.js";
-const available = { sourceWindows: false, languageFeatures: false };
+const available = { sourceWindows: false };
const entry = (host?: string) => ({ reviewId: "r1", title: "Remote", ...(host && { host, available }) }) as ReviewApiSummary;
test("a review a remote opened before its list entry arrived takes the host once the entry does", () => {
@@ -15,6 +15,8 @@ test("a review a remote opened before its list entry arrived takes the host once
const updated = withRemoteEntry(mounted, entry("wb-a"));
assert.deepEqual(updated, { kind: "api", reviewId: "r1", host: "wb-a", available });
assert.equal(withRemoteEntry(updated!, entry("wb-a")), undefined);
+
+ assert.deepEqual(withRemoteEntry(updated!, { ...entry("wb-a"), hostState: "offline" }), { ...updated, hostState: "offline" });
});
test("a laptop review's entry changes nothing", () => {
diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.ts
index af1d21611..9ca0aee1a 100644
--- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.ts
@@ -1,12 +1,12 @@
import type { ReviewApiSummary } from "../../../common/reviewProtocol.js";
-type RemoteFields = Pick;
+type RemoteFields = Pick;
export const remoteEntry = (review: ReviewApiSummary | undefined): RemoteFields =>
- review?.host ? { host: review.host, available: review.available } : {};
+ review?.host ? { host: review.host, ...(review.hostState && { hostState: review.hostState }), available: review.available } : {};
export function withRemoteEntry(content: T, review: ReviewApiSummary | undefined): T | undefined {
const entry = remoteEntry(review);
- if (!entry.host || (entry.host === content.host && JSON.stringify(entry.available) === JSON.stringify(content.available))) return undefined;
+ if (!entry.host || (entry.host === content.host && entry.hostState === content.hostState && JSON.stringify(entry.available) === JSON.stringify(content.available))) return undefined;
return { ...content, ...entry };
}
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts
index 88c5e98c7..e50a3c8ca 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.test.ts
@@ -381,7 +381,7 @@ test("a cancelled prompt is auth-failed, and there is no second attempt", async
await until(() => last()?.problem !== undefined);
assert.equal(last()?.problem?.state, "auth-failed");
- assert.equal(last()!.problem!.detail, "dev@127.0.0.1: Permission denied (publickey,password).");
+ assert.equal(last()!.problem!.detail, `wb-test-a did not accept your SSH key. Check that "ssh wb-test-a" works in a terminal.`);
assert.equal(clock.pending, 0);
assert.equal(ssh.of("wb-test-a", "master").length, 1);
});
@@ -404,7 +404,7 @@ test("the master exits and the host reconnects after the backoff", async (t) =>
await until(() => last()?.problem !== undefined);
assert.equal(last()?.problem?.state, "unreachable");
- assert.match(last()!.problem!.detail, /closed by remote host/);
+ assert.equal(last()!.problem!.detail, "wb-test-a closed the SSH connection.");
assert.equal(ssh.of("wb-test-a", "master").length, 1);
assert.ok(clock.next());
await until(() => last()?.endpoint !== undefined);
@@ -488,7 +488,7 @@ test("an authenticated master that ends is unreachable and retried, whatever its
await until(() => last()?.problem !== undefined);
assert.equal(last()?.problem?.state, "unreachable");
- assert.equal(last()!.problem!.detail, "The SSH connection to wb-test-a ended: Connection reset by peer");
+ assert.equal(last()!.problem!.detail, "wb-test-a closed the SSH connection.");
assert.equal(clock.pending, 1);
});
@@ -724,18 +724,17 @@ test("the version present: no prompt and no install shown; the installed CLI att
);
});
-test("the version present with another sha256 is not installed: the user is asked and the install is shown", async (t) => {
+test("the version present with another sha256 is reinstalled without asking, and the install is shown", async (t) => {
const port = await healthServer(t);
- const { flow, prompts, runs } = await installFlow(t, "ask", { answers: [true], steps: STEPS });
+ const { flow, prompts, runs } = await installFlow(t, "ask", { steps: STEPS });
const { host, reports, last } = hostFor(t, { probe: { installed: [at("0.1.5"), at("0.1.6", "b".repeat(64))] } }, port, "wb-test-a", "/tmp/wb-ssh-test", flow);
host.start();
await until(() => last()?.endpoint !== undefined);
- assert.equal(prompts.length, 1);
+ assert.equal(prompts.length, 0);
assert.equal(runs.length, 1);
assert.deepEqual(reports.find((report) => report.installing)?.installing, { step: "preparing" });
- assert.equal(await flow.consent.get("wb-test-a"), "allow");
});
test("the version absent with installs always: each step is reported, then the host attaches", async (t) => {
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts
index 66cca15b0..b6b4075aa 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts
@@ -83,6 +83,28 @@ export function classifySshFailure(stderr: string, promptCancelled: boolean): "a
: "unreachable";
}
+const SSH_SENTENCES: [RegExp, (alias: string) => string][] = [
+ [/REMOTE HOST IDENTIFICATION HAS CHANGED/, (alias) => `${alias}'s host key changed since you last connected. Verify the new fingerprint before replacing its old key in known_hosts.`],
+ [/Host key verification failed/, (alias) => `${alias}'s host key could not be verified. Run "ssh ${alias}" in a terminal and verify its fingerprint before accepting it.`],
+ [/Permission denied/, (alias) => `${alias} did not accept your SSH key. Check that "ssh ${alias}" works in a terminal.`],
+ [/Could not resolve hostname/, (alias) => `${alias}'s host name could not be found. Check its address in your SSH config.`],
+ [/Connection refused/, (alias) => `${alias} refused the SSH connection. Check that its SSH server is running.`],
+ [/(Operation|Connection) timed out/, (alias) => `The SSH connection to ${alias} timed out. Check that it is on and reachable from this network.`],
+];
+
+function sshFailureSentence(stderr: string, alias: string): string | undefined {
+ return SSH_SENTENCES.find(([pattern]) => pattern.test(stderr))?.[1](alias);
+}
+
+const SSH_DROPS: [RegExp, (alias: string) => string][] = [
+ [/Timeout, server .* not responding/, (alias) => `${alias} stopped answering over SSH. It may be asleep, or off this network.`],
+ [/Connection reset|Broken pipe|closed by remote host/, (alias) => `${alias} closed the SSH connection.`],
+];
+
+function sshDropSentence(lastLines: string, alias: string): string | undefined {
+ return SSH_DROPS.find(([pattern]) => pattern.test(lastLines))?.[1](alias);
+}
+
export const OPENSSH_NEEDED = "OpenSSH is needed: no `ssh` command was found on PATH.";
type Problem = NonNullable;
@@ -557,14 +579,17 @@ export class ReviewRemoteHost {
if (error?.code === "ENOENT") return this.fail({ state: "unreachable", detail: OPENSSH_NEEDED });
const exited = error?.message || `ssh exited with code ${code ?? "none"}.`;
if (this.connectedAt !== undefined) {
+ const last = lastLines(this.masterStderr);
return this.fail({
state: "unreachable",
- detail: `The SSH connection to ${this.alias} ended: ${lastLines(this.masterStderr) || exited}`,
+ detail: sshDropSentence(last, this.alias) ?? `The SSH connection to ${this.alias} ended: ${last || exited}`,
});
}
+ const sentence = sshFailureSentence(this.masterStderr, this.alias);
+ if (sentence) this.options.log(`${this.alias}: ${exited} ${firstLines(this.masterStderr)}`);
this.fail({
state: classifySshFailure(this.masterStderr, this.promptCancelled),
- detail: firstLines(this.masterStderr) || exited,
+ detail: sentence ?? (firstLines(this.masterStderr) || exited),
});
}
@@ -613,7 +638,9 @@ export class ReviewRemoteHost {
if (artifact instanceof ReviewRemoteNoTarball) return this.cannotInstall(probe, version, noTarballText(this.alias));
if (artifact instanceof Error) return this.cannotInstall(probe, version, installFailureText(this.alias, version, "preparing", artifact.message));
if (!present && !probe.releasesReachable) return this.cannotInstall(probe, version, `${this.alias} cannot reach ${origin}.`);
- if (!present && mode === "ask" && !(await this.agreed(flow, probe, version, stale))) {
+ // Like VS Code's server, Desktop updates its own installs without asking; a CLI the user installed is theirs.
+ const upgrade = probe.installed.length > 0;
+ if (!present && !upgrade && mode === "ask" && !(await this.agreed(flow, probe, version, stale))) {
this.declined = true;
return stale() ? undefined : onPath;
}
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts
index 195dcc3e3..10595f9ef 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts
@@ -12,7 +12,7 @@ import test from "node:test";
import type { ReviewGatewayHost } from "../../common/reviewProtocol.js";
import { attachOutput, COMMIT, detectOutput, FAKE_SERVER_ID, fakeClock, fakeSsh, flush, healthServer, until, type FakeRemote } from "./test/fakeSsh.js";
import type { ReviewRemoteInstallFlow } from "./reviewRemoteHost.js";
-import { ReviewRemoteHosts } from "./reviewRemoteHosts.js";
+import { closeRemoteHostWindows, ReviewRemoteHosts, type ReviewRemoteHostsOptions } from "./reviewRemoteHosts.js";
import { openRemoteInstallConsent } from "./reviewRemoteInstallConsent.js";
import type { SshPromptRequest } from "./reviewSshAskpass.js";
import { reviewSshInstancePrefix } from "./reviewSshCommand.js";
@@ -23,6 +23,7 @@ async function managerFor(
answer?: string,
before?: (directory: string) => Promise,
install?: ReviewRemoteInstallFlow,
+ removed?: ReviewRemoteHostsOptions["removed"],
) {
const port = await healthServer(t);
const directory = await mkdtemp(join(tmpdir(), "wb-hosts-"));
@@ -48,6 +49,7 @@ async function managerFor(
clock,
timeouts: { poll: 1 },
install,
+ removed,
});
t.after(async () => {
await manager.dispose();
@@ -98,6 +100,49 @@ test("removing a host from the setting closes its connection", async (t) => {
assert.ok(ssh.master("wb-test-b")!.alive);
});
+test("removing a machine's last alias closes the windows bound to it, and no other window", async (t) => {
+ const closed: string[] = [];
+ const windows = [`whiteboard+${FAKE_SERVER_ID}`, "whiteboard+wb-test-b-server", "ssh-remote+x", undefined].map((remoteAuthority) => ({
+ remoteAuthority,
+ close: () => closed.push(String(remoteAuthority)),
+ }));
+ const b = { attach: { code: 0, stdout: attachOutput(41234, "remote-token", { serverId: "wb-test-b-server" }) } };
+ const { manager, sentUntil } = await managerFor(t, { "wb-test-a": {}, "wb-test-a2": {}, "wb-test-b": b }, undefined, undefined, undefined, (serverId) =>
+ closeRemoteHostWindows({ getWindows: () => windows }, serverId),
+ );
+
+ manager.update(true, ["wb-test-a", "wb-test-a2", "wb-test-b"]);
+ await sentUntil((hosts) => hosts.length === 3 && hosts.every((h) => h.endpoint));
+ manager.update(true, ["wb-test-a", "wb-test-b"]);
+ assert.deepEqual(closed, []);
+ manager.update(true, ["wb-test-b"]);
+ assert.deepEqual(closed, [`whiteboard+${FAKE_SERVER_ID}`]);
+});
+
+test("a machine whose entry lost its id while unreachable is known through its alias until that alias leaves the setting", async (t) => {
+ const { manager, sentUntil } = await managerFor(t, { "wb-test-a": {} });
+ manager.update(true, ["wb-test-a"]);
+ await sentUntil((hosts) => byAlias(hosts, "wb-test-a")?.endpoint !== undefined);
+
+ const unreachable = [{ alias: "wb-test-a", state: "unreachable" as const }];
+ assert.deepEqual(manager.hostState(FAKE_SERVER_ID, unreachable), { alias: "wb-test-a", state: "unreachable" });
+ assert.deepEqual(manager.hostState(FAKE_SERVER_ID, []), { alias: "wb-test-a", state: "connecting" });
+ assert.equal(manager.hostState("other-machine", unreachable), undefined);
+
+ manager.update(true, []);
+ assert.equal(manager.hostState(FAKE_SERVER_ID, unreachable), undefined);
+});
+
+test("after a restart, a window's last alias gives its machine's state until that alias attaches", async (t) => {
+ const { manager } = await managerFor(t, { "wb-test-a": { master: "hang" } });
+ manager.update(true, ["wb-test-a"]);
+
+ const unreachable = [{ alias: "wb-test-a", state: "unreachable" as const }];
+ assert.deepEqual(manager.hostState(FAKE_SERVER_ID, unreachable, "wb-test-a"), { alias: "wb-test-a", state: "unreachable" });
+ assert.equal(manager.hostState(FAKE_SERVER_ID, unreachable), undefined);
+ assert.equal(manager.hostState(FAKE_SERVER_ID, unreachable, "gone"), undefined);
+});
+
test("an alias added again while its old host closes starts only after the old master exited", async (t) => {
const { manager, ssh, sentUntil } = await managerFor(t, { "wb-test-a": { exitDelayMs: 150 } });
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts
index d2fabf456..97f788f4a 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts
@@ -18,6 +18,7 @@ import {
type SpawnSsh,
} from "./reviewRemoteHost.js";
import type { ReviewSshAskpass, SshPromptRequest } from "./reviewSshAskpass.js";
+import { reviewRemoteAuthority } from "../../services/remote/reviewRemoteAuthority.js";
import { uninstallRemote } from "./reviewRemoteUninstall.js";
import {
prepareSshControlDirectory,
@@ -48,6 +49,26 @@ export interface ReviewRemoteHostsOptions {
readonly clock?: ReviewRemoteClock;
readonly timeouts?: ReviewRemoteHostOptions["timeouts"];
readonly install?: ReviewRemoteInstallFlow;
+ /** No host in the setting serves this machine any more. */
+ removed?(serverId: string): void;
+}
+
+/** The gateway's state of the machine `serverId`; `configured` is the alias that served it. */
+export function remoteHostState(
+ serverId: string,
+ states: readonly ReviewGatewayHostState[],
+ configured?: string,
+): ({ alias?: string } & Pick) | undefined {
+ const own = states.filter((state) => state.serverId === serverId);
+ const best = own.find((state) => state.state === "online") ?? own.find((state) => state.state === "connecting") ?? own[0];
+ if (best) return best;
+ if (configured) return states.find((state) => state.alias === configured) ?? { alias: configured, state: "connecting" };
+ return states.some((state) => state.serverId === undefined && state.state === "connecting") ? { state: "connecting" } : undefined;
+}
+
+export function closeRemoteHostWindows(windows: { getWindows(): readonly { readonly remoteAuthority?: string; close(): void }[] }, serverId: string): void {
+ const authority = reviewRemoteAuthority(serverId);
+ if (authority) for (const window of windows.getWindows()) if (window.remoteAuthority === authority) window.close();
}
export function freeLoopbackPort(): Promise {
@@ -110,6 +131,7 @@ export class ReviewRemoteHosts {
this.hosts.delete(alias);
this.options.log(`${alias}: removed from the setting; closing its connection.`);
this.closing.set(host, host.dispose().finally(() => this.closing.delete(host)));
+ this.forget(host);
}
this.order = wanted;
this.refused.clear();
@@ -183,6 +205,7 @@ export class ReviewRemoteHosts {
if (host && this.hosts.get(alias) === host) {
this.hosts.delete(alias);
this.removed.add(alias);
+ this.forget(host);
this.publish();
}
if (!host) return;
@@ -203,6 +226,13 @@ export class ReviewRemoteHosts {
return online && this.hosts.get(online.alias)?.languageEndpoint(serverId);
}
+ /** The state of machine `serverId`, through the alias that served it, or `last` until that alias attaches. */
+ hostState(serverId: string, states: readonly ReviewGatewayHostState[], last?: string): ReturnType {
+ const configured = [...this.hosts.values()].find((host) => host.serverId === serverId)?.state.alias
+ ?? (last !== undefined && this.hosts.has(last) && this.hosts.get(last)?.serverId === undefined ? last : undefined);
+ return remoteHostState(serverId, states, configured);
+ }
+
resume(): void {
for (const host of this.hosts.values()) void host.resume();
}
@@ -239,6 +269,11 @@ export class ReviewRemoteHosts {
});
}
+ private forget(host: ReviewRemoteHost): void {
+ const { serverId } = host;
+ if (serverId && ![...this.hosts.values()].some((other) => other.serverId === serverId)) this.options.removed?.(serverId);
+ }
+
private askpass(): Promise {
return (this.prepared ??= (async () => {
await prepareSshControlDirectory(this.options.controlDirectory);
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts
index e2b6159d8..a2876dea8 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.test.ts
@@ -14,7 +14,7 @@ import test from "node:test";
import type { SpawnSsh } from "./reviewRemoteHost.js";
import { probeRemote } from "./reviewRemoteProbe.js";
import { uninstallRemote } from "./reviewRemoteUninstall.js";
-import { compareVersions, installRemote, type ReviewRemoteInstallInput, type ReviewRemoteInstallProgress } from "./reviewRemoteInstaller.js";
+import { installRemote, type ReviewRemoteInstallInput, type ReviewRemoteInstallProgress } from "./reviewRemoteInstaller.js";
import { cleanupScript, lockScript, REVIEW_REMOTE_INSTALL_MARKER, REVIEW_REMOTE_WRAPPER_MARK, shellQuote } from "./reviewRemoteInstallScript.js";
import type { ReviewRemoteProbe } from "./reviewRemoteProbe.js";
import { reviewSshSession } from "./reviewSshCommand.js";
@@ -430,11 +430,6 @@ test("shellQuote survives quotes and refuses control characters", async () => {
assert.throws(() => shellQuote("a\u0007b"), /control character/);
});
-test("versions order as semver", () => {
- const sorted = ["0.1.10", "0.1.7", "0.1.7-preview.20261003.10", "0.1.6", "0.1.7-preview.20261003.2", "0.2.0-preview.1"].sort(compareVersions);
- assert.deepEqual(sorted, ["0.1.6", "0.1.7-preview.20261003.2", "0.1.7-preview.20261003.10", "0.1.7", "0.1.10", "0.2.0-preview.1"]);
-});
-
test("a lock taken over between the stealer's two reads is left to its new holder", async (t) => {
const f = await fixture(t);
const lock = join(f.remoteRoot, "install.lock");
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts
index eb27645ca..98987249e 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.ts
@@ -6,6 +6,8 @@
import { randomBytes } from "node:crypto";
import { hostname } from "node:os";
+import * as semver from "../../../base/common/semver/semver.js";
+
import type { ReviewRemoteArtifact } from "./reviewRemoteArtifacts.js";
import { lastLine, runSsh, type RunResult, type SpawnSsh, type SshChildProcess } from "./reviewRemoteHost.js";
import {
@@ -169,7 +171,7 @@ export async function installRemote(input: ReviewRemoteInstallInput): Promise REVIEW_REMOTE_VERSION.test(name));
- const newest = [input.version, ...have].sort(compareVersions).at(-1);
+ const newest = [input.version, ...have].sort(semver.compare).at(-1);
const finished = await run(
"finishing",
finishScript(context, {
@@ -181,7 +183,7 @@ export async function installRemote(input: ReviewRemoteInstallInput): Promise !keep.has(name)).sort(compareVersions).reverse();
+ const candidates = have.filter((name) => !keep.has(name)).sort(semver.rcompare);
if (candidates.length) {
await run("removing old versions", cleanupScript(context, { candidates, room: 2 - keep.size })).catch((error: unknown) => {
if (signal.aborted) throw error;
@@ -239,31 +241,6 @@ function parseVersion(text: string | undefined): string | undefined {
}
}
-export function compareVersions(a: string, b: string): number {
- const [coreA, preA] = split(a);
- const [coreB, preB] = split(b);
- for (let i = 0; i < 3; i++) if (coreA[i] !== coreB[i]) return coreA[i] - coreB[i];
- if (!preA.length || !preB.length) return preB.length - preA.length;
- for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
- const x = preA[i];
- const y = preB[i];
- if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
- if (x === y) continue;
- const nx = /^\d+$/.test(x);
- const ny = /^\d+$/.test(y);
- if (nx && ny) return Number(x) - Number(y);
- if (nx !== ny) return nx ? -1 : 1;
- return x < y ? -1 : 1;
- }
- return 0;
-}
-
-function split(version: string): [number[], string[]] {
- const dash = version.indexOf("-");
- const core = (dash < 0 ? version : version.slice(0, dash)).split(".").map(Number);
- return [core, dash < 0 ? [] : version.slice(dash + 1).split(".")];
-}
-
function delay(ms: number, signal: AbortSignal): Promise {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts
index ed35cb9db..d86f1b780 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteUninstall.ts
@@ -3,9 +3,9 @@
* Licensed under the MIT License. See LICENSE in the repository root for license information.
*--------------------------------------------------------------------------------------------*/
+import * as semver from "../../../base/common/semver/semver.js";
import { plainText } from "./reviewRemoteAgents.js";
import { lastLine, runSsh, type SpawnSsh } from "./reviewRemoteHost.js";
-import { compareVersions } from "./reviewRemoteInstaller.js";
import { REVIEW_REMOTE_INSTALL_MARKER, REVIEW_REMOTE_INSTALL_SAY, REVIEW_REMOTE_ROOT_SCRIPT, REVIEW_REMOTE_VERSION, shellQuote } from "./reviewRemoteInstallScript.js";
import { sshExecArgs, type ReviewSshSession } from "./reviewSshCommand.js";
@@ -47,7 +47,7 @@ export async function uninstallRemote(input: {
if (!root?.startsWith("/")) throw failed("DEV_REVIEW_HOME there is not an absolute, normalised path.");
const newest = said("HAVE")
.filter((name) => REVIEW_REMOTE_VERSION.test(name))
- .sort(compareVersions)
+ .sort(semver.compare)
.at(-1);
if (!newest) throw failed("Whiteboard Desktop installed nothing there.");
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts
index a538b0703..69ea22d73 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts
@@ -57,6 +57,11 @@ export class ReviewDesktopChannel implements IServerChannel {
if (typeof arg !== "string") return undefined as T;
return (await this.host.getRemoteLanguageEndpoint(arg)) as T;
}
+ if (command === "getRemoteHostState") {
+ const [serverId, last] = Array.isArray(arg) ? arg : [arg];
+ if (typeof serverId !== "string") return undefined as T;
+ return (await this.host.getRemoteHostState(serverId, typeof last === "string" ? last : undefined)) as T;
+ }
if (command === "retryRemoteHost") {
if (typeof arg === "string") this.host.retryRemoteHost(arg);
return undefined as T;
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts
index fa0ad8c39..47fe9b11d 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts
@@ -9,6 +9,7 @@ import { Disposable, toDisposable } from "../../base/common/lifecycle.js";
import { join } from "../../base/common/path.js";
import { IConfigurationService } from "../../platform/configuration/common/configuration.js";
import { IEnvironmentMainService } from "../../platform/environment/electron-main/environmentMainService.js";
+import { IInstantiationService } from "../../platform/instantiation/common/instantiation.js";
import {
ILifecycleMainService,
LifecycleMainPhase,
@@ -20,6 +21,7 @@ import { IApplicationStorageMainService } from "../../platform/storage/electron-
import { NullTelemetryService } from "../../platform/telemetry/common/telemetryUtils.js";
import { IUpdateService } from "../../platform/update/common/update.js";
import { UtilityProcess } from "../../platform/utilityProcess/electron-main/utilityProcess.js";
+import { IWindowsMainService } from "../../platform/windows/electron-main/windows.js";
import type { ReviewDesktopConnection } from "../common/reviewDesktopBootstrap.js";
import type {
ReviewGatewayHostState,
@@ -52,7 +54,7 @@ import type {
ReviewRemoteInstallFlow,
ReviewRemoteInstallMode,
} from "./remote/reviewRemoteHost.js";
-import { ReviewRemoteHosts } from "./remote/reviewRemoteHosts.js";
+import { closeRemoteHostWindows, ReviewRemoteHosts } from "./remote/reviewRemoteHosts.js";
import {
openRemoteInstallConsent,
reviewRemoteInstallConsentPath,
@@ -100,6 +102,8 @@ export class ReviewDesktopHost extends Disposable {
@IUpdateService private readonly updateService: IUpdateService,
@IApplicationStorageMainService
private readonly applicationStorageMainService: IApplicationStorageMainService,
+ @IInstantiationService
+ private readonly instantiationService: IInstantiationService,
) {
super();
let resolvedEnvironment: Promise | undefined;
@@ -255,17 +259,23 @@ export class ReviewDesktopHost extends Disposable {
async getRemoteLanguageEndpoint(serverId: string) {
const manager = this.remoteHosts;
- if (!manager) return undefined;
+ const states = manager && (await this.remoteHostStates());
+ return states && manager.languageEndpoint(serverId, states).catch(() => undefined);
+ }
+
+ async getRemoteHostState(serverId: string, last?: string) {
+ const manager = this.remoteHosts;
+ const states = manager && (await this.remoteHostStates());
+ return states && manager.hostState(serverId, states, last);
+ }
+
+ private async remoteHostStates(): Promise {
try {
const { url, token } = await this.whenConnected();
const response = await fetch(new URL("/remote-hosts", url), {
headers: { "x-review-token": token },
});
- if (!response.ok) return undefined;
- return await manager.languageEndpoint(
- serverId,
- (await response.json()) as ReviewGatewayHostState[],
- );
+ return response.ok ? ((await response.json()) as ReviewGatewayHostState[]) : undefined;
} catch {
return undefined;
}
@@ -354,6 +364,10 @@ export class ReviewDesktopHost extends Disposable {
send: (hosts) => this.supervisor.setRemoteHosts(hosts),
log: (message) => this.logService.info(`[Remote hosts] ${message}`),
install: this.remoteInstallFlow(),
+ removed: (serverId) =>
+ this.instantiationService.invokeFunction((accessor) =>
+ closeRemoteHostWindows(accessor.get(IWindowsMainService), serverId),
+ ),
});
this.remoteHosts = manager;
const update = () => {
diff --git a/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts b/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts
index d4bbff646..2fd719366 100644
--- a/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts
@@ -47,6 +47,20 @@ import { ILanguageFeaturesService } from '../editor/common/services/languageFeat
import { SymbolNavigationAnchor } from '../editor/contrib/gotoSymbol/browser/goToCommands.js';
import { CommandsRegistry, ICommandService } from '../platform/commands/common/commands.js';
import { IEditorService } from '../workbench/services/editor/common/editorService.js';
+import { NativeExtensionService } from '../workbench/services/extensions/electron-browser/nativeExtensionService.js';
+import { IExtensionService } from '../workbench/services/extensions/common/extensions.js';
+import { IRemoteAuthorityResolverService, type ResolverResult } from '../platform/remote/common/remoteAuthorityResolver.js';
+import { IMainProcessService } from '../platform/ipc/common/mainProcessService.js';
+import { REVIEW_DESKTOP_CHANNEL } from './common/reviewDesktopBootstrap.js';
+import { isReviewRemoteAuthority, reviewRemoteServerId } from './services/remote/reviewRemoteAuthority.js';
+import { reloadWhenOnline, reviewWindowAuthorityResolver, reviewWindowHosts } from './services/remote/reviewWindowAuthorityResolver.js';
+import { ReviewSourceWindowHostState } from './services/remote/reviewSourceWindowHostState.js';
+import { IHostService } from '../workbench/services/host/browser/host.js';
+import { FilesConfigurationService, IFilesConfigurationService } from '../workbench/services/filesConfiguration/common/filesConfigurationService.js';
+import type { IBaseFileStat } from '../platform/files/common/files.js';
+import { isEqual } from '../base/common/resources.js';
+import type { IMarkdownString } from '../base/common/htmlContent.js';
+import type { IWorkspaceContextService } from '../platform/workspace/common/workspace.js';
class NavigatorDefaults {
constructor(@IStorageService storage: IStorageService) {
@@ -59,6 +73,46 @@ class NavigatorDefaults {
}
registerWorkbenchContribution2('review.navigator.defaults', NavigatorDefaults, WorkbenchPhase.BlockStartup);
+registerWorkbenchContribution2(ReviewSourceWindowHostState.ID, ReviewSourceWindowHostState, WorkbenchPhase.BlockRestore);
+
+class NavigatorExtensionService extends NativeExtensionService {
+ private readonly windowHosts = this._instantiationService.invokeFunction((accessor) => reviewWindowHosts(accessor.get(IMainProcessService).getChannel(REVIEW_DESKTOP_CHANNEL)));
+ protected override readonly _remoteAuthorityResolverService = this._instantiationService.invokeFunction((accessor) =>
+ reviewWindowAuthorityResolver(accessor.get(IRemoteAuthorityResolverService), this.windowHosts));
+ private retrying = false;
+
+ protected override async _resolveAuthority(remoteAuthority: string): Promise {
+ if (!isReviewRemoteAuthority(remoteAuthority)) return super._resolveAuthority(remoteAuthority);
+ try {
+ return await this._remoteAuthorityResolverService.resolveAuthority(remoteAuthority);
+ } catch (error) {
+ if (!this.retrying) {
+ this.retrying = true;
+ const host = this._instantiationService.invokeFunction((accessor) => accessor.get(IHostService));
+ this._register(reloadWhenOnline(this.windowHosts, reviewRemoteServerId(remoteAuthority), () => void host.reload()));
+ }
+ throw error;
+ }
+ }
+}
+
+registerSingleton(IExtensionService, NavigatorExtensionService, InstantiationType.Eager);
+
+/** Every file in a Source window's folders is read-only, whatever a folder's settings say. */
+class NavigatorFilesConfigurationService extends FilesConfigurationService {
+ private readonly workspace: IWorkspaceContextService;
+
+ constructor(...args: ConstructorParameters) {
+ super(...args);
+ this.workspace = args[2];
+ }
+
+ override isReadonly(resource: URI, stat?: IBaseFileStat): boolean | IMarkdownString {
+ return super.isReadonly(resource, stat) || (!!this.workspace.getWorkspaceFolder(resource) && !isEqual(resource, this.workspace.getWorkspace().configuration ?? undefined));
+ }
+}
+
+registerSingleton(IFilesConfigurationService, NavigatorFilesConfigurationService, InstantiationType.Eager);
CommandsRegistry.registerCommand('review.action.showReferencesInSource', async (accessor, resource: string, lineNumber: number, column: number) => {
const editorService = accessor.get(IEditorService);
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts
index ca32e269b..c2223b36f 100644
--- a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts
@@ -6,14 +6,26 @@
import { Schemas } from "../../../base/common/network.js";
import type { URI } from "../../../base/common/uri.js";
+const PREFIX = "whiteboard+";
+
export function reviewRemoteAuthority(serverId: string): string | undefined {
- return /^[0-9a-z-]+$/i.test(serverId) ? `whiteboard+${serverId.toLowerCase()}` : undefined;
+ return /^[0-9a-z-]+$/i.test(serverId) ? `${PREFIX}${serverId.toLowerCase()}` : undefined;
+}
+
+/** The server id in a `reviewRemoteAuthority`. */
+export function reviewRemoteServerId(authority: string): string {
+ return authority.slice(PREFIX.length);
}
export function ownsRemoteResource(authority: string, resource: URI): boolean {
return resource.scheme === Schemas.vscodeRemote && resource.authority.toLowerCase() === authority;
}
+/** Exactly the lower-case `whiteboard+` that `reviewRemoteAuthority` makes. */
+export function isReviewRemoteAuthority(authority: string | null | undefined): authority is string {
+ return !!authority && /^whiteboard\+[0-9a-z-]+$/.test(authority);
+}
+
export function override(base: T, members: Partial): T {
return new Proxy(base, {
get(target, key) {
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.test.ts
new file mode 100644
index 000000000..898f94f00
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.test.ts
@@ -0,0 +1,159 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Emitter, Event } from "../../../base/common/event.js";
+import { CommandsRegistry } from "../../../platform/commands/common/commands.js";
+import type { IMainProcessService } from "../../../platform/ipc/common/mainProcessService.js";
+import type { INotification, INotificationActions, INotificationService } from "../../../platform/notification/common/notification.js";
+import { PersistentConnectionEventType, type PersistentConnectionEvent } from "../../../platform/remote/common/remoteAgentConnection.js";
+import type { IStorageService } from "../../../platform/storage/common/storage.js";
+import type { IWorkbenchEnvironmentService } from "../../../workbench/services/environment/common/environmentService.js";
+import type { IRemoteAgentConnection, IRemoteAgentService } from "../../../workbench/services/remote/common/remoteAgentService.js";
+import type { IStatusbarEntry, IStatusbarService } from "../../../workbench/services/statusbar/browser/statusbar.js";
+import { RETRY_HOST_COMMAND, ReviewSourceWindowHostState, showSourceWindowHostState } from "./reviewSourceWindowHostState.js";
+import type { ReviewWindowHosts } from "./reviewWindowAuthorityResolver.js";
+
+const OFFLINE = "devbox offline";
+
+function fakes() {
+ const entries: IStatusbarEntry[] = [];
+ const shown: INotification[] = [];
+ const retried: string[] = [];
+ let closed = 0;
+ const statusbar = {
+ addEntry: (entry: IStatusbarEntry) => {
+ entries.push(entry);
+ return { update: (next: IStatusbarEntry) => void entries.push(next), dispose: () => {} };
+ },
+ } as unknown as IStatusbarService;
+ const notifications = {
+ notify: (notification: INotification) => {
+ const shownAt = shown.push({ ...notification }) - 1;
+ return {
+ onDidClose: Event.None,
+ close: () => void closed++,
+ updateMessage: (message: string) => void (shown[shownAt] = { ...shown[shownAt], message }),
+ updateActions: (actions: INotificationActions) => void (shown[shownAt] = { ...shown[shownAt], actions }),
+ };
+ },
+ } as unknown as INotificationService;
+ let state: { state: string; detail?: string } = { state: "online" };
+ let polls = 0;
+ const hosts = { endpoint: async () => undefined, state: async () => (polls++, { alias: "devbox", ...state }) } as unknown as ReviewWindowHosts;
+ const connection = new Emitter();
+ return {
+ statusbar,
+ notifications,
+ hosts,
+ connection: { onDidStateChange: connection.event } as unknown as IRemoteAgentConnection,
+ retry: (alias: string) => void retried.push(alias),
+ fire: (type: PersistentConnectionEventType) => connection.fire({ type } as PersistentConnectionEvent),
+ set: (next: string, detail?: string) => void (state = { state: next, detail }),
+ polls: () => polls,
+ last: () => entries.at(-1),
+ shown,
+ retried,
+ closed: () => closed,
+ };
+}
+
+async function until(check: () => boolean) {
+ while (!check()) await new Promise((resolve) => setTimeout(resolve, 1));
+}
+const labels = (notification: INotification | undefined) => notification?.actions?.primary?.map((action) => action.label);
+
+test("shows the alias while online, the offline state within one poll, and restores it", async () => {
+ const f = fakes();
+ const watch = showSourceWindowHostState("abc-1", f.hosts, f.statusbar, f.notifications, f.connection, f.retry, undefined, 5);
+ try {
+ await until(() => f.last()?.text === "devbox");
+ assert.notEqual(f.last()?.kind, "warning");
+ assert.equal(f.last()?.command, undefined);
+ assert.equal(f.shown.length, 0);
+
+ f.set("offline");
+ await until(() => f.last()?.text === OFFLINE);
+ assert.equal(f.last()?.kind, "warning");
+ assert.equal(f.last()?.command, RETRY_HOST_COMMAND);
+ assert.deepEqual(f.shown.map((n) => [n.message, labels(n)]), [["devbox offline.", ["Retry"]]]);
+
+ f.fire(PersistentConnectionEventType.ConnectionLost);
+ f.set("online");
+ const polls = f.polls();
+ await until(() => f.polls() >= polls + 2);
+ assert.equal(f.last()?.text, OFFLINE, "the window's own connection is still down");
+ assert.equal(f.closed(), 0);
+
+ f.fire(PersistentConnectionEventType.ConnectionGain);
+ assert.equal(f.last()?.text, "devbox");
+ assert.notEqual(f.last()?.kind, "warning");
+ assert.equal(f.closed(), 1);
+
+ f.set("unreachable", "ssh: connect to host devbox port 22: Connection refused");
+ await until(() => f.shown.length === 2);
+ assert.equal(f.last()?.text, OFFLINE);
+ assert.deepEqual(f.shown.map((n) => n.message), ["devbox offline.", "ssh: connect to host devbox port 22: Connection refused"], "a later outage notifies again");
+ } finally {
+ watch.dispose();
+ }
+});
+
+test("Retry in the notice and the status bar retries the host", async () => {
+ const f = fakes();
+ f.set("offline");
+ const watch = showSourceWindowHostState("abc-1", f.hosts, f.statusbar, f.notifications, f.connection, f.retry, undefined, 5);
+ try {
+ await until(() => f.shown.length === 1);
+ await f.shown[0]?.actions?.primary?.[0]?.run();
+ await CommandsRegistry.getCommand(RETRY_HOST_COMMAND)?.handler(undefined as never);
+ assert.deepEqual(f.retried, ["devbox", "devbox"]);
+ } finally {
+ watch.dispose();
+ }
+});
+
+test("a host that refuses its sign-in is named so, with no Retry, and the open notice follows the state", async () => {
+ const f = fakes();
+ f.set("offline");
+ const watch = showSourceWindowHostState("abc-1", f.hosts, f.statusbar, f.notifications, f.connection, f.retry, undefined, 5);
+ try {
+ await until(() => f.last()?.text === OFFLINE);
+ f.set("auth-failed", "Permission denied (publickey).");
+ await until(() => f.last()?.text === "Can't sign in to devbox");
+ assert.equal(f.last()?.command, undefined);
+ assert.deepEqual(f.shown.map((n) => [n.message, labels(n)]), [["Permission denied (publickey).", []]]);
+ } finally {
+ watch.dispose();
+ }
+});
+
+test("a window restored before main knows its host's alias names it by the alias it stored when it opened", async () => {
+ const stored = new Map();
+ const storage = { get: (key: string) => stored.get(key), store: (key: string, value: string) => stored.set(key, value) } as unknown as IStorageService;
+ const open = (state: object) => {
+ const f = fakes();
+ const contribution = new ReviewSourceWindowHostState(
+ { remoteAuthority: "whiteboard+3f454168-aaaa" } as IWorkbenchEnvironmentService,
+ { getChannel: () => ({ call: async (command: string) => (command === "getRemoteHostState" ? state : undefined) }) } as unknown as IMainProcessService,
+ f.statusbar,
+ f.notifications,
+ { getConnection: () => null } as unknown as IRemoteAgentService,
+ storage,
+ );
+ return { f, contribution };
+ };
+ const first = open({ alias: "wb-test-a", state: "online" });
+ await until(() => first.f.last()?.text === "wb-test-a");
+ first.contribution.dispose();
+ const { f, contribution } = open({ state: "offline" });
+ try {
+ await until(() => f.last()?.text === "wb-test-a offline");
+ assert.equal(f.shown[0]?.message, "wb-test-a offline.");
+ } finally {
+ contribution.dispose();
+ }
+});
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.ts
new file mode 100644
index 000000000..7c3ba0603
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.ts
@@ -0,0 +1,134 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { toAction } from "../../../base/common/actions.js";
+import { Event } from "../../../base/common/event.js";
+import { Disposable, DisposableStore, toDisposable, type IDisposable } from "../../../base/common/lifecycle.js";
+import Severity from "../../../base/common/severity.js";
+import { CommandsRegistry } from "../../../platform/commands/common/commands.js";
+import { IMainProcessService } from "../../../platform/ipc/common/mainProcessService.js";
+import { INotificationService, type INotificationHandle } from "../../../platform/notification/common/notification.js";
+import { PersistentConnectionEventType } from "../../../platform/remote/common/remoteAgentConnection.js";
+import type { IWorkbenchContribution } from "../../../workbench/common/contributions.js";
+import { IWorkbenchEnvironmentService } from "../../../workbench/services/environment/common/environmentService.js";
+import { IRemoteAgentService, type IRemoteAgentConnection } from "../../../workbench/services/remote/common/remoteAgentService.js";
+import { IStatusbarService, StatusbarAlignment, type IStatusbarEntry, type IStatusbarEntryAccessor } from "../../../workbench/services/statusbar/browser/statusbar.js";
+import { IStorageService, StorageScope, StorageTarget } from "../../../platform/storage/common/storage.js";
+import { reviewHostStatus, type ReviewGatewayHostState } from "../../common/reviewProtocol.js";
+import { REVIEW_DESKTOP_CHANNEL } from "../../common/reviewDesktopBootstrap.js";
+import { isReviewRemoteAuthority, reviewRemoteServerId } from "./reviewRemoteAuthority.js";
+import { reviewWindowHosts, type ReviewWindowHosts } from "./reviewWindowAuthorityResolver.js";
+
+export const HOST_STATE_POLL_MS = 2_000;
+
+export const RETRY_HOST_COMMAND = "review.sourceWindow.retryHost";
+
+const HOST_ALIAS_KEY = "review.sourceWindow.hostAlias";
+
+/** Shows a Source window's host as main sees it, with Retry while it is offline. */
+export function showSourceWindowHostState(
+ serverId: string,
+ hosts: ReviewWindowHosts,
+ statusbar: IStatusbarService,
+ notifications: INotificationService,
+ connection: Pick | null,
+ retry: (alias: string) => void,
+ stored?: { get(): string | undefined; set(alias: string): void },
+ every = HOST_STATE_POLL_MS,
+): IDisposable {
+ const store = new DisposableStore();
+ let mainAlias: string | undefined;
+ let host: Pick | undefined;
+ let lost = false;
+ let notified = false;
+ let entry: IStatusbarEntryAccessor | undefined;
+ let notice: INotificationHandle | undefined;
+ const alias = () => mainAlias ?? stored?.get() ?? serverId.slice(0, 8);
+ store.add(CommandsRegistry.registerCommand(RETRY_HOST_COMMAND, () => retry(alias())));
+ const retryAction = toAction({ id: RETRY_HOST_COMMAND, label: "Retry", run: () => retry(alias()) });
+ const render = () => {
+ if (!host) return;
+ const state = lost && host.state === "online" ? "offline" : host.state;
+ const status = reviewHostStatus({ alias: alias(), state, detail: host.detail });
+ const offline = state !== "online";
+ const canRetry = status.action === "retry";
+ const props: IStatusbarEntry = {
+ name: "Remote Host",
+ text: status.label,
+ ariaLabel: status.label,
+ tooltip: offline ? status.sentence : undefined,
+ kind: offline ? "warning" : undefined,
+ command: canRetry ? RETRY_HOST_COMMAND : undefined,
+ };
+ if (entry) entry.update(props);
+ else entry = store.add(statusbar.addEntry(props, "review.sourceWindow.host", StatusbarAlignment.LEFT, Number.MAX_VALUE));
+ if (!offline) {
+ notified = false;
+ notice?.close();
+ notice = undefined;
+ return;
+ }
+ const actions = { primary: canRetry ? [retryAction] : [] };
+ if (notice) {
+ notice.updateMessage(status.sentence);
+ notice.updateActions(actions);
+ } else if (!notified) {
+ notified = true;
+ const shown = notifications.notify({ severity: Severity.Warning, message: status.sentence, actions });
+ notice = shown;
+ Event.once(shown.onDidClose)(() => {
+ if (notice === shown) notice = undefined;
+ });
+ }
+ };
+ if (connection) {
+ store.add(connection.onDidStateChange(({ type }) => {
+ lost = type !== PersistentConnectionEventType.ConnectionGain;
+ render();
+ }));
+ }
+ let stopped = false;
+ let handle: ReturnType | undefined;
+ const tick = async () => {
+ const state = await hosts.state(serverId, mainAlias ?? stored?.get()).catch(() => undefined);
+ if (stopped) return;
+ if (state) {
+ if (state.alias && state.alias !== mainAlias) stored?.set(state.alias);
+ mainAlias = state.alias ?? mainAlias;
+ host = state;
+ render();
+ }
+ handle = setTimeout(() => void tick(), every);
+ };
+ void tick();
+ store.add(toDisposable(() => {
+ stopped = true;
+ clearTimeout(handle);
+ }));
+ return store;
+}
+
+export class ReviewSourceWindowHostState extends Disposable implements IWorkbenchContribution {
+ static readonly ID = "review.sourceWindow.hostState";
+
+ constructor(
+ @IWorkbenchEnvironmentService environment: IWorkbenchEnvironmentService,
+ @IMainProcessService mainProcess: IMainProcessService,
+ @IStatusbarService statusbar: IStatusbarService,
+ @INotificationService notifications: INotificationService,
+ @IRemoteAgentService remoteAgent: IRemoteAgentService,
+ @IStorageService storage: IStorageService,
+ ) {
+ super();
+ const authority = environment.remoteAuthority;
+ if (!authority || !isReviewRemoteAuthority(authority)) return;
+ const channel = mainProcess.getChannel(REVIEW_DESKTOP_CHANNEL);
+ const retry = (alias: string) => void channel.call("retryRemoteHost", alias).catch(() => undefined);
+ this._register(showSourceWindowHostState(reviewRemoteServerId(authority), reviewWindowHosts(channel), statusbar, notifications, remoteAgent.getConnection(), retry, {
+ get: () => storage.get(HOST_ALIAS_KEY, StorageScope.WORKSPACE),
+ set: (alias) => storage.store(HOST_ALIAS_KEY, alias, StorageScope.WORKSPACE, StorageTarget.MACHINE),
+ }));
+ }
+}
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts
new file mode 100644
index 000000000..342fb84a4
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts
@@ -0,0 +1,165 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import { URI } from "../../../base/common/uri.js";
+import type { IChannel } from "../../../base/parts/ipc/common/ipc.js";
+import {
+ type IRemoteAuthorityResolverService,
+ RemoteAuthorityResolverError,
+ RemoteAuthorityResolverErrorCode,
+ RemoteConnectionType,
+ type ResolverResult,
+} from "../../../platform/remote/common/remoteAuthorityResolver.js";
+import type { ReviewGatewayHostState } from "../../common/reviewProtocol.js";
+import { remoteHostState } from "../../electron-main/remote/reviewRemoteHosts.js";
+import { reloadWhenOnline, reviewWindowAuthorityResolver, reviewWindowHosts, type ReviewWindowHosts } from "./reviewWindowAuthorityResolver.js";
+
+const AUTHORITY = "whiteboard+abc-1";
+
+/** Main's answers, in order; the last one repeats. */
+function fakeChannel(answers: { endpoint?: { host: string; port: number; connectionToken: string }; state?: { alias: string; state: string } }[]) {
+ const calls: [string, unknown][] = [];
+ let index = 0;
+ const channel = {
+ call: async (command: string, arg: unknown) => {
+ calls.push([command, arg]);
+ const answer = answers[Math.min(index, answers.length - 1)];
+ if (command === "getRemoteLanguageEndpoint") return answer.endpoint;
+ index++;
+ return answer.state;
+ },
+ listen: () => {
+ throw new Error("not used");
+ },
+ } as unknown as IChannel;
+ return { channel, calls };
+}
+
+/**
+ * Main as it behaves: each phase is what the gateway lists and which alias in
+ * the setting served the machine; the state goes through main's own lookup.
+ */
+function fakeMain(phases: { endpoint?: number; states: ReviewGatewayHostState[]; configured?: string }[]) {
+ let index = 0;
+ const phase = () => phases[Math.min(index, phases.length - 1)];
+ const hosts: ReviewWindowHosts = {
+ endpoint: async () => {
+ const port = phase().endpoint;
+ return port === undefined ? undefined : endpoint(port);
+ },
+ state: async (serverId) => {
+ const { states, configured } = phase();
+ index++;
+ return remoteHostState(serverId, states, configured);
+ },
+ };
+ return { hosts, next: (...more: typeof phases) => phases.splice(0, phases.length, ...more) };
+}
+
+function fakeBase() {
+ let provider: ((uri: URI) => Promise) | undefined;
+ const base = {
+ resolveAuthority: async (authority: string): Promise => ({
+ authority: { authority, connectTo: { type: RemoteConnectionType.WebSocket, host: "base", port: 1 }, connectionToken: undefined },
+ }),
+ _setCanonicalURIProvider: (next: (uri: URI) => Promise) => (provider = next),
+ getCanonicalURI: (uri: URI) => provider!(uri),
+ } as unknown as IRemoteAuthorityResolverService;
+ return base;
+}
+
+const endpoint = (port: number) => ({ host: "127.0.0.1", port, connectionToken: "vscode-token" });
+const connectTo = (result: ResolverResult) => {
+ const { connectTo, connectionToken } = result.authority;
+ assert.equal(connectTo.type, RemoteConnectionType.WebSocket);
+ return { host: connectTo.host, port: connectTo.port, connectionToken };
+};
+
+test("a whiteboard+ authority resolves to its host's forward, afresh each time", async () => {
+ const { channel, calls } = fakeChannel([{ endpoint: endpoint(41000) }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel));
+
+ assert.deepEqual(connectTo(await resolver.resolveAuthority(AUTHORITY)), { host: "127.0.0.1", port: 41000, connectionToken: "vscode-token" });
+ assert.deepEqual(calls, [["getRemoteLanguageEndpoint", "abc-1"]]);
+});
+
+test("a resolved authority waits through offline and unreachable for the moved forward", async () => {
+ const online = { alias: "devbox", serverId: "abc-1", state: "online" as const };
+ const { hosts, next } = fakeMain([{ endpoint: 41000, states: [online], configured: "devbox" }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), hosts, { wait: 20, poll: 5 });
+ assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 41000);
+
+ // The gateway keeps the id while offline and drops it with Desktop's problem report; each phase outlasts the wait.
+ const offline = Array.from({ length: 6 }, () => ({ states: [{ ...online, state: "offline" as const }], configured: "devbox" }));
+ const unreachable = Array.from({ length: 6 }, () => ({ states: [{ alias: "devbox", state: "unreachable" as const }], configured: "devbox" }));
+ next(...offline, ...unreachable, { endpoint: 42000, states: [online], configured: "devbox" });
+ assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 42000);
+});
+
+test("a resolved authority whose machine is gone after the wait fails; one still known keeps waiting", async () => {
+ const answers: Parameters[0] = [{ endpoint: endpoint(41000) }];
+ const { channel } = fakeChannel(answers);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait: 20, poll: 5 });
+ await resolver.resolveAuthority(AUTHORITY);
+
+ answers.splice(0, 1, {});
+ await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => error._code === RemoteAuthorityResolverErrorCode.NotAvailable);
+
+ answers.splice(0, 1, ...Array.from({ length: 10 }, () => ({ state: { alias: "devbox", state: "offline" } })), { endpoint: endpoint(43000) });
+ assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 43000);
+});
+
+test("a connecting host is waited for, and resolves once its forward appears", async () => {
+ const connecting = { state: { alias: "devbox", state: "connecting" } };
+ const { channel } = fakeChannel([connecting, connecting, { endpoint: endpoint(43000) }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait: 1_000, poll: 1 });
+
+ assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 43000);
+});
+
+test("a host still connecting after the wait, or not connecting at all, is offline", async () => {
+ for (const [state, wait] of [["connecting", 20], ["offline", 60_000]] as const) {
+ const { channel, calls } = fakeChannel([{ state: { alias: "devbox", state } }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait, poll: 5 });
+
+ await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => {
+ assert.ok(error instanceof RemoteAuthorityResolverError);
+ assert.equal(error._code, RemoteAuthorityResolverErrorCode.NotAvailable);
+ assert.equal(error._detail, "devbox is offline");
+ return true;
+ });
+ if (state === "offline") assert.equal(calls.length, 2);
+ }
+});
+
+test("canonical URIs of whiteboard+ are themselves; every other authority is the window's", async () => {
+ const { channel, calls } = fakeChannel([{ endpoint: endpoint(41000) }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel));
+ resolver._setCanonicalURIProvider(async () => {
+ throw new Error("no resolver extension");
+ });
+
+ const own = URI.parse(`vscode-remote://${AUTHORITY}/home/dev/repo/f.ts`);
+ assert.equal(await resolver.getCanonicalURI(own), own);
+ await assert.rejects(resolver.getCanonicalURI(URI.parse("vscode-remote://ssh-remote+x/f.ts")), /no resolver extension/);
+
+ for (const other of ["ssh-remote+x", "Whiteboard+ABC", "whiteboard+a/b"]) {
+ assert.equal(connectTo(await resolver.resolveAuthority(other)).host, "base");
+ }
+ assert.deepEqual(calls, []);
+});
+
+test("a window that could not reach its host reloads when the host's forward answers", async () => {
+ let polls = 0;
+ const hosts: ReviewWindowHosts = { endpoint: async () => (++polls >= 3 ? endpoint(45000) : undefined), state: async () => undefined };
+ const reloaded = Promise.withResolvers();
+ const watch = reloadWhenOnline(hosts, "abc-1", reloaded.resolve, 1);
+
+ await reloaded.promise;
+ watch.dispose();
+ assert.equal(polls, 3);
+});
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts
new file mode 100644
index 000000000..e84366b9d
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts
@@ -0,0 +1,86 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { timeout } from "../../../base/common/async.js";
+import { toDisposable, type IDisposable } from "../../../base/common/lifecycle.js";
+import type { IChannel } from "../../../base/parts/ipc/common/ipc.js";
+import {
+ type IRemoteAuthorityResolverService,
+ RemoteAuthorityResolverError,
+ RemoteAuthorityResolverErrorCode,
+ type ResolverResult,
+ WebSocketRemoteConnection,
+} from "../../../platform/remote/common/remoteAuthorityResolver.js";
+import type { ReviewGatewayHostState } from "../../common/reviewProtocol.js";
+import type { ReviewRemoteLanguageEndpoint } from "../reviewDesktopConnectionService.js";
+import { isReviewRemoteAuthority, override, reviewRemoteServerId } from "./reviewRemoteAuthority.js";
+
+/** How long a Source window waits for its host to finish connecting. */
+export const REMOTE_WINDOW_RESOLVE_WAIT_MS = 60_000;
+
+export interface ReviewWindowHosts {
+ endpoint(serverId: string): Promise;
+ state(serverId: string, last?: string): Promise<({ alias?: string } & Pick) | undefined>;
+}
+
+/** Reloads a window that could not reach its host, once the host's forward answers. */
+export function reloadWhenOnline(hosts: ReviewWindowHosts, serverId: string, reload: () => void, every = 5_000): IDisposable {
+ let done = false;
+ const check = async () => {
+ const endpoint = await hosts.endpoint(serverId).catch(() => undefined);
+ if (!endpoint || done) return;
+ done = true;
+ clearInterval(handle);
+ reload();
+ };
+ const handle = setInterval(() => void check(), every);
+ return toDisposable(() => {
+ done = true;
+ clearInterval(handle);
+ });
+}
+
+export function reviewWindowHosts(channel: IChannel): ReviewWindowHosts {
+ return {
+ endpoint: (serverId) => channel.call("getRemoteLanguageEndpoint", serverId),
+ state: (serverId, last) => channel.call("getRemoteHostState", last === undefined ? serverId : [serverId, last]),
+ };
+}
+
+/** Resolves `whiteboard+` to the host's current forward; other authorities go to `base`. */
+export function reviewWindowAuthorityResolver(
+ base: IRemoteAuthorityResolverService,
+ hosts: ReviewWindowHosts,
+ { wait = REMOTE_WINDOW_RESOLVE_WAIT_MS, poll = 1_000 } = {},
+): IRemoteAuthorityResolverService {
+ const resolved = new Set();
+ const resolve = async (authority: string): Promise => {
+ const serverId = reviewRemoteServerId(authority);
+ const again = resolved.has(authority);
+ const deadline = Date.now() + wait;
+ for (;;) {
+ const endpoint = await hosts.endpoint(serverId);
+ if (endpoint) {
+ resolved.add(authority);
+ const { host, port, connectionToken } = endpoint;
+ return { authority: { authority, connectTo: new WebSocketRemoteConnection(host, port), connectionToken }, options: {} };
+ }
+ const state = await hosts.state(serverId);
+ const left = deadline - Date.now();
+ // A moved forward passes through offline or unreachable; only a machine gone from the setting ends a reconnect.
+ const waiting = again ? state !== undefined || left > 0 : (state === undefined || state.state === "connecting") && left > 0;
+ if (!waiting) {
+ const offline = `${state?.alias ?? serverId.slice(0, 8)} is offline`;
+ throw new RemoteAuthorityResolverError(offline, RemoteAuthorityResolverErrorCode.NotAvailable, offline);
+ }
+ await timeout(left > 0 ? Math.min(poll, left) : poll);
+ }
+ };
+ return override(base, {
+ resolveAuthority: (authority) => (isReviewRemoteAuthority(authority) ? resolve(authority) : base.resolveAuthority(authority)),
+ _setCanonicalURIProvider: (provider) =>
+ base._setCanonicalURIProvider((uri) => (isReviewRemoteAuthority(uri.authority) ? Promise.resolve(uri) : provider(uri))),
+ });
+}
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.test.ts
index 3476334a2..4f62fd4d7 100644
--- a/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.test.ts
@@ -4,10 +4,14 @@
*--------------------------------------------------------------------------------------------*/
import assert from "node:assert/strict";
-import test from "node:test";
+import test, { type TestContext } from "node:test";
+import type { VSBuffer } from "../../base/common/buffer.js";
import { Event } from "../../base/common/event.js";
+import { URI } from "../../base/common/uri.js";
import { ReviewCanvasEditorInput } from "../browser/parts/canvas/reviewCanvasEditorInput.js";
+import { ReviewHostDown } from "../browser/parts/canvas/reviewHostFailure.js";
+import { apiSourceUri } from "../common/reviewSourceView.js";
import { ReviewCanvasEditorTabsService } from "./reviewCanvasEditorTabsService.js";
async function closeWelcome(updateNeeded: boolean): Promise {
@@ -34,6 +38,8 @@ async function closeWelcome(updateNeeded: boolean): Promise {
connection as never,
{} as never,
{ warn() {} } as never,
+ {} as never,
+ {} as never,
);
try {
const welcome = await tabs.openWelcome(true);
@@ -49,3 +55,68 @@ test("closing Welcome finishes the CLI install update only when one is pending",
assert.equal(await closeWelcome(true), 1);
assert.equal(await closeWelcome(false), 0);
});
+
+const HOST = "whiteboard+c0ffee";
+const remote = (path: string) => URI.from({ scheme: "vscode-remote", authority: HOST, path }).toString();
+const view = (generation?: string) => ({ reviewId: "r1", version: 2, generation });
+
+function sourceTabs(t: TestContext, answer: (url: URL) => object) {
+ const opened: { toOpen: { workspaceUri?: URI; fileUri?: URI; label?: string }[]; options: Record }[] = [];
+ const written: string[] = [];
+ t.mock.method(globalThis, "fetch", async (url: string) => Response.json(answer(new URL(url))));
+ const tabs = new ReviewCanvasEditorTabsService(
+ { createInstance: (_ctor: unknown, target: never) => new ReviewCanvasEditorInput(target, {} as never) } as never,
+ { onDidCloseEditor: Event.None } as never,
+ {} as never,
+ { async getConnection() { return { serverUrl: "http://localhost", token: "test" }; }, async readRemoteHosts() { return []; } } as never,
+ { async openWindow(toOpen: never, options: never) { opened.push({ toOpen, options }); } } as never,
+ { warn() {} } as never,
+ { async writeFile(resource: URI, content: VSBuffer) { written.push(`${resource.toString()}=${content.toString()}`); } } as never,
+ { cacheHome: URI.file("/laptop/cache") } as never,
+ );
+ t.after(() => tabs.dispose());
+ return { tabs, opened, written };
+}
+
+const remoteAnswer = {
+ workspacePath: "/home/dev/navigator/repo.code-workspace",
+ filePath: "/home/dev/repo/src/a b.ts",
+ serverId: "C0FFEE",
+};
+
+test("a remote answer opens the host's workspace and file at the position, titled with the review", async (t) => {
+ const { tabs, opened } = sourceTabs(t, () => remoteAnswer);
+ tabs.inputFor({ kind: "api", reviewId: "r1", title: "Fix the parser" });
+ const resource = apiSourceUri({ view: view(), side: "head", file: "src/a b.ts" });
+ assert.equal(await tabs.openSourceEditor({ resource, options: { selection: { startLineNumber: 3, startColumn: 2 } } }), true);
+ const [{ toOpen, options }] = opened;
+ assert.deepEqual(toOpen.map(item => (item.workspaceUri ?? item.fileUri)!.toString()), [remote("/home/dev/navigator/repo.code-workspace"), remote("/home/dev/repo/src/a b.ts:3:2")]);
+ assert.equal(options.remoteAuthority, HOST);
+});
+
+test("a remote diff opens its empty side from a laptop empty file", async (t) => {
+ const { tabs, opened, written } = sourceTabs(t, url => url.searchParams.has("empty")
+ ? { workspacePath: remoteAnswer.workspacePath, serverId: remoteAnswer.serverId, emptySide: true }
+ : remoteAnswer);
+ await tabs.openSourceEditor({
+ original: { resource: apiSourceUri({ view: view("wt1"), side: "base", file: "src/a b.ts" }, true) },
+ modified: { resource: apiSourceUri({ view: view("wt1"), side: "head", file: "src/a b.ts" }) },
+ });
+ const empty = URI.file("/laptop/cache/source-empty/empty");
+ assert.deepEqual(written, [`${empty.toString()}=`]);
+ const [{ toOpen, options }] = opened;
+ assert.deepEqual(toOpen.map(item => (item.workspaceUri ?? item.fileUri)!.toString()), [
+ remote("/home/dev/navigator/repo.code-workspace"),
+ empty.with({ scheme: "vscode-userdata" }).toString(),
+ remote("/home/dev/repo/src/a b.ts"),
+ ]);
+ assert.equal(options.remoteAuthority, HOST);
+ assert.equal(options.diffMode, true);
+});
+
+test("a gateway 503 naming a host is that host's error", async (t) => {
+ const { tabs } = sourceTabs(t, () => remoteAnswer);
+ t.mock.method(globalThis, "fetch", async () => Response.json({ ok: false, error: "gateway text" }, { status: 503, headers: { "x-review-host": "devbox" } }));
+ await assert.rejects(tabs.openApiSource({ reviewId: "r1", kind: "current" }, "Fix the parser"), (error: unknown) =>
+ error instanceof ReviewHostDown && error.host.alias === "devbox" && error.host.state === "offline");
+});
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.ts
index 9a4d54687..5e5865bbc 100644
--- a/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.ts
@@ -3,10 +3,15 @@
* Licensed under the MIT License. See LICENSE in the repository root for license information.
*--------------------------------------------------------------------------------------------*/
+import { VSBuffer } from "../../base/common/buffer.js";
import { Event } from "../../base/common/event.js";
+import { Schemas } from "../../base/common/network.js";
import { Disposable } from "../../base/common/lifecycle.js";
+import { joinPath } from "../../base/common/resources.js";
import { URI } from "../../base/common/uri.js";
import type { ITextEditorOptions } from "../../platform/editor/common/editor.js";
+import { IEnvironmentService } from "../../platform/environment/common/environment.js";
+import { IFileService } from "../../platform/files/common/files.js";
import { createDecorator, IInstantiationService } from "../../platform/instantiation/common/instantiation.js";
import { ILogService } from "../../platform/log/common/log.js";
import type { EditorInput } from "../../workbench/common/editor/editorInput.js";
@@ -18,12 +23,23 @@ import {
ReviewCanvasEditorInput,
type ReviewCanvasEditorTarget,
} from "../browser/parts/canvas/reviewCanvasEditorInput.js";
+import { ReviewHostDown } from "../browser/parts/canvas/reviewHostFailure.js";
-import { reviewSourceQuery, type ReviewSourceSelection } from "../common/reviewProtocol.js";
+import { reviewSourceQuery, type ReviewRemoteNavigatorAnswer, type ReviewSourceSelection } from "../common/reviewProtocol.js";
import { REVIEW_LANGUAGE_SOURCE_SCHEME } from "../common/reviewReadonlySource.js";
import { sourceLocation, sourceSelectionIdentity, REVIEW_API_SOURCE_SCHEME } from "../common/reviewSourceView.js";
+import { reviewRemoteAuthority } from "./remote/reviewRemoteAuthority.js";
import { IReviewDesktopConnectionService, reviewResponseError } from "./reviewDesktopConnectionService.js";
+type NavigatorAnswer = { workspacePath: string; filePath?: string } | ReviewRemoteNavigatorAnswer;
+
+function navigatorPaths(answer: NavigatorAnswer): { uri: (path: string) => URI; remote: { remoteAuthority?: string } } {
+ if (!("serverId" in answer)) return { uri: URI.file, remote: {} };
+ const authority = reviewRemoteAuthority(answer.serverId);
+ if (!authority) throw new Error("The remote host's server id cannot be opened.");
+ return { uri: (path) => URI.from({ scheme: Schemas.vscodeRemote, authority, path }), remote: { remoteAuthority: authority } };
+}
+
export const IReviewCanvasEditorTabsService = createDecorator(
"reviewCanvasEditorTabsService",
);
@@ -59,6 +75,8 @@ export class ReviewCanvasEditorTabsService extends Disposable implements IReview
private readonly desktopConnection: IReviewDesktopConnectionService,
@IHostService private readonly host: IHostService,
@ILogService private readonly logService: ILogService,
+ @IFileService private readonly files: IFileService,
+ @IEnvironmentService private readonly environment: IEnvironmentService,
) {
super();
this._register(
@@ -109,7 +127,8 @@ export class ReviewCanvasEditorTabsService extends Disposable implements IReview
async openApiSource(selection: ReviewSourceSelection, title: string): Promise {
const result = await this.navigatorWorkspace(selection.reviewId, selection.kind === "version" ? { version: selection.version } : {});
- await this.host.openWindow([{ workspaceUri: URI.file(result.workspacePath), label: title }], { forceNewWindow: true });
+ const { uri, remote } = navigatorPaths(result);
+ await this.host.openWindow([{ workspaceUri: uri(result.workspacePath), label: title }], { forceNewWindow: true, ...remote });
}
/** Hand source opens to the native workspace before Review creates an editor group. */
@@ -118,27 +137,27 @@ export class ReviewCanvasEditorTabsService extends Disposable implements IReview
const resources = diff ? [editor.original.resource, editor.modified.resource] : [isResourceEditorInput(editor) ? editor.resource : undefined];
if (!resources.every((resource): resource is URI => !!resource && [REVIEW_API_SOURCE_SCHEME, REVIEW_LANGUAGE_SOURCE_SCHEME].includes(resource.scheme))) return false;
const destinations = await Promise.all(resources.map(resource => this.sourceDestination(resource)));
+ const { workspaceUri, remote } = destinations[destinations.length - 1];
+ const selection = !diff ? (editor.options as ITextEditorOptions | undefined)?.selection : undefined;
await this.host.openWindow([
- { workspaceUri: destinations[destinations.length - 1].workspaceUri },
- ...destinations.map(({ filePath }) => {
- const selection = !diff ? (editor.options as ITextEditorOptions | undefined)?.selection : undefined;
- return { fileUri: URI.file(selection ? `${filePath}:${selection.startLineNumber}:${selection.startColumn ?? 1}` : filePath) };
- }),
- ], { forceNewWindow: true, gotoLineMode: true, diffMode: diff });
+ { workspaceUri },
+ ...destinations.map(({ fileUri }) => ({ fileUri: selection ? fileUri.with({ path: `${fileUri.path}:${selection.startLineNumber}:${selection.startColumn ?? 1}` }) : fileUri })),
+ ], { forceNewWindow: true, gotoLineMode: true, diffMode: diff, ...remote });
return true;
}
async openSourceReferences(resource: URI, position: { readonly lineNumber: number; readonly column: number }): Promise {
if (![REVIEW_API_SOURCE_SCHEME, REVIEW_LANGUAGE_SOURCE_SCHEME].includes(resource.scheme)) return false;
- const destination = await this.sourceDestination(resource);
- await this.host.openWindow([{ workspaceUri: destination.workspaceUri }], {
+ const { workspaceUri, fileUri, remote } = await this.sourceDestination(resource);
+ await this.host.openWindow([{ workspaceUri }], {
forceNewWindow: true,
- reviewReferencesToShow: { resource: URI.file(destination.filePath), lineNumber: position.lineNumber, column: position.column },
+ ...remote,
+ reviewReferencesToShow: { resource: fileUri, lineNumber: position.lineNumber, column: position.column },
});
return true;
}
- private async sourceDestination(resource: URI): Promise<{ workspaceUri: URI; filePath: string }> {
+ private async sourceDestination(resource: URI) {
const target = sourceLocation(resource);
const local = resource.scheme === REVIEW_LANGUAGE_SOURCE_SCHEME;
const result = await this.navigatorWorkspace(target.view.reviewId, {
@@ -147,12 +166,21 @@ export class ReviewCanvasEditorTabsService extends Disposable implements IReview
file: local ? undefined : target.file,
empty: new URLSearchParams(resource.query).has("empty") ? "true" : undefined,
});
- const filePath = local ? resource.fsPath : result.filePath;
- if (!filePath) throw new Error("The navigator did not resolve the source file.");
- return { workspaceUri: URI.file(result.workspacePath), filePath };
+ const { uri, remote } = navigatorPaths(result);
+ const fileUri = "serverId" in result
+ ? result.emptySide ? await this.emptyFile() : result.filePath && uri(result.filePath)
+ : local ? URI.file(resource.fsPath) : result.filePath && uri(result.filePath);
+ if (!fileUri) throw new Error("The navigator did not resolve the source file.");
+ return { workspaceUri: uri(result.workspacePath), fileUri, remote };
}
- private async navigatorWorkspace(reviewId: string, values: Record): Promise<{ workspacePath: string; filePath?: string }> {
+ private async emptyFile(): Promise {
+ const resource = joinPath(this.environment.cacheHome, "source-empty", "empty");
+ await this.files.writeFile(resource, VSBuffer.fromString(""));
+ return resource.with({ scheme: Schemas.vscodeUserData });
+ }
+
+ private async navigatorWorkspace(reviewId: string, values: Record): Promise {
const { serverUrl, token } = await this.desktopConnection.getConnection();
const query = new URLSearchParams(Object.entries(values).filter(([key, value]) => key !== "reviewId" && value !== undefined).map(([key, value]) => [key, String(value)]));
const response = await fetch(`${serverUrl}/reviews-api/${encodeURIComponent(reviewId)}/navigator${query.size ? `?${query}` : ""}`, {
@@ -160,6 +188,11 @@ export class ReviewCanvasEditorTabsService extends Disposable implements IReview
headers: { "x-review-token": token },
signal: AbortSignal.timeout(60_000),
});
+ const alias = response.status === 503 && response.headers.get("x-review-host");
+ if (alias) {
+ const host = (await this.desktopConnection.readRemoteHosts().catch(() => [])).find(host => host.alias === alias);
+ throw new ReviewHostDown(host && host.state !== "online" ? host : { alias, state: "offline" });
+ }
if (!response.ok) throw await reviewResponseError(response, "Could not open the code navigator.");
return response.json();
}
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts
index 8c214ffea..d74460cc4 100644
--- a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts
@@ -258,12 +258,7 @@ export class ReviewDesktopConnectionService extends Disposable implements IRevie
}
async getRemoteLanguageEndpoint(serverId: string): Promise {
- const endpoint: unknown = await this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("getRemoteLanguageEndpoint", serverId);
- if (typeof endpoint !== "object" || endpoint === null) return undefined;
- const { host, port, connectionToken } = endpoint as Record;
- return typeof host === "string" && Number.isInteger(port) && typeof connectionToken === "string"
- ? { host, port: port as number, connectionToken }
- : undefined;
+ return this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("getRemoteLanguageEndpoint", serverId);
}
async installRemoteHost(alias: string): Promise {
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewEditorResolverService.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewEditorResolverService.test.ts
index 179afa0e7..0139f6443 100644
--- a/apps/review-desktop/code-oss/src/vs/review/services/reviewEditorResolverService.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewEditorResolverService.test.ts
@@ -28,6 +28,8 @@ test("source tree, selected code, definitions and diffs hand off before creating
{ async getConnection() { return { serverUrl: "http://localhost", token: "test" }; } } as never,
{ async openWindow(openables: IWindowOpenable[], options: IOpenWindowOptions) { windows.push({ openables, options }); } } as never,
{ warn() {} } as never,
+ {} as never,
+ {} as never,
);
const resolver = new ReviewEditorResolverService(
{ get activeGroup() { throw new Error("Review must not create an editor group"); } } as never,
@@ -46,7 +48,7 @@ test("source tree, selected code, definitions and diffs hand off before creating
{ workspaceUri: URI.file("/navigator/base.code-workspace") },
{ fileUri: URI.file("/navigator/base/old-name.ts:42:3") },
]);
- assert.deepEqual(windows[1].options, { forceNewWindow: true, gotoLineMode: true, diffMode: false });
+ assert.equal(windows[1].options.remoteAuthority, undefined);
assert.equal(requests[1].searchParams.get("version"), "7");
assert.equal(requests[1].searchParams.get("commit"), "selected-commit");
assert.equal(requests[1].searchParams.get("repositoryId"), "other-repository");
diff --git a/apps/review-desktop/code-oss/src/vs/server/node/reviewServerHandshake.test.ts b/apps/review-desktop/code-oss/src/vs/server/node/reviewServerHandshake.test.ts
index 9bb92e665..5af306f62 100644
--- a/apps/review-desktop/code-oss/src/vs/server/node/reviewServerHandshake.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/server/node/reviewServerHandshake.test.ts
@@ -160,6 +160,10 @@ if (external) {
const connection = await handshake(target);
assert.equal(connection.environment.pid, server.pid);
assert.match(output, /Launched Extension Host Process/);
+ const extensions = connection.management.client.getChannel('extensions');
+ assert.deepEqual(await extensions.call('getInstalled', []), []);
+ await assert.rejects(extensions.call('installFromGallery', []), /not managed from a Source window \(installFromGallery\)/);
+ await connection.management.client.getChannel('extensionGalleryManifest').call('setExtensionGalleryManifest', [null]);
close(connection);
});
diff --git a/apps/review-desktop/code-oss/src/vs/server/node/serverServices.ts b/apps/review-desktop/code-oss/src/vs/server/node/serverServices.ts
index 445ef4d9a..61671acf4 100644
--- a/apps/review-desktop/code-oss/src/vs/server/node/serverServices.ts
+++ b/apps/review-desktop/code-oss/src/vs/server/node/serverServices.ts
@@ -139,6 +139,10 @@ export async function setupServerServices(connectionToken: ServerConnectionToken
// Whiteboard: the client always opens this channel; telemetry is cut.
socketServer.registerChannel('telemetry', new NullTelemetryChannel());
+ // Whiteboard: extension management is cut.
+ socketServer.registerChannel('extensions', new NoExtensionsChannel());
+ socketServer.registerChannel('extensionGalleryManifest', new NoExtensionsChannel());
+
const remoteExtensionsScanner = new RemoteExtensionsScannerService(userDataProfilesService, extensionsScannerService, logService);
socketServer.registerChannel(RemoteExtensionsScannerChannelName, new RemoteExtensionsScannerChannel(remoteExtensionsScanner, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority)));
@@ -156,6 +160,15 @@ class NullTelemetryChannel implements IServerChannel(): Event { return Event.None; }
}
+class NoExtensionsChannel implements IServerChannel {
+ async call(_: RemoteAgentConnectionContext, command: string): Promise {
+ if (command === 'getInstalled') { return [] as T; }
+ if (command === 'setExtensionGalleryManifest') { return undefined as T; }
+ throw new Error(`Extensions on a Whiteboard remote are not managed from a Source window (${command}).`);
+ }
+ listen(): Event { return Event.None; }
+}
+
const _uriTransformerCache: { [remoteAuthority: string]: IURITransformer } = Object.create(null);
function getUriTransformer(remoteAuthority: string): IURITransformer {
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/harness.mjs b/apps/review-desktop/scripts/e2e/harness.mjs
index 2fdce52aa..04c5ae0e6 100644
--- a/apps/review-desktop/scripts/e2e/harness.mjs
+++ b/apps/review-desktop/scripts/e2e/harness.mjs
@@ -565,13 +565,16 @@ export async function createHarness({
}
// Artifacts are best-effort: a Desktop that outlives the runner is worse than a missing screenshot.
- if (!success && page) {
- await page
+ // A journey may have moved ctx.page to another window after a relaunch.
+ const shown = ctx.page ?? page;
+
+ if (!success && shown) {
+ await shown
.screenshot({ path: path.join(root, "failure.png") })
.catch(() => {});
await writeFile(
path.join(root, "failure-dom.txt"),
- await page
+ await shown
.locator("body")
.innerText()
.catch(() => ""),
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs
new file mode 100644
index 000000000..13cadc30d
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs
@@ -0,0 +1,413 @@
+/** Ask on a review whose checkout is on another machine: a container with a fake OpenCode, reached over SSH and added in Settings as a user would. */
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+import { closeSourceWindow, openHome, openSettings } from "../harness.mjs";
+import {
+ alias,
+ closeDesktop,
+ createRemoteReview,
+ masterPid,
+ onRemote,
+ prepared,
+ recordRequests,
+ remote,
+ remoteToken,
+ runDir,
+ sourcePages,
+} from "./remote-host.mjs";
+import { removeHost } from "./remote-install.mjs";
+
+const exec = promisify(execFile);
+
+const title = "Remote ask";
+
+const question = "what does f.ts do";
+
+export const name = "remote-ask";
+
+// Phase 2: the container image downloads Ubuntu packages and Node.
+export const phase = 2;
+
+export const options = {
+ settings: { "review.experimental.remoteHosts.enabled": true },
+ env: { DEV_FAST_REVIEW_SSH_CONFIG: `${runDir}/ssh_config` },
+};
+
+export async function run(ctx) {
+ // A packaged build ignores DEV_FAST_REVIEW_SSH_CONFIG, so its ssh would read the user's configuration.
+ if (ctx.report.mode === "packaged")
+ throw new Error("skip: remote-ask runs in development mode only");
+
+ if (prepared === undefined)
+ try {
+ await exec("docker", ["info", "--format", "{{.ServerVersion}}"]);
+ } catch (error) {
+ throw new Error(
+ `skip: remote-ask needs Docker for its SSH server (${error.message.split("\n")[0]})`,
+ );
+ }
+
+ try {
+ await journey(ctx);
+ } finally {
+ if (prepared === undefined)
+ await remote("down", "--all").catch((error) =>
+ console.error(`[remote-ask] down --all: ${error.message}`),
+ );
+ else await closeDesktop(ctx);
+ }
+}
+
+/** Pauses the container, or stops a prepared host's server and returns its resume. */
+async function freeze() {
+ if (prepared === undefined) {
+ await remote("pause", "a");
+
+ return undefined;
+ }
+
+ const { serverPid } = JSON.parse(
+ await onRemote("whiteboard server status --json"),
+ );
+
+ await onRemote(`kill -STOP ${serverPid}`);
+
+ return () => onRemote(`kill -CONT ${serverPid}`);
+}
+
+const reviewCanvas = (ctx) =>
+ ctx.page.locator(".review-canvas-root [data-review-api]");
+
+/** Ask, docked in the side panel or floating, by the layout. */
+export const askPanel = (ctx) =>
+ ctx.page.locator(
+ '[role=complementary][aria-label="Ask"], [role=dialog][aria-label="Ask"]',
+ );
+
+/** Home's row for the review titled `title`. */
+export const sessionRow = (ctx, title) =>
+ ctx.page
+ .locator("main.review-home [data-home-row]")
+ .filter({ hasText: title });
+
+/** Opens the review titled `title` from Home. */
+export async function openRemoteReview(ctx, title) {
+ await openHome(ctx);
+ await sessionRow(ctx, title).waitFor({ timeout: 60000 });
+ await sessionRow(ctx, title).getByTitle(title, { exact: true }).click();
+ await reviewCanvas(ctx).getByRole("heading", { name: title }).waitFor({
+ timeout: 60000,
+ });
+}
+
+/** Selects the document's sentence and opens Ask on it with ⌘L. */
+export async function askAboutReviewSentence(ctx) {
+ // The workbench's usage-data notice sits over the composer's Ask button.
+ for (const clear of await ctx.page
+ .locator(".notifications-toasts .codicon-notifications-clear")
+ .all())
+ await clear.click().catch(() => {});
+
+ const sentence = reviewCanvas(ctx).getByText("One now returns");
+
+ await sentence.waitFor();
+ await sentence.evaluate((element) => {
+ const range = document.createRange();
+
+ range.selectNodeContents(element);
+ document.getSelection().removeAllRanges();
+ document.getSelection().addRange(range);
+ });
+ await ctx.page
+ .getByRole("button", { name: "Ask OpenCode", exact: true })
+ .waitFor({ timeout: 30000 });
+ await ctx.page.keyboard.press("Meta+l");
+ await askPanel(ctx)
+ .getByRole("combobox", { name: "Question" })
+ .waitFor({ timeout: 30000 });
+}
+
+async function journey(ctx) {
+ const { until } = ctx;
+ const timings = {};
+
+ const hostState = async () =>
+ (await ctx.apiOk("/remote-hosts")).find((host) => host.alias === alias)
+ ?.state;
+
+ const waitState = (state, label, timeout = 60000) =>
+ until(
+ async () => (await hostState()) === state,
+ `${alias} ${label}`,
+ timeout,
+ );
+
+ const canvas = () => reviewCanvas(ctx);
+
+ const panel = () => askPanel(ctx);
+
+ const composer = () => panel().getByRole("combobox", { name: "Question" });
+
+ const homeRow = () => sessionRow(ctx, title);
+
+ const openReview = () => openRemoteReview(ctx, title);
+
+ const askAboutSentence = () => askAboutReviewSentence(ctx);
+
+ /** Resolves with the ms until the panel shows the answer. */
+ async function send(text) {
+ await composer().fill(text);
+
+ const sent = Date.now();
+
+ await panel().getByRole("button", { name: "Ask", exact: true }).click();
+ await panel().getByText(`You asked: ${text}`).waitFor({ timeout: 20000 });
+
+ return Date.now() - sent;
+ }
+
+ const savedConversations = () =>
+ canvas().getByRole("button", { name: "Saved conversations" });
+
+ async function historyLists(label) {
+ await until(
+ async () => {
+ await savedConversations().first().click();
+
+ return (
+ (await panel()
+ .getByRole("list")
+ .filter({ hasText: question })
+ .count()) > 0
+ );
+ },
+ label,
+ 30000,
+ );
+ }
+
+ // 1. A container with sshd, this checkout's package and the fake OpenCode, or a prepared host, holding a review.
+ if (prepared === undefined) {
+ await remote("up", "a", "--fake-agent");
+ await remote("install", "a");
+ }
+
+ const [reviewId] = (
+ await onRemote(`bash -s -- '${title}'`, createRemoteReview)
+ ).split("\n");
+
+ assert.match(reviewId, /^[0-9a-f-]{36}$/, "the remote review's id");
+
+ const remoteTokens = new Set([await remoteToken()]);
+
+ ctx.check(`1. ${alias}, with a fake OpenCode, holds a review`);
+
+ // 2. Settings, as a user adds a host.
+ const section = (await openSettings(ctx)).getByRole("region", {
+ name: "Remote hosts",
+ });
+
+ await section.getByLabel("SSH alias").fill(alias);
+
+ const added = Date.now();
+
+ await section.getByRole("button", { name: "Add", exact: true }).click();
+ await waitState("online", "online");
+ timings.addToOnline = Date.now() - added;
+ ctx.check(
+ `2. Settings added ${alias}; online ${timings.addToOnline} ms after Add`,
+ );
+
+ // 3. The remote review, a sentence selected, ⌘L.
+ const requests = await recordRequests(ctx.page, /\/ask\/watch(\?|$)/);
+ const recorders = [{ requests, origin: new URL(ctx.discovery.url).origin }];
+ await openReview();
+ await askAboutSentence();
+ ctx.check("3. ⌘L on a selected sentence of the remote review opened Ask");
+
+ // 4. The agents are the remote's: with OpenCode gone, the panel names the host; back, it answers again.
+ await onRemote("mv ~/.opencode ~/.opencode.off");
+ await until(
+ async () => {
+ await ctx.page.evaluate(() => window.dispatchEvent(new Event("focus")));
+
+ return (
+ (await panel().getByText(`Not installed on ${alias}`).count()) >= 3
+ );
+ },
+ `the panel to read Not installed on ${alias}`,
+ 30000,
+ );
+
+ for (const agent of ["Claude Code", "Codex", "OpenCode"])
+ assert.match(
+ await panel()
+ .getByRole("listitem")
+ .filter({ hasText: agent })
+ .innerText(),
+ new RegExp(`Not installed on ${alias}`),
+ );
+
+ await onRemote("mv ~/.opencode.off ~/.opencode");
+ await until(
+ async () => {
+ await ctx.page.evaluate(() => window.dispatchEvent(new Event("focus")));
+
+ return (await composer().count()) > 0;
+ },
+ "the composer to come back with OpenCode",
+ 30000,
+ );
+ await panel().getByText("OpenCode", { exact: true }).waitFor();
+
+ for (const name of ["OpenCode", "Choose an agent"])
+ assert.equal(
+ await panel().getByRole("button", { name, exact: true }).count(),
+ 0,
+ );
+ ctx.check(
+ `4. without the remote's OpenCode, Claude Code, Codex and OpenCode read Not installed on ${alias}; with it back, the header names OpenCode alone, without a menu`,
+ );
+
+ // 5. The question goes to the remote's agent; its answer streams back through the gateway.
+ timings.firstAnswer = await send(question);
+ ctx.check(
+ `5. OpenCode on ${alias} answered "You asked: …" ${timings.firstAnswer} ms after Ask`,
+ );
+
+ // 6. The answer's f.ts is a link: it opens the host's file in a Source window.
+ const reference = panel()
+ .getByRole("link")
+ .filter({ hasText: /^f\.ts$/ });
+
+ await reference.waitFor();
+ await reference.click();
+
+ const source = await until(
+ () => sourcePages(ctx)[0],
+ "a Source window for the answer's f.ts",
+ 60000,
+ );
+
+ await until(
+ async () =>
+ (
+ await source.locator(".tabs-container .tab.active").innerText()
+ ).includes("f.ts"),
+ "f.ts in the Source window",
+ 60000,
+ );
+ await closeSourceWindow(source);
+ ctx.check("6. the answer's f.ts link opened f.ts in a Source window");
+
+ // 7. Ask spoke only to the local server, and no remote token reached the page.
+ const asks = audit(recorders, remoteTokens);
+
+ assert.ok(
+ asks.some((r) => r.received?.toString().includes("You asked:")),
+ "no watch stream carried the answer",
+ );
+ ctx.check(
+ `7. ${asks.length} Ask requests, all to the local server, none with the remote token; the answer came on its watch stream`,
+ );
+
+ // 8. The ssh master dies and comes back: the thread is still saved.
+ const master = await masterPid();
+
+ assert.ok(master, "no ssh master for the host");
+ process.kill(master, "SIGKILL");
+ await until(
+ async () =>
+ (await hostState()) === "online" && (await masterPid()) !== master,
+ `${alias} online on a new master`,
+ 60000,
+ );
+ await panel().getByRole("button", { name: "Close Ask" }).click();
+ await historyLists("the thread in Ask history after the reattach");
+ ctx.check("8. after a new ssh master, Ask history lists the thread");
+
+ // 9. A Desktop restart: the thread is still listed.
+ await ctx.quitAndRelaunchDesktop();
+ recorders.push({
+ requests: await recordRequests(ctx.page, /^$/),
+ origin: new URL(ctx.discovery.url).origin,
+ });
+ await waitState("online", "online after the relaunch");
+ remoteTokens.add(await remoteToken());
+ await openReview();
+ await historyLists("the thread in Ask history after the relaunch");
+ await panel().getByRole("button", { name: "Close Ask" }).click();
+ ctx.check("9. after a Desktop restart, Ask history lists the thread");
+
+ // 10. The host goes away with a conversation open: the panel says so, and the host reads offline.
+ await askAboutSentence();
+ await send("and then");
+ const thaw = await freeze();
+
+ const paused = Date.now();
+
+ await until(
+ async () =>
+ (await panel()
+ .getByRole("alert")
+ .filter({ hasText: "lost its connection" })
+ .count()) > 0,
+ "the panel's lost-connection alert",
+ 20000,
+ );
+ timings.streamEnded = Date.now() - paused;
+ await waitState("offline", "offline after the pause", 20000);
+ timings.offline = Date.now() - paused;
+ assert.ok(
+ timings.streamEnded <= 15000,
+ `alert after ${timings.streamEnded} ms`,
+ );
+ assert.ok(timings.offline <= 15000, `offline after ${timings.offline} ms`);
+ await thaw?.();
+ ctx.check(
+ `10. ${prepared === undefined ? "docker pause" : "server SIGSTOP"}: the panel said it lost OpenCode after ${timings.streamEnded} ms; ${alias} offline after ${timings.offline} ms`,
+ );
+
+ // 11. Removing the host takes the review out of Home.
+ await removeHost(ctx, alias);
+ await openHome(ctx);
+ await until(
+ async () => (await homeRow().count()) === 0,
+ "the remote review to leave Home",
+ );
+ const allAsks = audit(recorders, remoteTokens);
+
+ ctx.check(
+ `11. removing the host took the review out of Home; across both launches ${allAsks.length} Ask requests, all to the local server, none with a remote token`,
+ );
+}
+
+/** Asserts each launch's Ask requests stayed local and none carried a remote token. */
+function audit(recorders, remoteTokens) {
+ const asks = [];
+
+ for (const { requests, origin } of recorders)
+ for (const r of requests.values()) {
+ if (!r.url) continue;
+ assert.ok(
+ ![r.url, ...Object.values(r.headers ?? {})].some((value) =>
+ [...remoteTokens].some((token) => String(value).includes(token)),
+ ),
+ "a request carried a remote token",
+ );
+
+ if (!/\/ask(\/|\?|$)/.test(new URL(r.url).pathname)) continue;
+ assert.equal(
+ new URL(r.url).origin,
+ origin,
+ "an Ask request past the local server",
+ );
+ asks.push(r);
+ }
+
+ assert.ok(asks.length > 0, "no Ask request was recorded");
+
+ return asks;
+}
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
index dae1f47ce..a71711f4d 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
@@ -12,11 +12,11 @@ const remoteScript = path.join(import.meta.dirname, "../remote/remote.mjs");
const runId = process.env.WB_TEST_RUN ?? `e2e${Date.now().toString(36)}`;
-const runDir = `/tmp/wbt.${runId}`;
+export const runDir = `/tmp/wbt.${runId}`;
-const prepared = process.env.REVIEW_E2E_REMOTE_HOST;
+export const prepared = process.env.REVIEW_E2E_REMOTE_HOST;
-const alias = `wb-test-${prepared ?? "a"}`;
+export const alias = `wb-test-${prepared ?? "a"}`;
const title = "Remote order";
@@ -32,7 +32,7 @@ export const options = {
env: { DEV_FAST_REVIEW_SSH_CONFIG: `${runDir}/ssh_config` },
};
-const createRemoteReview = String.raw`
+export const createRemoteReview = String.raw`
set -e
field() { node -pe "JSON.parse(require('fs').readFileSync(0, 'utf8')).$1"; }
rm -rf ~/wbrepo
@@ -61,7 +61,7 @@ export async function listedAs(ctx, title) {
return review && `${review.host}: ${review.repositoryGroup?.label}`;
}
-async function remote(...args) {
+export async function remote(...args) {
return (
await exec(process.execPath, [remoteScript, ...args], {
env: { ...process.env, WB_TEST_RUN: runId },
@@ -70,7 +70,7 @@ async function remote(...args) {
).stdout.trim();
}
-function onRemote(command, input = "") {
+export function onRemote(command, input = "") {
return new Promise((resolve, reject) => {
const child = spawn(
"ssh",
@@ -96,7 +96,7 @@ function onRemote(command, input = "") {
const remoteApi = (tool, input) =>
onRemote(`whiteboard api ${tool} -`, JSON.stringify(input));
-async function remoteToken() {
+export async function remoteToken() {
const out = (await onRemote("whiteboard remote attach --json")).split("\n");
let token;
@@ -115,7 +115,7 @@ async function remoteToken() {
return token;
}
-async function recordRequests(page, streamed) {
+export async function recordRequests(page, streamed) {
const cdp = await page.context().newCDPSession(page);
const requests = new Map();
const entry = (id) => requests.get(id) ?? requests.set(id, { id }).get(id);
@@ -161,6 +161,14 @@ async function recordRequests(page, streamed) {
return requests;
}
+/** Every other workbench window than `ctx.page`: the Source windows. */
+export function sourcePages(ctx) {
+ return ctx.browser
+ .contexts()
+ .flatMap((context) => context.pages())
+ .filter((page) => page !== ctx.page && !page.isClosed());
+}
+
const DESKTOP_ROUTE =
/^\/(app|control|crash-reports|diffr-config|install|preferences|remote-hosts|telemetry|tutorial)(\/|$)|^\/reviews-api\/[^/]+\/telemetry\//;
@@ -191,7 +199,7 @@ export async function run(ctx) {
}
}
-async function closeDesktop(ctx) {
+export async function closeDesktop(ctx) {
const session = await ctx.browser.newBrowserCDPSession().catch(() => null);
await Promise.race([
@@ -409,12 +417,17 @@ async function journey(ctx, page, until) {
await canvas.getByRole("heading", { name: second }).waitFor();
const laptopOnly = canvas.locator(
- 'button[aria-label="Source tree ↗"], button[aria-label="Share review"], button[aria-label="Shared review"], [aria-label="Session views"] button[aria-label="Trace"]',
+ 'button[aria-label="Share review"], button[aria-label="Shared review"], [aria-label="Session views"] button[aria-label="Trace"]',
+ );
+
+ const sourceTree = canvas.locator(
+ 'button[aria-label="Source tree ↗"]:not([disabled])',
);
await until(
- async () => (await laptopOnly.count()) === 0,
- "the pushed tab without Source tree, Share and Trace",
+ async () =>
+ (await laptopOnly.count()) === 0 && (await sourceTree.count()) === 1,
+ "the pushed tab with Source tree, without Share and Trace",
10000,
);
await view("Diff").click();
@@ -425,32 +438,31 @@ async function journey(ctx, page, until) {
),
"the pushed tab's Diff view to show f.ts",
);
- assert.ok(
- await page
- .locator(".review-multidiff-open-container")
- .evaluateAll((all) => all.length > 0 && all.every((e) => e.hidden)),
- "Open file shown for a review on another machine",
+ // A host with language features opens a review's files in a Source window on it.
+ await until(
+ () =>
+ page
+ .locator(".review-multidiff-open-container")
+ .evaluateAll((all) => all.length > 0 && all.some((e) => !e.hidden)),
+ "Open file in the pushed tab's Diff view",
+ 10000,
);
await view("Whiteboard").click();
ctx.check(
- "5. session_create with open: true on the remote opened a tab without Open file, Source tree, Share or Trace",
+ "5. session_create with open: true on the remote opened a tab with Open file and Source tree, without Share or Trace",
);
// 6. The ssh master dies: the review says so, then recovers in the same page.
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,
@@ -463,8 +475,8 @@ 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,
);
await until(
@@ -474,12 +486,8 @@ async function journey(ctx, page, until) {
60000,
);
await until(
- async () =>
- (await canvas
- .locator("[role=status]")
- .filter({ hasText: "Connection lost" })
- .count()) === 0,
- "the banner to clear",
+ async () => (await canvas.locator(".connection-chip").count()) === 0,
+ "the chip to clear",
);
await remoteApi("session_edit", {
sessionId: reviewId,
@@ -491,7 +499,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.
@@ -532,8 +540,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(),
@@ -544,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(
@@ -672,7 +683,7 @@ async function desktopSshProcesses() {
const desktopSsh = async () =>
(await desktopSshProcesses()).map(([pid]) => pid);
-async function masterPid() {
+export async function masterPid() {
return (await desktopSshProcesses()).find(
([, args]) => args.includes(" -M -N ") && args.endsWith(`-- ${alias}`),
)?.[0];
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs
index d9c9000bc..a381a2fba 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-install.mjs
@@ -323,7 +323,7 @@ async function addHost(ctx, host) {
return section;
}
-async function removeHost(ctx, host, { uninstall = false } = {}) {
+export async function removeHost(ctx, host, { uninstall = false } = {}) {
const section = await hostsSection(ctx);
await section.getByRole("button", { name: `Remove ${host}` }).click();
@@ -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/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs
new file mode 100644
index 000000000..be019eb4c
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs
@@ -0,0 +1,739 @@
+/** A remote review's Source window, as a user meets it: opened from the review and from Ask, offline and back, restored at start, closed when its host goes. */
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+import { openSettings } from "../harness.mjs";
+import {
+ askAboutReviewSentence,
+ askPanel,
+ openRemoteReview,
+} from "./remote-ask.mjs";
+import {
+ alias,
+ closeDesktop,
+ onRemote,
+ prepared,
+ remote,
+ runDir,
+ sourcePages,
+} from "./remote-host.mjs";
+import { removeHost } from "./remote-install.mjs";
+import { lines, pointerHover } from "./remote-lsp.mjs";
+import {
+ fileText,
+ focusFile,
+ inSource,
+ pointAt,
+ quickRows,
+ typeInto,
+ windowState,
+} from "../remote/source-window-helpers.mjs";
+
+const exec = promisify(execFile);
+
+const title = "Remote source";
+
+const second = "Second checkout";
+
+export const name = "remote-source-window";
+
+// Phase 2: the container image downloads Ubuntu packages and Node.
+export const phase = 2;
+
+export const options = {
+ settings: { "review.experimental.remoteHosts.enabled": true },
+ env: { DEV_FAST_REVIEW_SSH_CONFIG: `${runDir}/ssh_config` },
+};
+
+/** Runs on the remote: two repositories, each with a review of its second commit (f.ts changed, added.ts added). Prints the reviews' ids. */
+const fixture = String.raw`
+set -e
+field() { node -pe "JSON.parse(require('fs').readFileSync(0, 'utf8')).$1"; }
+make() {
+ rm -rf ~/$1
+ git init -q -b main ~/$1
+ cd ~/$1
+ git config user.email e2e@example.invalid
+ git config user.name e2e
+ mkdir docs
+ printf '%s\n' "$3" > docs/needle.md
+ printf 'export function one() {\n return 1;\n}\n' > f.ts
+ git add .
+ git commit -qm one
+ printf 'export function one() {\n return 2;\n}\n\nexport function two() {\n const unused = 3;\n return one() + 1;\n}\n' > f.ts
+ printf 'export const added = "wbsource-added";\n' > added.ts
+ git add .
+ git commit -qm two
+ id=$(whiteboard api session_create "{\"title\":\"$2\",\"open\":false,\"target\":{\"kind\":\"commits\",\"repositoryPath\":\"$HOME/$1\",\"base\":\"HEAD~1\",\"head\":\"HEAD\"}}" | field sessionId)
+ whiteboard api session_edit "{\"sessionId\":\"$id\",\"edit\":{\"type\":\"insert\",\"content\":{\"type\":\"markdown\",\"markdown\":\"One now returns **two**.\"}}}" >/dev/null
+ echo "$id"
+}
+make wbsource "$1" wbsource-needle
+make wbsource2 "$2" wbsource-other
+`;
+
+export async function run(ctx) {
+ // A packaged build ignores DEV_FAST_REVIEW_SSH_CONFIG, so its ssh would read the user's configuration.
+ if (ctx.report.mode === "packaged")
+ throw new Error("skip: remote-source-window runs in development mode only");
+
+ if (prepared === undefined)
+ try {
+ await exec("docker", ["info", "--format", "{{.ServerVersion}}"]);
+ } catch (error) {
+ throw new Error(
+ `skip: remote-source-window needs Docker for its SSH server (${error.message.split("\n")[0]})`,
+ );
+ }
+
+ try {
+ await journey(ctx);
+ } finally {
+ if (prepared === undefined)
+ await remote("down", "--all").catch((error) =>
+ console.error(`[remote-source-window] down --all: ${error.message}`),
+ );
+ else await closeDesktop(ctx);
+ }
+}
+
+function check(ctx, text) {
+ console.error(`[remote-source-window] ${new Date().toISOString()} ${text}`);
+ ctx.check(text);
+}
+
+/**
+ * Freezes the host: the container, or on a prepared host its user's Node
+ * and Whiteboard processes (the review server, the VS Code server and its
+ * extension hosts, the attach the Desktop's ssh runs).
+ * Returns the function that thaws it.
+ */
+async function freeze() {
+ if (prepared === undefined) {
+ await remote("pause", "a");
+
+ return () => remote("resume", "a");
+ }
+
+ const pids = await onRemote(String.raw`
+ s=$(whiteboard server status --json | node -pe "JSON.parse(require('fs').readFileSync(0, 'utf8')).serverPid")
+ p=$(pgrep -u "$(id -u)" -f 'node|whiteboard' | grep -vx "$$" | tr '\n' ' ')
+ kill -STOP $s $p 2>/dev/null
+ echo $s $p
+ `);
+
+ return () => onRemote(`kill -CONT ${pids}`);
+}
+
+/** The host's remote extension hosts, as `[pid, rss KiB]`. */
+async function extensionHosts() {
+ return (
+ await onRemote(
+ "ps -eo pid=,rss=,args= | grep -- '--type=extensionHost' | grep -v grep || true",
+ )
+ )
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => line.trim().split(/\s+/).slice(0, 2).map(Number));
+}
+
+const mib = (kib) => Math.round(kib / 1024);
+
+/** The Source window's host entry in its status bar. */
+const hostEntry = (page) =>
+ page
+ .locator('[id="review.sourceWindow.host"]')
+ .innerText({ timeout: 1000 })
+ .then((text) => text.trim())
+ .catch(() => "");
+
+async function journey(ctx) {
+ const { until } = ctx;
+ const timings = {};
+
+ const hostState = async () =>
+ (await ctx.apiOk("/remote-hosts")).find((host) => host.alias === alias);
+
+ const openReview = (text) => openRemoteReview(ctx, text);
+
+ /** The review's Diff view, then Open file on f.ts; resolves with the Source window that shows it. */
+ async function openFile(text) {
+ await openReview(text);
+ await ctx.page
+ .locator('[aria-label="Session views"] button[aria-label="Diff"]')
+ .filter({ visible: true })
+ .click();
+ await until(
+ async () =>
+ (
+ await ctx.page
+ .locator(".review-path-label")
+ .filter({ visible: true })
+ .allInnerTexts()
+ ).some((t) => t.includes("f.ts")),
+ "the Diff view to show f.ts",
+ );
+
+ // The button in the header whose path label reads f.ts.
+ const index = await until(
+ () =>
+ ctx.page
+ .evaluate(() =>
+ [...document.querySelectorAll(".review-multidiff-open")].findIndex(
+ (button) => {
+ for (let e = button; e; e = e.parentElement) {
+ const label = e.querySelector(".review-path-label");
+
+ if (label) return label.innerText.trim() === "f.ts";
+ }
+
+ return false;
+ },
+ ),
+ )
+ .then((i) => (i >= 0 ? i : null)),
+ "f.ts's Open file button",
+ );
+
+ const before = new Set(sourcePages(ctx));
+
+ await ctx.page.locator(".review-multidiff-open").nth(index).click();
+
+ const source = await until(
+ () => sourcePages(ctx).find((page) => !before.has(page)),
+ `a new Source window for ${text}`,
+ 60000,
+ );
+
+ await until(
+ async () => (await lines(source)).includes("return one() + 1;"),
+ "f.ts's text from the host in the Source window",
+ 120000,
+ );
+
+ return source;
+ }
+
+ // 1. A host holding two reviews, added in Settings.
+ if (prepared === undefined) {
+ await remote("up", "a", "--fake-agent");
+ await remote("install", "a");
+ }
+
+ await onRemote("whiteboard remote extensions ensure --json");
+
+ const [reviewId, secondId] = (
+ await onRemote(`bash -s -- '${title}' '${second}'`, fixture)
+ ).split("\n");
+
+ assert.match(reviewId, /^[0-9a-f-]{36}$/, "the remote review's id");
+ assert.match(secondId, /^[0-9a-f-]{36}$/, "the second review's id");
+
+ const section = (await openSettings(ctx)).getByRole("region", {
+ name: "Remote hosts",
+ });
+
+ await section.getByLabel("SSH alias").fill(alias);
+
+ const added = Date.now();
+
+ await section.getByRole("button", { name: "Add", exact: true }).click();
+ await until(
+ async () => {
+ const host = await hostState();
+
+ return host?.state === "online" && host.languageFeatures === true;
+ },
+ `${alias} online with language features`,
+ 180000,
+ );
+ timings.online = Date.now() - added;
+ check(
+ ctx,
+ `1. ${alias}${prepared ? " (prepared)" : ""} holds two reviews; Settings added it, online with language features ${timings.online} ms after Add`,
+ );
+
+ // 2. Open file opens a Source window bound to the host, titled for the review.
+ const opened = Date.now();
+ const source = await openFile(title);
+
+ timings.openFile = Date.now() - opened;
+
+ let state = await inSource(source, windowState);
+ const { authority } = state;
+ const serverId = authority.replace(/^whiteboard\+/, "");
+
+ assert.match(authority, /^whiteboard\+[0-9a-f-]+$/, JSON.stringify(state));
+ const { serverId: hostServerId } = await hostState();
+
+ assert.ok(hostServerId, "the host reports no server id");
+ assert.equal(serverId, hostServerId.toLowerCase());
+ assert.equal(state.title, `${title} — Source — Whiteboard`);
+ assert.equal(await source.title(), state.title);
+ assert.match(
+ state.folder,
+ new RegExp(`^vscode-remote://${authority.replace("+", "%2B")}/`),
+ );
+ assert.equal(sourcePages(ctx).length, 1);
+ check(
+ ctx,
+ `2. Open file opened one Source window on ${authority} in ${timings.openFile} ms, titled "${state.title}"`,
+ );
+
+ // 3. The explorer lists the checkout.
+ await source.keyboard.press("ControlOrMeta+Shift+KeyE");
+
+ const explorer = await until(async () => {
+ const rows = await source
+ .locator(".explorer-folders-view .monaco-list-row")
+ .allInnerTexts()
+ .catch(() => []);
+
+ return ["docs", "added.ts", "f.ts"].every((name) =>
+ rows.some((row) => row.trim() === name),
+ )
+ ? rows
+ : null;
+ }, "the explorer to list docs, added.ts and f.ts");
+
+ check(
+ ctx,
+ `3. the explorer lists the checkout: ${explorer.map((r) => r.trim()).join(", ")}`,
+ );
+
+ // 4. Read-only: typing changes nothing and the notice shows.
+ await inSource(source, focusFile, state.active.resource);
+
+ const typed = await typeInto(source, ctx);
+
+ state = await inSource(source, windowState);
+ assert.ok(state.active.readonly, "f.ts is not read-only");
+ assert.ok(typed.message, "no read-only notice");
+ check(
+ ctx,
+ `4. f.ts is read-only: typing changed nothing and showed "${typed.message}"`,
+ );
+
+ // 5. Quick open answers from the host: f.ts, and needle.md, which no editor has open.
+ const quick = source.locator(".quick-input-widget input");
+
+ for (const query of ["f.ts", "needle"]) {
+ await source.keyboard.press("ControlOrMeta+KeyP");
+ await quick.waitFor({ state: "visible" });
+ await quick.fill(query);
+ await until(
+ async () =>
+ (await quickRows(source)).some((t) =>
+ t.includes(query === "needle" ? "needle.md" : "f.ts"),
+ ),
+ `quick open to find ${query}`,
+ 60000,
+ );
+ await source.keyboard.press("Escape");
+ await quick.waitFor({ state: "hidden" });
+ }
+
+ check(ctx, "5. quick open found f.ts, and needle.md, which was not open");
+
+ // 6. Text search.
+ await source.keyboard.press("ControlOrMeta+Shift+KeyF");
+ await until(
+ () =>
+ source.evaluate(
+ () => !!document.activeElement?.closest(".search-view .search-widget"),
+ ),
+ "focus in the search view's input",
+ );
+ await source.keyboard.press("ControlOrMeta+KeyA");
+ await source.keyboard.type("wbsource-needle");
+
+ const results = await until(async () => {
+ const text = await source
+ .locator(".search-view")
+ .innerText()
+ .catch(() => "");
+
+ return text.includes("needle.md") && text;
+ }, "needle.md in the search results");
+
+ check(
+ ctx,
+ `6. text search found wbsource-needle: "${results.replace(/\s+/g, " ").slice(0, 60)}…"`,
+ );
+
+ // 7. A TypeScript hover from the host's language server.
+ await inSource(source, focusFile, state.active.resource);
+
+ const hover = await pointerHover(
+ source,
+ await inSource(source, pointAt, 7, "one"),
+ /function one\(\): number/,
+ 90000,
+ );
+
+ assert.ok(hover.ms !== null, `no hover: ${JSON.stringify(hover.seen)}`);
+ check(
+ ctx,
+ `7. the hover on one read "function one(): number" after ${hover.ms} ms`,
+ );
+
+ // 8. A code action from the host's TypeScript runs from the window.
+ const action = await until(
+ () => inSource(source, codeAction, state.active.resource),
+ "TypeScript's Remove unused declaration code action",
+ 60000,
+ );
+
+ assert.equal(action.result, true, JSON.stringify(action));
+ assert.ok(action.diagnosticBefore, "no unused-declaration diagnostic");
+ assert.equal(action.diagnosticAfter, false, JSON.stringify(action));
+ check(
+ ctx,
+ `8. TypeScript's "${action.title}" (${action.command}) answered ${action.result} from the host and cleared its diagnostic`,
+ );
+
+ // 9. An added file's diff opens in the same window, its empty side the laptop's.
+ await inSource(ctx.page, openDiff, reviewId, "added.ts");
+
+ const diff = await until(
+ () => inSource(source, activeDiff),
+ "added.ts's diff in the Source window",
+ 60000,
+ );
+
+ assert.equal(diff.original.scheme, "vscode-userdata", JSON.stringify(diff));
+ assert.equal(diff.original.text, "");
+ assert.match(
+ diff.modified.uri,
+ new RegExp(
+ `^vscode-remote://${authority.replace("+", "%2B")}/.*/added\\.ts$`,
+ ),
+ );
+ assert.match(diff.modified.text, /wbsource-added/);
+ assert.deepEqual(sourcePages(ctx), [source]);
+ check(
+ ctx,
+ `9. added.ts's diff opened in the same window: its original side is ${diff.original.uri} (empty), its modified side the host's`,
+ );
+
+ // 10. An Ask file link opens the same window at its line.
+ await inSource(source, focusFile, state.active.resource);
+ await inSource(source, cursorLine, 1);
+ await ctx.page
+ .locator('[aria-label="Session views"] button[aria-label="Whiteboard"]')
+ .filter({ visible: true })
+ .click();
+
+ const link = await askLink(ctx, "where is `./f.ts:7`");
+
+ await link.click();
+ await until(
+ async () => {
+ const at = await inSource(source, cursorLine);
+
+ return at.resource?.endsWith("/f.ts") && at.line === 7;
+ },
+ "f.ts at line 7 in the Source window",
+ 60000,
+ );
+ assert.deepEqual(sourcePages(ctx), [source]);
+ check(
+ ctx,
+ `10. the Ask answer's ./f.ts:7 link opened f.ts at line 7 in the same Source window`,
+ );
+
+ // Extension-host RSS with one Source window, then two; checked at the end.
+ const rss = { one: await extensionHosts() };
+ const other = await openFile(second);
+
+ assert.equal(sourcePages(ctx).length, 2);
+ await until(
+ async () => (await extensionHosts()).length > rss.one.length,
+ "the second window's extension host",
+ 60000,
+ );
+ rss.two = await extensionHosts();
+ await other.keyboard.press("ControlOrMeta+Shift+KeyW");
+ await until(() => other.isClosed(), "the second Source window to close");
+
+ // 11–12. The host freezes: the window's status bar says so, then names the host again.
+ const offlineText = `${alias} offline`;
+
+ let thaw = await freeze();
+ const paused = Date.now();
+
+ await until(
+ async () => (await hostEntry(source)) === offlineText,
+ "the Source window's status bar to read offline",
+ 30000,
+ );
+ timings.offline = Date.now() - paused;
+ assert.ok(timings.offline <= 20000, `offline after ${timings.offline} ms`);
+ assert.equal((await inSource(source, windowStatus)).dialogs, 0);
+ check(
+ ctx,
+ `11. paused: the Source window's status bar read "${offlineText}" after ${timings.offline} ms, with no dialog`,
+ );
+
+ await thaw();
+
+ const resumed = Date.now();
+
+ await until(
+ async () =>
+ (await hostEntry(source)) === alias &&
+ (await inSource(source, readsCheckout).catch(() => false)),
+ "the Source window to reconnect",
+ 60000,
+ );
+ timings.reconnect = Date.now() - resumed;
+ assert.ok(
+ timings.reconnect <= 30000,
+ `reconnected after ${timings.reconnect} ms`,
+ );
+ check(
+ ctx,
+ `12. resumed: the status bar read "${alias}" again and the checkout answered after ${timings.reconnect} ms`,
+ );
+
+ // 13–14. A restart with the host paused: the restored window is offline, without a dialog, then reconnects.
+ thaw = await freeze();
+ await ctx.quitAndRelaunchDesktop();
+
+ const relaunched = Date.now();
+
+ const restored = await until(
+ async () => {
+ for (const page of ctx.browser.contexts().flatMap((c) => c.pages()))
+ if ((await page.title().catch(() => "")) === state.title) return page;
+
+ return null;
+ },
+ "the restored Source window",
+ 60000,
+ );
+
+ // The harness takes the first workbench page, which can be the restored Source window.
+ ctx.page = ctx.browser
+ .contexts()
+ .flatMap((c) => c.pages())
+ .find((page) => page !== restored && page.url().includes("workbench"));
+ await ctx.watchPage(ctx.page);
+
+ await until(
+ async () => (await hostEntry(restored)) === offlineText,
+ "the restored window's status bar to read offline",
+ 120000,
+ );
+ timings.restoredOffline = Date.now() - relaunched;
+ assert.equal(
+ (await inSource(restored, windowStatus)).dialogs,
+ 0,
+ "a dialog in the restored window",
+ );
+ check(
+ ctx,
+ `13. after a restart with ${alias} paused, the restored Source window's status bar read "${offlineText}" ${timings.restoredOffline} ms after the relaunch, with no dialog`,
+ );
+
+ await thaw();
+
+ const unpaused = Date.now();
+
+ await until(
+ async () =>
+ (await hostEntry(restored).catch(() => "")) === alias &&
+ (await lines(restored).catch(() => "")).includes("return one() + 1;") &&
+ (await inSource(restored, readsCheckout).catch(() => false)),
+ "the restored Source window to reconnect",
+ 90000,
+ );
+ timings.restoredReconnect = Date.now() - unpaused;
+ assert.ok(
+ timings.restoredReconnect <= 30000,
+ `reconnected after ${timings.restoredReconnect} ms`,
+ );
+ check(
+ ctx,
+ `14. resumed: the restored window's status bar read "${alias}" and it showed f.ts from the host ${timings.restoredReconnect} ms later`,
+ );
+
+ // 15. Removing the host closes its window.
+ await removeHost(ctx, alias);
+ await until(() => restored.isClosed(), "the Source window to close", 30000);
+ assert.deepEqual(sourcePages(ctx), []);
+ check(ctx, `15. removing ${alias} in Settings closed its Source window`);
+
+ // 16. The host's extension hosts with one Source window, then two.
+ const total = (hosts) => mib(hosts.reduce((sum, [, kib]) => sum + kib, 0));
+ const extra = rss.two.filter(([pid]) => !rss.one.some(([p]) => p === pid));
+
+ check(
+ ctx,
+ `16. extension hosts on ${alias}: the review window and one Source window ${rss.one.length} processes, ${rss.one.map(([, kib]) => `${mib(kib)} MiB`).join(" + ")} = ${total(rss.one)} MiB; two Source windows ${rss.two.length}, ${total(rss.two)} MiB (the second window's ${extra.map(([, kib]) => `${mib(kib)} MiB`).join(", ")})`,
+ );
+}
+
+// The functions below run in a window.
+
+/** TypeScript's quick fix for the unused declaration on line 6, run as a click would run it. */
+async function codeAction({ get, imp }, resource) {
+ const [commands, markers] = await Promise.all([
+ get("vs/platform/commands/common/commands.js", "ICommandService"),
+ get("vs/platform/markers/common/markers.js", "IMarkerService"),
+ ]);
+
+ const { URI } = await imp("vs/base/common/uri.js");
+ const uri = URI.parse(resource);
+
+ const unused = () =>
+ markers
+ .read({ resource: uri })
+ .find((m) => m.startLineNumber === 6 && /never read/.test(m.message));
+
+ const diagnostic = unused();
+
+ if (!diagnostic) return null;
+
+ const actions = await commands.executeCommand(
+ "_executeCodeActionProvider",
+ uri,
+ {
+ startLineNumber: diagnostic.startLineNumber,
+ startColumn: diagnostic.startColumn,
+ endLineNumber: diagnostic.endLineNumber,
+ endColumn: diagnostic.endColumn,
+ },
+ );
+
+ const action = actions?.find((a) =>
+ /^Remove unused declaration/.test(a.title),
+ );
+
+ if (!action?.command) return null;
+
+ const result = await commands.executeCommand(
+ action.command.id,
+ ...(action.command.arguments ?? []),
+ );
+
+ for (let frame = 0; unused() && frame < 120; frame++)
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+
+ return {
+ title: action.title,
+ command: action.command.id,
+ result,
+ diagnosticBefore: diagnostic.message,
+ diagnosticAfter: !!unused(),
+ };
+}
+
+/** The canvas bridge's openDiff verb for `path`, on the review's current view. */
+async function openDiff({ get, imp }, reviewId, path) {
+ const { resolveReviewSourceView } = await imp(
+ "vs/review/common/reviewProtocol.js",
+ );
+
+ const source = await get(
+ "vs/review/services/reviewApiSourceService.js",
+ "IReviewApiSourceService",
+ );
+
+ const snapshot = await source.read(reviewId, "", { full: "true" });
+
+ await source.openDiff(resolveReviewSourceView(snapshot), path);
+
+ return true;
+}
+
+async function activeDiff({ get }) {
+ const editors = await get(
+ "vs/workbench/services/editor/common/editorService.js",
+ "IEditorService",
+ );
+
+ const control = editors.activeTextEditorControl;
+ const model = control?.getModel?.();
+
+ if (!model?.original || !model?.modified) return null;
+
+ if (!model.modified.uri.path.endsWith("/added.ts")) return null;
+
+ return {
+ original: {
+ scheme: model.original.uri.scheme,
+ uri: model.original.uri.toString(),
+ text: model.original.getValue(),
+ },
+ modified: {
+ uri: model.modified.uri.toString(),
+ text: model.modified.getValue(),
+ },
+ };
+}
+
+/** Moves the cursor to `line` when given; returns the active file and line. */
+async function cursorLine({ get }, line) {
+ const editor = (
+ await get(
+ "vs/editor/browser/services/codeEditorService.js",
+ "ICodeEditorService",
+ )
+ ).getActiveCodeEditor();
+
+ if (line) editor?.setPosition({ lineNumber: line, column: 1 });
+
+ return {
+ resource: editor?.getModel()?.uri.toString(),
+ line: editor?.getPosition()?.lineNumber,
+ };
+}
+
+async function readsCheckout({ get }) {
+ const [files, workspace] = await Promise.all([
+ get("vs/platform/files/common/files.js", "IFileService"),
+ get(
+ "vs/platform/workspace/common/workspace.js",
+ "IWorkspaceContextService",
+ ),
+ ]);
+
+ const folder = workspace.getWorkspace().folders[0].uri;
+ const stat = await files.resolve(folder);
+
+ return stat.children?.some((child) => child.name === "f.ts") ?? false;
+}
+
+/** Open dialogs, native or not, and the window's text. */
+async function windowStatus({ get }) {
+ const dialogs = await get(
+ "vs/platform/dialogs/common/dialogs.js",
+ "IDialogService",
+ );
+
+ return {
+ dialogs: dialogs.model.dialogs.length,
+ body: document.body.innerText,
+ };
+}
+
+/** Opens Ask on the review's sentence, asks `question`, and returns the answer's link for its file. */
+async function askLink(ctx, question) {
+ const panel = askPanel(ctx);
+
+ await askAboutReviewSentence(ctx);
+
+ const composer = panel.getByRole("combobox", { name: "Question" });
+
+ await composer.waitFor({ timeout: 30000 });
+ await composer.fill(question);
+ await panel.getByRole("button", { name: "Ask", exact: true }).click();
+
+ const link = panel.getByRole("link").filter({ hasText: /^\.\/f\.ts:7$/ });
+
+ await link.waitFor({ timeout: 30000 });
+
+ return link;
+}
diff --git a/apps/review-desktop/scripts/e2e/remote/aws-hosts.mjs b/apps/review-desktop/scripts/e2e/remote/aws-hosts.mjs
index 573a9e6ce..2d546a83c 100644
--- a/apps/review-desktop/scripts/e2e/remote/aws-hosts.mjs
+++ b/apps/review-desktop/scripts/e2e/remote/aws-hosts.mjs
@@ -1,6 +1,6 @@
/** Hosts on AWS: `aws-up`, and removing a run's instances, key pair and security groups. */
import { addOnce, aws, run, sleep } from "./exec.mjs";
-import { waitForSsh } from "./ssh.mjs";
+import { installFakeAgent, waitForSsh } from "./ssh.mjs";
const maxInstances = 2;
@@ -221,6 +221,9 @@ export async function awsUp(runState, name, options) {
60,
"test -f /run/systemd/shutdown/scheduled",
);
+
+ if (options["fake-agent"]) await installFakeAgent(runState, host);
+
console.log(alias);
}
diff --git a/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs b/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs
index d4737ddcf..d74934e18 100644
--- a/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs
+++ b/apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs
@@ -8,7 +8,7 @@ import { remoteDevInstall } from "../../remote-dev-install.mjs";
import { addOnce, docker, exec, run, sleep } from "./exec.mjs";
import { hostOf } from "./run-state.mjs";
-import { waitForSsh } from "./ssh.mjs";
+import { installFakeAgent, waitForSsh } from "./ssh.mjs";
const imageDir = path.join(import.meta.dirname, "image");
@@ -122,6 +122,9 @@ export async function up(runState, name, options) {
if (jump && port) throw new Error("--port and --jump cannot be combined");
+ if (options["fake-agent"] && auth !== "key")
+ throw new Error("--fake-agent needs --auth key");
+
const toolchain = options.toolchain ?? "none";
if (toolchain !== "none" && !TOOLCHAIN_IMAGES[toolchain])
@@ -286,6 +289,8 @@ export async function up(runState, name, options) {
: [],
);
+ if (options["fake-agent"]) await installFakeAgent(runState, host);
+
console.log(alias);
}
diff --git a/apps/review-desktop/scripts/e2e/remote/fake-agent/acp-agent.mjs b/apps/review-desktop/scripts/e2e/remote/fake-agent/acp-agent.mjs
new file mode 100644
index 000000000..3665aadb5
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/remote/fake-agent/acp-agent.mjs
@@ -0,0 +1,83 @@
+import { createInterface } from "node:readline";
+
+const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
+
+const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
+
+const notify = (method, params) => send({ jsonrpc: "2.0", method, params });
+
+const cwds = new Map();
+
+let toolCalls = 0;
+
+createInterface({ input: process.stdin }).on("line", (line) => {
+ if (!line.trim()) return;
+
+ const { id, method, params } = JSON.parse(line);
+
+ switch (method) {
+ case "initialize":
+ return reply(id, {
+ protocolVersion: 1,
+ agentCapabilities: {
+ loadSession: false,
+ promptCapabilities: { image: false },
+ },
+ authMethods: [],
+ });
+ case "session/new": {
+ const sessionId = `wb-test-${cwds.size + 1}`;
+
+ cwds.set(sessionId, params.cwd);
+
+ return reply(id, {
+ sessionId,
+ modes: {
+ currentModeId: "build",
+ availableModes: [
+ { id: "build", name: "Build" },
+ { id: "plan", name: "Plan" },
+ ],
+ },
+ });
+ }
+
+ case "session/set_mode":
+ return reply(id, {});
+ case "session/prompt": {
+ const { sessionId, prompt } = params;
+ const question = prompt.findLast((block) => block.type === "text")?.text;
+
+ notify("session/update", {
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: `You asked: ${question}. See \`f.ts\`.` },
+ },
+ });
+ notify("session/update", {
+ sessionId,
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: `t${++toolCalls}`,
+ title: "Read f.ts",
+ kind: "read",
+ status: "completed",
+ locations: [{ path: `${cwds.get(sessionId)}/f.ts`, line: 1 }],
+ },
+ });
+
+ return reply(id, { stopReason: "end_turn" });
+ }
+
+ case "session/cancel":
+ return;
+ default:
+ if (id !== undefined)
+ send({
+ jsonrpc: "2.0",
+ id,
+ error: { code: -32601, message: `unknown method ${method}` },
+ });
+ }
+});
diff --git a/apps/review-desktop/scripts/e2e/remote/fake-agent/opencode b/apps/review-desktop/scripts/e2e/remote/fake-agent/opencode
new file mode 100755
index 000000000..9ea3b4c7a
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/remote/fake-agent/opencode
@@ -0,0 +1,10 @@
+#!/bin/sh
+# wb-test: stands in for OpenCode. `opencode acp` speaks ACP; anything else prints a version.
+case "$1" in
+ acp)
+ node=$(command -v node)
+ [ -n "$node" ] || for candidate in /opt/whiteboard/whiteboard-remote/bin/node "$HOME"/.dev/whiteboard-remote/versions/*/bin/node; do [ -x "$candidate" ] && node=$candidate; done
+ [ -x "$node" ] || { echo "opencode (wb-test): no node on PATH or in a Whiteboard install" >&2; exit 127; }
+ exec "$node" "${0%/*}/acp-agent.mjs" ;;
+ *) echo "opencode 0.0.0-wb-test" ;;
+esac
diff --git a/apps/review-desktop/scripts/e2e/remote/remote.mjs b/apps/review-desktop/scripts/e2e/remote/remote.mjs
index 583c3bba5..100cdc02a 100644
--- a/apps/review-desktop/scripts/e2e/remote/remote.mjs
+++ b/apps/review-desktop/scripts/e2e/remote/remote.mjs
@@ -15,13 +15,13 @@ const usage = `usage: remote.mjs
up [--platform linux/amd64|linux/arm64] [--image ] [--node 24|20|none]
[--auth key|password] [--banner] [--shell bash|fish] [--jump ]
[--sealed] [--delay-ms ] [--no-forwarding] [--port ]
- [--toolchain rust|swift|dotnet]
+ [--toolchain rust|swift|dotnet] [--fake-agent]
install [--version ] [--commit ]
pack --out [--version ] [--arch x64|arm64]
ssh --
forward
pause | resume | logs
- aws-up [--arch x64|arm64] [--sealed]
+ aws-up [--arch x64|arm64] [--sealed] [--fake-agent]
down | down --all | down --every-run
verify-clean
@@ -49,6 +49,7 @@ async function main(argv) {
"no-forwarding": { type: "boolean" },
port: { type: "string" },
toolchain: { type: "string" },
+ "fake-agent": { type: "boolean" },
version: { type: "string" },
commit: { type: "string" },
out: { type: "string" },
diff --git a/apps/review-desktop/scripts/e2e/remote/source-window-helpers.mjs b/apps/review-desktop/scripts/e2e/remote/source-window-helpers.mjs
new file mode 100644
index 000000000..9024b584a
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/remote/source-window-helpers.mjs
@@ -0,0 +1,166 @@
+/** Helpers the Source-window journeys run against a Source window over CDP. */
+
+import assert from "node:assert/strict";
+
+/**
+ * Runs `fn` in the Source window with its services, reached through its
+ * extension service instance: the journey needs no hook in the product.
+ */
+export async function inSource(page, fn, ...args) {
+ const cdp = await page.context().newCDPSession(page);
+
+ try {
+ await cdp.send("Emulation.setFocusEmulationEnabled", { enabled: true });
+
+ const prototype = await cdp.send("Runtime.evaluate", {
+ expression:
+ 'import(globalThis._VSCODE_FILE_ROOT + "vs/workbench/services/extensions/electron-browser/nativeExtensionService.js").then((m) => m.NativeExtensionService.prototype)',
+ awaitPromise: true,
+ });
+
+ const { objects } = await cdp.send("Runtime.queryObjects", {
+ prototypeObjectId: prototype.result.objectId,
+ });
+
+ const result = await cdp.send("Runtime.callFunctionOn", {
+ objectId: objects.objectId,
+ functionDeclaration: `async function (args) {
+ // Subclass prototypes have the prototype in their chain too.
+ const service = [...this].reverse().find((s) => Object.hasOwn(s, "_instantiationService"));
+ const imp = (file) => import(globalThis._VSCODE_FILE_ROOT + file);
+ const get = async (file, id) => {
+ const module = await imp(file);
+ return service._instantiationService.invokeFunction((a) => a.get(module[id]));
+ };
+ const value = await (${fn.toString()})({ service, get, imp }, ...args);
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+ }`,
+ arguments: [{ value: args }],
+ awaitPromise: true,
+ returnByValue: true,
+ });
+
+ if (result.exceptionDetails)
+ throw new Error(
+ result.exceptionDetails.exception?.description ??
+ result.exceptionDetails.text,
+ );
+
+ return result.result.value;
+ } finally {
+ await cdp.detach().catch(() => {});
+ }
+}
+
+/** Types into f.ts; returns the read-only notice, after checking the text did not change. */
+export async function typeInto(source, ctx) {
+ const before = await inSource(source, fileText);
+
+ await source.locator(".monaco-editor .view-lines").first().click();
+ await source.keyboard.type("x");
+
+ const message = await ctx.until(
+ () =>
+ source.evaluate(() =>
+ [...document.querySelectorAll(".monaco-editor-overlaymessage")]
+ .map((e) => e.innerText.trim())
+ .find(Boolean),
+ ),
+ "the read-only notice",
+ 10000,
+ );
+
+ assert.equal(await inSource(source, fileText), before, "typing changed f.ts");
+
+ return { message };
+}
+
+export async function quickRows(page) {
+ return page
+ .locator(".quick-input-list .monaco-list-row")
+ .allInnerTexts()
+ .catch(() => []);
+}
+
+// The functions below run in the Source window.
+
+export async function windowState({ get }) {
+ const [editors, workspace, environment] = await Promise.all([
+ get(
+ "vs/workbench/services/editor/common/editorService.js",
+ "IEditorService",
+ ),
+ get("vs/platform/workspace/common/workspace.js", "IWorkspaceContextService"),
+ get(
+ "vs/workbench/services/environment/common/environmentService.js",
+ "IWorkbenchEnvironmentService",
+ ),
+ ]);
+
+ return {
+ authority: environment.remoteAuthority,
+ title: document.title,
+ folder: workspace.getWorkspace().folders[0]?.uri.toString(),
+ active: {
+ resource: editors.activeEditor?.resource?.toString(),
+ readonly: !!editors.activeEditor?.isReadonly(),
+ },
+ };
+}
+
+export async function fileText({ get }) {
+ const models = await get(
+ "vs/editor/common/services/model.js",
+ "IModelService",
+ );
+
+ return models
+ .getModels()
+ .find((m) => m.uri.scheme === "vscode-remote" && m.uri.path.endsWith("/f.ts"))
+ ?.getValue();
+}
+
+export async function focusFile({ get, imp }, resource) {
+ const editors = await get(
+ "vs/workbench/services/editor/common/editorService.js",
+ "IEditorService",
+ );
+
+ const { URI } = await imp("vs/base/common/uri.js");
+
+ await editors.openEditor({
+ resource: URI.parse(resource),
+ options: { pinned: true },
+ });
+
+ return true;
+}
+
+export async function pointAt({ get }, line, word) {
+ const editor = (
+ await get("vs/editor/browser/services/codeEditorService.js", "ICodeEditorService")
+ ).getActiveCodeEditor();
+
+ const position = {
+ lineNumber: line,
+ column: editor.getModel().getLineContent(line).indexOf(word) + 2,
+ };
+
+ editor.revealLineInCenter(line);
+
+ let at = editor.getScrolledVisiblePosition(position);
+
+ for (let frame = 0; !at && frame < 120; frame++) {
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ at = editor.getScrolledVisiblePosition(position);
+ }
+
+ if (!at) throw new Error(`line ${line} never rendered`);
+
+ const rect = editor.getDomNode().getBoundingClientRect();
+
+ return {
+ x: Math.round(rect.left + at.left),
+ y: Math.round(rect.top + at.top + at.height / 2),
+ };
+}
diff --git a/apps/review-desktop/scripts/e2e/remote/ssh.mjs b/apps/review-desktop/scripts/e2e/remote/ssh.mjs
index d28c5e956..67570fafe 100644
--- a/apps/review-desktop/scripts/e2e/remote/ssh.mjs
+++ b/apps/review-desktop/scripts/e2e/remote/ssh.mjs
@@ -145,3 +145,21 @@ export async function stopTunnels(runState, name) {
state.forwards = state.forwards.filter((f) => name && f.host !== name);
await runState.save();
}
+
+/** Puts the fake OpenCode in ~/.opencode/bin, where Ask finds it. */
+export async function installFakeAgent(runState, host) {
+ const dir = path.join(import.meta.dirname, "fake-agent");
+
+ await run("ssh", [...sshArgs(runState, host), "mkdir -p ~/.opencode/bin"]);
+ await run("scp", [
+ "-F",
+ `${runState.dir}/ssh_config`,
+ path.join(dir, "opencode"),
+ path.join(dir, "acp-agent.mjs"),
+ `${host.alias}:.opencode/bin/`,
+ ]);
+ await run("ssh", [
+ ...sshArgs(runState, host),
+ "chmod 755 ~/.opencode/bin/opencode ~/.opencode/bin/acp-agent.mjs",
+ ]);
+}
diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md
index 11b387ed3..4ccc33f56 100644
--- a/docs/remote-hosts.md
+++ b/docs/remote-hosts.md
@@ -56,9 +56,11 @@ never stored.
## How Desktop installs Whiteboard
-Desktop first checks the host and writes nothing. If the host lacks Desktop's
-version, Desktop asks once, then installs it as your user. The host downloads
-it from `install.dev.fast` and checks its pinned checksum before unpacking.
+Desktop first checks the host and writes nothing. If the host has no
+Whiteboard, Desktop asks once, then installs as your user. A host with a
+version Desktop installed gets Desktop's new version without a question. The
+host downloads it from `install.dev.fast` and checks its pinned checksum before
+unpacking.
You do not need to start anything. Desktop starts the review server when it
connects. On a remote with no Desktop, `whiteboard api` starts one too, so an
@@ -77,9 +79,10 @@ instead of `~/.dev`. The review server keeps reviews in `~/.dev`.
### Updates
-When Desktop updates, it installs its new version without asking. It restarts
-the review server it started. It keeps the previous version and any version
-still running. It leaves a server you started yourself running.
+When Desktop updates, it installs its new version without asking on a host
+where it installed Whiteboard before. It restarts the review server it started.
+It keeps the previous version and any version still running. It leaves a server
+you started yourself running.
### The setting
@@ -88,7 +91,7 @@ not have its version:
| Value | What Desktop does |
|---|---|
-| `ask` (the default) | Asks once per host. |
+| `ask` (the default) | Asks once per host, unless Desktop installed Whiteboard there before. |
| `always` | Installs without asking. |
| `never` | Never installs, and uses the `whiteboard` on the host's `PATH`. |
@@ -117,6 +120,14 @@ Once a host is `online`, Settings lists its unconnected agents: Claude Code,
Codex, OpenCode and Pi. **Connect** runs the agent's own install commands on
the host.
+**Ask.** Ask works on a review that lives on a host. The agent runs on the
+host, in the review's checkout, with the agents and sign-ins already there
+(`claude auth login`, `codex login`, `opencode auth login`,
+`cursor-agent login`, Pi's `/login`). Threads are kept on the host, so every
+laptop that connects to it sees the same history for its reviews. A file that
+an answer names opens in a Source window on the host; see
+[Source windows](#source-windows).
+
## Remove Whiteboard from a host
In Settings, click **Remove** on the host, check **Also remove Whiteboard from
@@ -213,9 +224,25 @@ login shell (`~/.profile` or your shell's own start-up file is enough):
The C# extension downloads its OmniSharp server from Microsoft the first
time it starts.
+## Source windows
+
+"Open file", the source tree and Ask's file links open a read-only Source
+window on the host, with search and language features.
+
+When the host stops answering, the status bar says so and offers Retry. The
+window reconnects when the host is back.
+
+Not available in a Source window:
+
+- Terminals and source control.
+- Live file updates on musl hosts such as Alpine. The explorer refreshes when
+ the window gets focus.
+
+Each Source window runs its own extension host on the host. Close the ones you
+are done with.
+
## Not available for remote reviews yet
-- Source windows: "Open file" and the source tree are hidden.
- Sharing.
- Traces.
- Scratchpads. The scratchpad is always the laptop's.
diff --git a/packages/review-protocol/scripts/generate-native-source.mjs b/packages/review-protocol/scripts/generate-native-source.mjs
index ff78db185..f072630da 100644
--- a/packages/review-protocol/scripts/generate-native-source.mjs
+++ b/packages/review-protocol/scripts/generate-native-source.mjs
@@ -123,6 +123,7 @@ const MODULE_PATHS = [
path.join(sourceRoot, "structural-diff.ts"),
path.join(sourceRoot, "source-alignment.ts"),
path.join(sourceRoot, "contracts.ts"),
+ path.join(sourceRoot, "review-host-status.ts"),
path.join(sourceRoot, "code-peek-diff.ts"),
path.join(sourceRoot, "review-api-client.ts"),
path.join(sourceRoot, "index.ts"),
diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts
index 61eb359e7..b8d3f7174 100644
--- a/packages/review-protocol/src/contracts.ts
+++ b/packages/review-protocol/src/contracts.ts
@@ -590,6 +590,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[];
@@ -755,7 +763,9 @@ export type ReviewCanvasContent =
reviewId: string;
version?: number;
host?: string;
+ hostState?: ReviewApiSummary["hostState"];
available?: ReviewApiSummary["available"];
+ remoteHosts?: ReviewRemoteHostActions;
bridge: ReviewCanvasBridge;
setTitle?(title: string): void;
setSourceView?(
@@ -790,7 +800,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;
@@ -986,6 +996,14 @@ export interface ReviewGatewayHostState {
declined?: true;
}
+/** `emptySide`: the diff side has no file; open one locally. */
+export interface ReviewRemoteNavigatorAnswer {
+ workspacePath: string;
+ filePath?: string;
+ serverId: string;
+ emptySide?: true;
+}
+
export const ReviewRepositoryIdentitySchema = z.strictObject({
kind: z.enum(["git", "jj", "none"], {
error: "must be git, jj, or none",
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/native-source.test.ts b/packages/review-protocol/src/native-source.test.ts
index 0e233f5be..7e9f7f487 100644
--- a/packages/review-protocol/src/native-source.test.ts
+++ b/packages/review-protocol/src/native-source.test.ts
@@ -79,6 +79,7 @@ describe("native Review Protocol source generation", () => {
"structural-diff.ts",
"source-alignment.ts",
"contracts.ts",
+ "review-host-status.ts",
"code-peek-diff.ts",
"review-api-client.ts",
"index.ts",
@@ -166,6 +167,7 @@ describe("native Review Protocol source generation", () => {
await writeFile(path.join(sourceRoot, "runtime-value.ts"), "");
await writeFile(path.join(sourceRoot, "json.ts"), "");
await writeFile(path.join(sourceRoot, "trace-contracts.ts"), "");
+ await writeFile(path.join(sourceRoot, "review-host-status.ts"), "");
await writeFile(path.join(sourceRoot, "code-peek-diff.ts"), "");
await writeFile(path.join(sourceRoot, "review-api-client.ts"), "");
await writeFile(
diff --git a/packages/review-protocol/src/review-api-client.ts b/packages/review-protocol/src/review-api-client.ts
index dfd290424..d95b912e5 100644
--- a/packages/review-protocol/src/review-api-client.ts
+++ b/packages/review-protocol/src/review-api-client.ts
@@ -51,7 +51,11 @@ export interface ReviewApiSummary {
working?: boolean;
host?: string;
hostState?: Exclude;
- available?: { sourceWindows: boolean; languageFeatures: boolean };
+ available?: {
+ sourceWindows: boolean;
+ /** Why they are off: the host's sentence, or its language features detail. */
+ reason?: string;
+ };
}
/**
@@ -206,11 +210,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 +230,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 +349,7 @@ class LiveConnection {
}, restartDelayMs);
}
- private reconnect() {
+ reconnect() {
this.abort.abort();
this.abort = new AbortController();
const listeners = [...this.listeners];
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..da35e70aa
--- /dev/null
+++ b/packages/review-protocol/src/review-host-status.ts
@@ -0,0 +1,76 @@
+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;
+}
+
+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",
+ label: (alias) => `Connecting to ${alias}…`,
+ busy: true,
+ },
+ installing: {
+ status: "installing",
+ label: (alias) => `Installing on ${alias}…`,
+ busy: true,
+ },
+ offline: OFFLINE,
+ unreachable: OFFLINE,
+ 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: UNUSABLE,
+ duplicate: UNUSABLE,
+};
+
+/** 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/app/src/App.tsx b/packages/review/app/src/App.tsx
index 7783ad158..eff65b9f3 100644
--- a/packages/review/app/src/App.tsx
+++ b/packages/review/app/src/App.tsx
@@ -295,12 +295,21 @@ function ReviewLayoutContent({
// The scratchpad is a document and nothing else: no source tree to browse,
// nothing to share, nothing to dismiss.
const scratchpad = session.review?.kind === "scratchpad";
- const sourceWindows = session.review?.available?.sourceWindows !== false;
+ const available = session.review?.available;
+ // A remote review keeps Source tree, disabled with the reason.
+ const sourceWindows = available?.sourceWindows !== false;
+ const sourceTreeShown = sourceWindows || !!session.review?.host;
useEffect(() => {
if (scratchpad) captureUiEvent(session, "scratchpad_opened");
}, [scratchpad, session]);
const discordTooltip = useTooltip("Join our Discord community");
- const sourceTreeTooltip = useTooltip("Open full read-only source");
+
+ const sourceTreeTooltip = useTooltip(
+ sourceWindows
+ ? "Open full read-only source"
+ : (available?.reason ?? "Source windows are unavailable."),
+ );
+
const panelStore = useReviewPanelStore();
useSuppressPanelMotionOnCanvasResume(appRef);
const activePanel = useReviewPanel((state) => state.active);
@@ -614,20 +623,21 @@ function ReviewLayoutContent({
shellStyles.topbarContext,
)}
>
- {!scratchpad && sourceWindows && (
+ {!scratchpad && sourceTreeShown && (
{
captureUiEvent(session, "source_tree_opened", {
via: "topbar",
});
- session.surface.post({
- name: "openSourceTree",
- args: {},
- });
+ // The desktop already showed why it failed.
+ session.surface
+ .post({ name: "openSourceTree", args: {} })
+ .catch(() => undefined);
}}
>
diff --git a/packages/review/app/src/api-canvas.test.tsx b/packages/review/app/src/api-canvas.test.tsx
index 219167dbf..68f57c3dd 100644
--- a/packages/review/app/src/api-canvas.test.tsx
+++ b/packages/review/app/src/api-canvas.test.tsx
@@ -6,6 +6,7 @@ import path from "node:path";
import type {
JsonObject,
+ ReviewApiSummary,
ReviewCanvasBridge,
ReviewDiffFileWire,
ReviewDiffViewSpec,
@@ -1292,9 +1293,176 @@ 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(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.",
+ );
+ 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; languageFeatures: boolean };
+ available?: ReviewApiSummary["available"];
}) {
const review = await command({
type: "create",
@@ -1387,19 +1555,25 @@ async function mountPeekReview(content: {
const container = document.createElement("div");
document.body.append(container);
+
+ const mounted = {
+ kind: "api" as const,
+ reviewId: review.reviewId,
+ bridge,
+ setSourceView: () => {},
+ ...content,
+ };
+
await act(async () => {
- canvas = mount(container, {
- kind: "api",
- reviewId: review.reviewId,
- bridge,
- setSourceView: () => {},
- ...content,
- });
+ canvas = mount(container, mounted);
});
await act(async () => {
await vi.waitFor(() => expect(inline).toHaveLength(1));
});
+ const setAvailable = (available: (typeof content)["available"]) =>
+ act(async () => canvas!.update({ ...mounted, available }));
+
const button = (label: string) =>
[...container.querySelectorAll("button")].find(
(candidate) =>
@@ -1407,13 +1581,13 @@ async function mountPeekReview(content: {
candidate.getAttribute("aria-label") === label,
);
- return { container, inline, diffs, requested, button };
+ return { container, inline, diffs, requested, button, setAvailable };
}
it.each([
{ available: undefined, shown: true },
{
- available: { sourceWindows: false, languageFeatures: false },
+ available: { sourceWindows: false },
shown: false,
},
])(
@@ -1431,6 +1605,39 @@ it.each([
},
);
+it("keeps Source tree on a remote review whose Source windows are off, disabled with the reason", async () => {
+ const { button } = await mountPeekReview({
+ host: "devbox",
+ available: {
+ sourceWindows: false,
+ reason: "devbox offline.",
+ },
+ });
+
+ expect(button("Source tree ↗")?.disabled).toBe(true);
+ expect(button("Source tree ↗")?.title).toBe("devbox offline.");
+});
+
+it("keeps the open peeks and Diff view when the host's source windows go away and return", async () => {
+ const on = { sourceWindows: true };
+
+ const { inline, diffs, button, setAvailable } = await mountPeekReview({
+ host: "devbox",
+ available: on,
+ });
+
+ await act(async () => button("Diff")!.click());
+ await act(async () => {
+ await vi.waitFor(() => expect(diffs).toHaveLength(1));
+ });
+
+ await setAvailable({ sourceWindows: false });
+ await setAvailable(on);
+
+ expect(inline).toHaveLength(1);
+ expect(diffs).toHaveLength(1);
+});
+
it.each([
{ host: undefined, shown: true },
{ host: "devbox", shown: false },
@@ -1442,7 +1649,7 @@ it.each([
const { button, requested } = await mountPeekReview({
host,
...(host && {
- available: { sourceWindows: false, languageFeatures: false },
+ available: { sourceWindows: false },
}),
});
diff --git a/packages/review/app/src/api-canvas.tsx b/packages/review/app/src/api-canvas.tsx
index 4ed421f0b..23ef27f24 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 {
@@ -47,6 +48,7 @@ import {
createReviewSession,
useReviewSession,
} from "./host/review-session";
+import { checkoutUnavailable } from "./missing-checkout-banner";
import { ReviewDocumentBoundary } from "./review-document-boundary";
import { reportReviewDocumentRenderError } from "./review-document-error-report";
import type { ReviewFindHost } from "./review-find";
@@ -112,11 +114,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 +179,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 +242,7 @@ export function ApiCanvas({
return;
}
- setError(
- `Connection lost. Reconnecting… ${cause instanceof Error ? cause.message : ""}`,
- );
+ setLost(cause instanceof Error ? cause.message : "");
},
);
})();
@@ -250,26 +253,31 @@ export function ApiCanvas({
};
}, [client, content.reviewId, version]);
- const openFile = content.available?.sourceWindows !== false;
+ // Read at create, so a host going offline keeps the open editors.
+ const openFile = useRef(true);
+
+ openFile.current = content.available?.sourceWindows !== false;
const nativeSources = useMemo(() => {
const { inlineEditors, diffView } = content.bridge;
- return openFile
- ? { inlineEditors: { ...inlineEditors }, diffView: { ...diffView } }
- : {
- inlineEditors: {
- ...inlineEditors,
- create: (spec: ReviewInlineEditorSpec) =>
- inlineEditors.create({ ...spec, onDidOpen: undefined }),
- },
- diffView: {
- ...diffView,
- create: (spec: ReviewDiffViewSpec) =>
- diffView.create({ ...spec, openFile: false }),
- },
- };
- }, [content.bridge, sourceVersion, content.structuralDiffEnabled, openFile]);
+ return {
+ inlineEditors: {
+ ...inlineEditors,
+ create: (spec: ReviewInlineEditorSpec) =>
+ inlineEditors.create(
+ openFile.current ? spec : { ...spec, onDidOpen: undefined },
+ ),
+ },
+ diffView: {
+ ...diffView,
+ create: (spec: ReviewDiffViewSpec) =>
+ diffView.create(
+ openFile.current ? spec : { ...spec, openFile: false },
+ ),
+ },
+ };
+ }, [content.bridge, sourceVersion, content.structuralDiffEnabled]);
const baseSession = useMemo(() => {
const bridge = {
@@ -314,6 +322,8 @@ export function ApiCanvas({
kind: snapshot.kind,
trashedAt: snapshot.trashedAt,
host: content.host,
+ hostState: content.hostState,
+ hosts: content.remoteHosts,
available: content.available,
pins: snapshot.pins
? { base: snapshot.pins.base, head: snapshot.pins.head }
@@ -369,6 +379,8 @@ export function ApiCanvas({
client,
content.reviewId,
content.host,
+ content.hostState,
+ content.remoteHosts,
content.available,
data,
version,
@@ -462,16 +474,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 && (
setVersion(undefined)}>
@@ -479,7 +514,7 @@ export function ApiCanvas({
)}
>
- )}
+ ) : null}
);
@@ -520,41 +555,43 @@ export function ApiCanvas({
)}
{trashBanner}
-
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
@@ -578,6 +615,7 @@ const CanvasDocument = memo(function CanvasDocument({
softwareMapEnabled: boolean;
documentWidth?: ReviewDocumentWidthChoice;
}) {
+ const host = useReviewSession().review?.host;
const snapshot = data.snapshot;
const document: RenderedReviewDocument = {
@@ -615,7 +653,7 @@ const CanvasDocument = memo(function CanvasDocument({
// Commits views hide, as for a review whose base is its head.
range={{
sourceUnavailable: snapshot.sourceUnavailable
- ? "Local checkout unavailable."
+ ? checkoutUnavailable(host)
: undefined,
baseRef: snapshot.pins?.base ?? "",
headRef: snapshot.pins?.head ?? "",
diff --git a/packages/review/app/src/ask-agent-picker.tsx b/packages/review/app/src/ask-agent-picker.tsx
index aa8dada21..a289724d8 100644
--- a/packages/review/app/src/ask-agent-picker.tsx
+++ b/packages/review/app/src/ask-agent-picker.tsx
@@ -37,6 +37,7 @@ import { textStyles } from "./ui/text";
import { fieldStyles } from "./ui/text-field";
import { useAnchoredPopover } from "./use-anchored-popover";
import { useDismissOnOutside } from "./use-dismiss-on-outside";
+import { useTooltip } from "./use-tooltip";
const agentsSchema = z.object({
agents: z.array(
@@ -63,26 +64,37 @@ export const logos: Record<
// request under way, which the toolbar and the panel share.
const knownAgents = new WeakMap();
-const askingAgents = new WeakMap>();
+// Undefined: the host could not say now.
+const askingAgents = new WeakMap<
+ ReviewSession,
+ Promise
+>();
-function askAgentsOf(session: ReviewSession): Promise {
+function askAgentsOf(
+ session: ReviewSession,
+): Promise {
let request = askingAgents.get(session);
if (!request) {
request = session
.fetch("/ask/agents")
- .then(async (response) =>
- response.ok ? agentsSchema.parse(await response.json()).agents : null,
+ .then(async (response) => {
+ // A host without Ask refuses the request; one that is down fails it.
+ if (response.status >= 500) throw new Error(response.statusText);
+
+ return response.ok
+ ? agentsSchema.parse(await response.json()).agents
+ : null;
+ })
+ .then(
+ (agents) => {
+ knownAgents.set(session, agents);
+
+ return agents;
+ },
+ () => knownAgents.get(session),
)
- // A check that fails keeps the last answer; a host without Ask refuses
- // the request instead.
- .catch(() => knownAgents.get(session) ?? null)
- .then((agents) => {
- knownAgents.set(session, agents);
- askingAgents.delete(session);
-
- return agents;
- });
+ .finally(() => askingAgents.delete(session));
askingAgents.set(session, request);
}
@@ -90,8 +102,9 @@ function askAgentsOf(session: ReviewSession): Promise {
}
/** Which local agents can answer, or null where this host has no Ask. Asked
- * again each time Ask opens and each time the review comes back into focus,
- * so an agent installed during the review shows up. */
+ * again each time Ask opens, each time the review comes back into focus and
+ * each time its host changes state, so an agent installed during the review
+ * shows up. A host that cannot answer now keeps the last answer. */
export function useAskAgents(session: ReviewSession | null): AskAgent[] | null {
const [agents, setAgents] = useState(() =>
session ? (knownAgents.get(session) ?? null) : null,
@@ -103,7 +116,7 @@ export function useAskAgents(session: ReviewSession | null): AskAgent[] | null {
const refresh = () => {
void askAgentsOf(session).then((value) => {
- if (current) setAgents(value);
+ if (current && value !== undefined) setAgents(value);
});
};
@@ -393,25 +406,32 @@ export function AskAgentPicker({
agents,
agent,
locked,
+ unavailable,
onPick,
}: {
agents: AskAgent[] | null;
agent: AskAgentId | undefined;
locked: boolean;
+ unavailable?: string;
onPick: (agent: AskAgentId) => void;
}): ReactElement {
const [open, setOpen] = useState(false);
const anchor = useRef(null);
const trigger = useRef(null);
const dismiss = useMenuClose(setOpen, trigger);
+ const hint = useTooltip(unavailable ?? "");
const chosen = agents?.find((candidate) => candidate.id === agent);
if (
locked ||
+ unavailable !== undefined ||
agents?.filter((candidate) => candidate.available).length === 1
) {
return (
-
+
{agent ? logos[agent]({}) : null}
{chosen?.name ?? agent ?? "Agent"}
diff --git a/packages/review/app/src/ask-files.test.tsx b/packages/review/app/src/ask-files.test.tsx
index bf8b48495..20b1261d6 100644
--- a/packages/review/app/src/ask-files.test.tsx
+++ b/packages/review/app/src/ask-files.test.tsx
@@ -122,3 +122,65 @@ it("links the files an answer names to the Source window, once the checkout has
await act(async () => root.unmount());
});
+
+it("shows the files an answer names as text where Source windows are unavailable", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const post = vi.fn(async () => ({ ok: true }));
+ const session = testReviewSession({}, { post });
+ const fetch = vi.spyOn(session, "fetch");
+
+ session.review!.available = {
+ sourceWindows: false,
+ reason: "devbox offline.",
+ };
+
+ const entries: AgentEntry[] = [
+ { kind: "agent", id: "answer", text: "See `src/f.ts:12`." },
+ ];
+
+ const thread: AskThreadState = {
+ id: "thread",
+ agent: "codex",
+ agentName: "Codex",
+ status: "idle",
+ readOnly: true,
+ bypass: false,
+ head: "7fd03b8e2",
+ cwd: "/checkouts/whiteboard",
+ selection: { title: "remote review" },
+ entries,
+ };
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+
+ await act(async () =>
+ root.render(
+
+
+ null}
+ />
+
+ ,
+ ),
+ );
+
+ const code = [...container.querySelectorAll("code")].find(
+ (element) => element.textContent === "src/f.ts:12",
+ )!;
+
+ expect(code.closest("[title]")?.getAttribute("title")).toBe(
+ "devbox offline.",
+ );
+ expect(container.querySelector('[role="link"], a, button')).toBeNull();
+
+ await act(async () => code.click());
+ expect(fetch).not.toHaveBeenCalled();
+ expect(post).not.toHaveBeenCalled();
+
+ await act(async () => root.unmount());
+});
diff --git a/packages/review/app/src/ask-files.tsx b/packages/review/app/src/ask-files.tsx
index d5c5dac74..53ea2669f 100644
--- a/packages/review/app/src/ask-files.tsx
+++ b/packages/review/app/src/ask-files.tsx
@@ -141,16 +141,23 @@ function AskFileLink({
children: ReactNode;
}): ReactNode {
const files = useContext(AskFilesContext);
+ const available = useReviewSession().review?.available;
+ const canOpen = available?.sourceWindows !== false;
const { path, line } = fileRef;
- useEffect(() => files?.request(path), [files, path]);
+ useEffect(() => {
+ if (canOpen) files?.request(path);
+ }, [canOpen, files, path]);
const file = files?.lookup(path);
const tooltip = useTooltip(
`Open ${file}${line ? `:${line}` : ""}`,
);
- if (!files || !file) return fallback;
+ if (!canOpen && available?.reason)
+ return {fallback} ;
+
+ if (!canOpen || !files || !file) return fallback;
const open = () => files.open(file, line);
// A link, not a button: a button is one box, so a long path would jump to
diff --git a/packages/review/app/src/ask-panel.test.tsx b/packages/review/app/src/ask-panel.test.tsx
index 0a2b21914..7256bf703 100644
--- a/packages/review/app/src/ask-panel.test.tsx
+++ b/packages/review/app/src/ask-panel.test.tsx
@@ -10,7 +10,7 @@ import {
useShowOpenThread,
} from "./ask-delete";
import { AskHistoryProvider, useAskHistory } from "./ask-history";
-import { AskHistoryList } from "./ask-history-list";
+import { AskHistoryControl, AskHistoryList } from "./ask-history-list";
import { AskPanelContent, AskReadOnlyThread } from "./ask-panel";
import { TestCanvasQuery } from "./canvas-query-test-utils";
import { ReviewSessionProvider } from "./host/review-session";
@@ -1501,3 +1501,199 @@ it("shows a saved conversation whose checkout is gone, with nothing to ask", asy
vi.unstubAllGlobals();
}
});
+
+const twoAgents = () =>
+ Response.json({
+ agents: [
+ { id: "claude", name: "Claude Code", available: true },
+ { id: "codex", name: "Codex", available: true },
+ ],
+ });
+
+it("asks nothing while a remote review's host is down, and says why with its next step", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const session = testReviewSession();
+
+ const hosts = {
+ states: async () => [
+ { alias: "wb-a", state: "offline" as const, detail: "ssh timed out." },
+ ],
+ retry: vi.fn<(alias: string) => Promise>(async () => {}),
+ install: async () => {},
+ openSettings: async () => {},
+ };
+
+ session.review = {
+ ...session.review!,
+ host: "wb-a",
+ hostState: "offline",
+ hosts,
+ };
+
+ const fetch = vi
+ .spyOn(session, "fetch")
+ .mockImplementation(async (endpoint) =>
+ endpoint === "/ask/agents"
+ ? twoAgents()
+ : Response.json({ ok: true }, { status: 404 }),
+ );
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+
+ try {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const textarea = container.querySelector("textarea")!;
+
+ expect(textarea.disabled).toBe(true);
+ expect(container.querySelector('[role="status"]')?.textContent).toContain(
+ "wb-a offline · ssh timed out.",
+ );
+
+ await type(textarea, "Is this safe?");
+ await act(async () => textarea.form!.requestSubmit());
+ expect(fetch.mock.calls.some(([called]) => called === "/ask")).toBe(false);
+
+ await act(async () => buttonNamed(container, "Retry")!.click());
+ expect(hosts.retry).toHaveBeenCalledWith("wb-a");
+ } finally {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ }
+});
+
+it("offers to try again a question that did not go, and sends it again", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const session = testReviewSession();
+ let refused = false;
+
+ const fetch = vi
+ .spyOn(session, "fetch")
+ .mockImplementation(async (endpoint, init) => {
+ if (endpoint === "/ask/agents") return twoAgents();
+
+ if (endpoint === "/ask") {
+ if (refused) return Response.json({ threadId: "thread" });
+ refused = true;
+
+ return Response.json({ error: "Busy." }, { status: 500 });
+ }
+
+ if (endpoint === "/ask/watch") return new Response(new ReadableStream());
+
+ return Response.json({ ok: true }, { status: init?.method ? 200 : 404 });
+ });
+
+ const asked = () =>
+ fetch.mock.calls
+ .filter(([called]) => called === "/ask")
+ .map(([, init]) => JSON.parse(String(init?.body)).question);
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+
+ try {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const textarea = container.querySelector("textarea")!;
+
+ await type(textarea, "Is this safe?");
+ await enter(textarea);
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "Busy.",
+ );
+
+ await act(async () => buttonNamed(container, "Try again")!.click());
+ expect(asked()).toEqual([
+ { text: "Is this safe?" },
+ { text: "Is this safe?" },
+ ]);
+ expect(container.querySelector('[role="alert"]')).toBeNull();
+ expect(textarea.value).toBe("");
+ } finally {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ }
+});
+
+it("keeps Ask while its host cannot list agents, and lets an agent be picked again once the host is back", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const session = testReviewSession();
+ const header = document.createElement("div");
+ document.body.append(header);
+ let hostState: "online" | "offline" = "online";
+
+ vi.spyOn(session, "fetch").mockImplementation(async (endpoint) => {
+ if (endpoint === "/ask/agents")
+ return hostState === "online"
+ ? twoAgents()
+ : Response.json({ error: "wb-a is offline." }, { status: 503 });
+
+ return Response.json({ ok: true }, { status: 404 });
+ });
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+
+ const picker = () =>
+ header.querySelector('button[aria-haspopup="menu"]:not([aria-label])');
+
+ // The catalog hands the canvas a new session as the host changes state.
+ const render = async () =>
+ act(async () =>
+ root.render(
+
+
+
+
+
+ ,
+ ),
+ );
+
+ try {
+ await render();
+ expect(picker()).not.toBeNull();
+
+ hostState = "offline";
+ await render();
+ expect(picker()).toBeNull();
+ expect(header.textContent).toBe("Claude Code");
+ expect(buttonNamed(container, "Saved conversations")).not.toBeNull();
+
+ hostState = "online";
+ await render();
+ expect(picker()).not.toBeNull();
+ } finally {
+ await act(async () => root.unmount());
+ container.remove();
+ header.remove();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ }
+});
diff --git a/packages/review/app/src/ask-panel.tsx b/packages/review/app/src/ask-panel.tsx
index cf139e683..5549ebe6e 100644
--- a/packages/review/app/src/ask-panel.tsx
+++ b/packages/review/app/src/ask-panel.tsx
@@ -1,3 +1,7 @@
+import type {
+ ReviewHostStatus,
+ ReviewRemoteHostActions,
+} from "@dev.fast/review-protocol";
import type { AgentSelection } from "@review/agent-selection";
import {
type AskAgentId,
@@ -51,10 +55,12 @@ import { askPanelStyles } from "./ask-styles";
import { useLatest, useThread } from "./ask-thread-stream";
import { AskAgentTurn, AskWorking, turns } from "./ask-turn";
import { canvasQueryKeys } from "./canvas-query";
+import { useHostActionButton } from "./connection-chip";
import { controlStyles } from "./controls-styles";
import { formatRelativeTime } from "./format";
import { useReviewSession } from "./host/review-session";
import { ArrowUpIcon, ImageIcon, LockIcon } from "./icons";
+import { useReviewHostDown } from "./remote-host-state";
import { useAskKey, useOptionalReviewPanelStore } from "./review-panel";
import type { AskPresence, AskReport } from "./review-panel-model";
import { fontSize, radius } from "./scale.stylex";
@@ -110,6 +116,8 @@ export function AskPanelContent({
const [threadId, setThreadId] = useState(null);
const [sending, setSending] = useState(false);
const [requestError, setRequestError] = useState(null);
+ const [again, setAgain] = useState<() => void>();
+ const down = useReviewHostDown(session.review);
// Choices for a question not yet asked; a thread says its own.
const [picks, setPicks] = useState({});
const [bypassPick, setBypassPick] = useState();
@@ -234,6 +242,7 @@ export function AskPanelContent({
const canAsk =
!session.review?.trashedAt &&
Boolean(agent) &&
+ !down &&
(!busy || (answering && !sending));
const post = useCallback(
@@ -255,6 +264,11 @@ export function AskPanelContent({
[session],
);
+ const fail = useCallback((message: string, rerun: () => void) => {
+ setRequestError(message);
+ setAgain(() => rerun);
+ }, []);
+
const ask = async (question: AskQuestion) => {
if (!agent || !canAsk || thread?.status === "failed") return false;
setSending(true);
@@ -291,7 +305,10 @@ export function AskPanelContent({
return true;
} catch (error) {
- setRequestError(error instanceof Error ? error.message : String(error));
+ // The question is back in the composer; sending it again sends that.
+ fail(error instanceof Error ? error.message : String(error), () =>
+ composer.current?.form?.requestSubmit(),
+ );
return false;
} finally {
@@ -319,17 +336,20 @@ export function AskPanelContent({
);
const decide = useCallback(
- (permissionId: string, optionId: string) =>
+ function decide(permissionId: string, optionId: string) {
void post(`/ask/${threadId}/permission`, {
permissionId,
optionId,
- }).catch((error: Error) => setRequestError(error.message)),
- [post, threadId],
+ }).catch((error: Error) =>
+ fail(error.message, () => decide(permissionId, optionId)),
+ );
+ },
+ [post, threadId, fail],
);
const stop = () =>
void post(`/ask/${threadId}/cancel`).catch((error: Error) =>
- setRequestError(error.message),
+ fail(error.message, stop),
);
const chosen = agents?.find((candidate) => candidate.id === agent);
@@ -374,7 +394,7 @@ export function AskPanelContent({
}
void post(`/ask/${threadId}/choice`, { kind, value }).catch(
- (error: Error) => setRequestError(error.message),
+ (error: Error) => fail(error.message, () => choose(kind, value)),
);
};
@@ -397,7 +417,7 @@ export function AskPanelContent({
}
void post(`/ask/${threadId}/permissions`, { bypass: value }).catch(
- (error: Error) => setRequestError(error.message),
+ (error: Error) => fail(error.message, () => permit(value)),
);
};
@@ -407,7 +427,7 @@ export function AskPanelContent({
requestError ??
thread?.error ??
(lost
- ? `Whiteboard lost its connection to ${thread?.agentName ?? chosenName}.`
+ ? `Whiteboard lost its connection to ${session.review?.host ?? thread?.agentName ?? chosenName}.`
: null);
// Saved once its agent started a session; before that, nothing reopens it.
@@ -454,6 +474,22 @@ export function AskPanelContent({
})
: undefined;
+ const errorAction = reconnect
+ ? { label: "Reconnect", run: reconnect }
+ : retry
+ ? { label: "Try again", run: retry }
+ : startOver
+ ? { label: "Start a new conversation", run: startOver }
+ : requestError !== null && again && !down
+ ? {
+ label: "Try again",
+ run: () => {
+ setRequestError(null);
+ again();
+ },
+ }
+ : undefined;
+
// Reopening starts the agent and loads its session. Whiteboard's saved
// copy shows meanwhile; one saved before that copy waits for the replay.
const connecting =
@@ -594,6 +630,7 @@ export function AskPanelContent({
agents={agents}
agent={agent}
locked={threadId !== null || savedThreadId !== undefined}
+ unavailable={down?.label}
onPick={(picked) => {
setAgent(picked);
setPicks({});
@@ -605,6 +642,13 @@ export function AskPanelContent({
return (
{header ? createPortal(agentPicker, header) : agentPicker}
+ {down ? (
+
+ ) : null}
@@ -651,19 +695,15 @@ export function AskPanelContent({
) : error ? (
{error}
- {reconnect || retry || startOver ? (
+ {errorAction ? (
<>
{" "}
- {reconnect
- ? "Reconnect"
- : retry
- ? "Try again"
- : "Start a new conversation"}
+ {errorAction.label}
>
) : null}
@@ -685,7 +725,7 @@ export function AskPanelContent({
? "/ for commands, @ for files"
: "@ for files",
)}
- disabled={thread?.status === "failed"}
+ disabled={thread?.status === "failed" || down !== undefined}
canAsk={canAsk}
stop={busy && threadId ? stop : undefined}
connecting={thread?.status === "starting"}
@@ -779,6 +819,25 @@ export function AskReadOnlyThread({
);
}
+function AskHostBanner({
+ status,
+ host,
+ hosts,
+}: {
+ status: ReviewHostStatus;
+ host?: string;
+ hosts?: ReviewRemoteHostActions;
+}): ReactElement {
+ const { failure, button } = useHostActionButton(hosts, host, status.action);
+ const why = failure ?? status.sentence;
+
+ return (
+
+ {host && why.includes(host) ? why : `${status.label} · ${why}`}
+
+ );
+}
+
function ToLatestButton({ onClick }: { onClick: () => void }): ReactElement {
const label = "Scroll to the latest";
diff --git a/packages/review/app/src/ask-setup.tsx b/packages/review/app/src/ask-setup.tsx
index 04dfee72e..a3e2a170f 100644
--- a/packages/review/app/src/ask-setup.tsx
+++ b/packages/review/app/src/ask-setup.tsx
@@ -24,6 +24,7 @@ export function AskSetup({
selection: AgentSelection;
}): ReactElement {
const session = useReviewSession();
+ const host = session.review?.host;
const notify = useNotify();
const copy = async () => {
@@ -47,7 +48,11 @@ export function AskSetup({