{agentName} is signed out. Sign in again in a terminal, then try again;
- the question is still here.
+ the question is still here.{host ? ` Run it on ${host}.` : null}
$ {command}
diff --git a/packages/review/src/ask/agents.ts b/packages/review/src/ask/agents.ts
index acc83aaea..661333704 100644
--- a/packages/review/src/ask/agents.ts
+++ b/packages/review/src/ask/agents.ts
@@ -206,6 +206,8 @@ export type AskAgentLauncher = (
bypass?: boolean;
/** The MCP servers its sessions are given. */
mcpServers?: McpServer[];
+ /** Where it is found, and the environment it runs in. */
+ env?: NodeJS.ProcessEnv;
},
) => Promise;
@@ -286,15 +288,15 @@ const STDERR_LIMIT = 8_000;
export const launchAskAgent: AskAgentLauncher = async (
agent,
cwd,
- { bypass = false, mcpServers = [] } = {},
+ { bypass = false, mcpServers = [], env: base = process.env } = {},
) => {
const spec = askAgents[agent];
- const executable = await findAgent(spec);
+ const executable = await findAgent(spec, base);
if (!executable) throw new Error(`${spec.name} is not installed.`);
const env: NodeJS.ProcessEnv = {
- ...process.env,
+ ...base,
...((bypass && spec.bypass?.env) || spec.env),
};
diff --git a/packages/review/src/ask/threads.ts b/packages/review/src/ask/threads.ts
index 2bd13f02c..b529f6166 100644
--- a/packages/review/src/ask/threads.ts
+++ b/packages/review/src/ask/threads.ts
@@ -1,5 +1,5 @@
import { PROTOCOL_VERSION, client, methods } from "@agentclientprotocol/sdk";
-import { errorMessage } from "@dev.fast/trace-core";
+import { errorMessage, shellQuote } from "@dev.fast/trace-core";
import {
type AskAgentLauncher,
askAgentTakesMcp,
@@ -146,6 +146,52 @@ export interface AskTools {
takesMcp?: (agent: AskAgentId) => Promise;
}
+/** Whiteboard's CLI as the tools, with `selector` in its env so it reaches
+ * this server and no other. */
+export function cliAskTools(
+ cliPath: () => string | undefined,
+ selector: { name: string; value: string },
+): AskTools {
+ const env = () => [
+ selector,
+ ...(process.versions.electron
+ ? [{ name: "ELECTRON_RUN_AS_NODE", value: "1" }]
+ : []),
+ ...(process.env.DEV_REVIEW_HOME
+ ? [{ name: "DEV_REVIEW_HOME", value: process.env.DEV_REVIEW_HOME }]
+ : []),
+ ];
+
+ return {
+ mcpServers: () => {
+ const cli = cliPath();
+
+ return cli
+ ? [
+ {
+ name: "whiteboard",
+ command: process.execPath,
+ args: [cli, "mcp"],
+ env: env(),
+ },
+ ]
+ : [];
+ },
+ cli: () => {
+ const cli = cliPath();
+
+ return (
+ cli &&
+ [
+ ...env().map(({ name, value }) => `${name}=${shellQuote(value)}`),
+ shellQuote(process.execPath),
+ shellQuote(cli),
+ ].join(" ")
+ );
+ },
+ };
+}
+
/** How an agent reaches Whiteboard's tools, which its first prompt says. */
export type AskToolsReach =
| { kind: "mcp" }
diff --git a/packages/review/src/review-api/local-data.test.ts b/packages/review/src/review-api/local-data.test.ts
index 0d0d868a8..bc658e1e4 100644
--- a/packages/review/src/review-api/local-data.test.ts
+++ b/packages/review/src/review-api/local-data.test.ts
@@ -18,6 +18,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
+import {
+ type ClientConnection,
+ agent,
+ methods,
+} from "@agentclientprotocol/sdk";
import { setLocalVcsCommandObserver } from "@dev.fast/local-vcs";
import {
type JsonValue,
@@ -25,6 +30,8 @@ import {
REVIEW_CLIENT_REMOTE,
} from "@dev.fast/review-protocol";
import { type SubscribeCallback, subscribe } from "@parcel/watcher";
+import { type AskAgentLauncher, detectAskAgents } from "@review/ask/agents.js";
+import { AskThreads } from "@review/ask/threads.js";
import { rangeAnchor } from "@review/lens-selection";
import { createGlobalReviewServer } from "@review/server/desktop-server.js";
import { Hono } from "hono";
@@ -1013,6 +1020,120 @@ it("gives a remote caller a commit review's checkout as remoteRootPath, with the
expect(await read({ [REVIEW_CLIENT_HEADER]: "local" })).toEqual(ownMachine);
});
+it("gives a remote caller Ask answers with checkout-relative mentions", async () => {
+ const { reviewId } = await local.store.execute(
+ command({
+ type: "create",
+ title: "Ask",
+ target: { kind: "commits", ...pins },
+ }),
+ );
+
+ const { rootPath } = await local.data.agentCheckout(
+ local.store.read(reviewId),
+ );
+
+ const fake = agent({ name: "fake" })
+ .onRequest(methods.agent.initialize, () => ({
+ protocolVersion: 1,
+ agentCapabilities: { loadSession: true },
+ authMethods: [],
+ }))
+ .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
+ .onRequest(methods.agent.session.load, () => ({}))
+ .onRequest(methods.agent.session.prompt, () => ({
+ stopReason: "end_turn" as const,
+ }));
+
+ const launch: AskAgentLauncher = async () => {
+ let connection: ClientConnection | undefined;
+
+ return {
+ connect: (client) => (connection = client.connect(fake)),
+ diagnostics: () => "",
+ stop: () => connection?.close(),
+ };
+ };
+
+ const threads = new AskThreads(launch);
+
+ const app = createReviewApi(
+ local.store,
+ local.data,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ { threads, agents: () => detectAskAgents({}) },
+ );
+
+ const remote = { [REVIEW_CLIENT_HEADER]: REVIEW_CLIENT_REMOTE };
+
+ const request = async (route: string, body?: JsonValue) => {
+ const response = await app.request(`/${reviewId}/${route}`, {
+ headers: { ...remote, "content-type": "application/json" },
+ ...(body !== undefined && {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ });
+
+ expect(response.status).toBe(200);
+
+ return response;
+ };
+
+ const read = async (route: string, body?: JsonValue) =>
+ (await request(route, body)).json();
+
+ const snapshot = async (threadId: string) => {
+ const reader = (await request("ask/watch", { threads: [threadId] }))
+ .body!.pipeThrough(new TextDecoderStream())
+ .getReader();
+
+ const { value = "" } = await reader.read();
+ await reader.cancel();
+
+ return JSON.parse(value.split("\n")[0]!).update.snapshot;
+ };
+
+ const agents = await read("ask/agents");
+ expect(agents.agents.length).toBeGreaterThan(0);
+
+ const { threadId } = await read("ask", {
+ agent: "claude",
+ question: { text: "Why?" },
+ selection: { target: { kind: "text", quote: "value" }, title: "value" },
+ });
+
+ const asked = await snapshot(threadId);
+ expect(asked.cwd).toBe(rootPath);
+
+ await vi.waitFor(async () =>
+ expect((await read("ask/threads")).threads).toHaveLength(1),
+ );
+
+ for (const route of [
+ "ask/mentions?query=example",
+ `ask/mentions?query=example&thread=${threadId}`,
+ ]) {
+ const mentions = await read(route);
+ expect(mentions.paths).toContain(source.file);
+ expect(mentions.paths.every((file: string) => !file.startsWith("/"))).toBe(
+ true,
+ );
+ }
+
+ threads.close(threadId);
+ await read(`ask/${threadId}/open`, {});
+ const reopened = await snapshot(threadId);
+ expect(reopened.cwd).toBe(rootPath);
+ threads.close(threadId);
+});
+
it("opens a stable native workspace on the Review's pinned checkout at the selected version", async () => {
const { reviewId } = await local.store.execute(
command({
diff --git a/packages/review/src/server/desktop-server.ts b/packages/review/src/server/desktop-server.ts
index 18a3ef25a..ba584c35c 100644
--- a/packages/review/src/server/desktop-server.ts
+++ b/packages/review/src/server/desktop-server.ts
@@ -20,8 +20,8 @@ import {
parseReviewCliInstallApplyRequest,
reviewDiffrSummarizerInputSchema,
} from "@dev.fast/review-protocol";
-import { shellQuote, writePrivateJsonAtomic } from "@dev.fast/trace-core";
-import type { AskTools } from "@review/ask/threads.js";
+import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
+import { cliAskTools } from "@review/ask/threads.js";
import {
applyCliInstall,
declineCliInstall,
@@ -149,40 +149,13 @@ export function createGlobalReviewServer(
const relay =
input.relay ?? new GlobalReviewDesktopVerbRelay({ maxClients: 1 });
- // This Desktop's own CLI, pinned to this instance so another running
- // Whiteboard never answers it.
- const askCliEnv = () => [
- { name: REVIEW_INSTANCE_ENV, value: identity.key },
- ...(process.versions.electron
- ? [{ name: "ELECTRON_RUN_AS_NODE", value: "1" }]
- : []),
- ...(process.env.DEV_REVIEW_HOME
- ? [{ name: "DEV_REVIEW_HOME", value: process.env.DEV_REVIEW_HOME }]
- : []),
- ];
-
- // Ask sessions get its MCP server, `whiteboard mcp`; an agent whose model
- // would not get it uses `whiteboard api` from its shell instead.
- const askTools: AskTools = {
- mcpServers: () =>
- discovery.cliPath
- ? [
- {
- name: "whiteboard",
- command: process.execPath,
- args: [discovery.cliPath, "mcp"],
- env: askCliEnv(),
- },
- ]
- : [],
- cli: () =>
- discovery.cliPath &&
- [
- ...askCliEnv().map(({ name, value }) => `${name}=${shellQuote(value)}`),
- shellQuote(process.execPath),
- shellQuote(discovery.cliPath),
- ].join(" "),
- };
+ // Ask sessions get this Desktop's own CLI, pinned to this instance so
+ // another running Whiteboard never answers it: its MCP server, or
+ // `whiteboard api` from the shell for an agent whose model would not get it.
+ const askTools = cliAskTools(() => discovery.cliPath, {
+ name: REVIEW_INSTANCE_ENV,
+ value: identity.key,
+ });
const reviewStore = input.reviewStore;
diff --git a/packages/review/src/server/headless-host.test.ts b/packages/review/src/server/headless-host.test.ts
index be0a3ca42..4cccb8776 100644
--- a/packages/review/src/server/headless-host.test.ts
+++ b/packages/review/src/server/headless-host.test.ts
@@ -17,6 +17,11 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { PassThrough } from "node:stream";
+import {
+ type ClientConnection,
+ agent,
+ methods,
+} from "@agentclientprotocol/sdk";
import * as diffr from "@dev.fast/diffr";
import {
type JsonValue,
@@ -26,6 +31,7 @@ import {
STRUCTURAL_DIFF_WIRE_VERSION,
parseReviewDesktopVerbFrame,
} from "@dev.fast/review-protocol";
+import type { AskAgentLauncher } from "@review/ask/agents.js";
import { runReviewCli } from "@review/cli-runner.js";
import {
connectReviewApi,
@@ -51,7 +57,7 @@ import {
reviewServerIsHealthy,
} from "@review/server-discovery.js";
import sharp from "sharp";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest";
import { z } from "zod";
import { createGlobalReviewServer } from "./desktop-server.js";
@@ -65,6 +71,7 @@ beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "review-headless-"));
vi.stubEnv("DEV_REVIEW_HOME", root);
vi.stubEnv("DEV_FAST_REVIEW_TELEMETRY_DISABLED", "1");
+ vi.stubEnv("SHELL", "");
});
afterEach(async () => {
@@ -76,6 +83,7 @@ afterEach(async () => {
async function start(
stateDir = path.join(root, "server"),
softwareMapEnabled = false,
+ launchAskAgent?: AskAgentLauncher,
) {
const controller = new AbortController();
const ready = Promise.withResolvers();
@@ -85,6 +93,7 @@ async function start(
softwareMapEnabled,
signal: controller.signal,
onReady: ready.resolve,
+ launchAskAgent,
});
const stop = async () => {
@@ -1305,3 +1314,229 @@ it("leaves a review's checkouts to a live lease owner and takes over a dead owne
);
expect(worktrees()).toBe(2);
});
+
+async function askCall(
+ server: Awaited>,
+ route: string,
+ body?: JsonValue,
+) {
+ return fetch(`${server.discovery.url}/reviews-api/${route}`, {
+ headers: {
+ "x-review-token": server.discovery.token,
+ "content-type": "application/json",
+ },
+ ...(body !== undefined && { method: "POST", body: JSON.stringify(body) }),
+ });
+}
+
+async function executable(file: string, body: string) {
+ await mkdir(path.dirname(file), { recursive: true });
+ await writeFile(file, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
+}
+
+async function askAgentsAndEnv() {
+ const launched = Promise.withResolvers();
+ const started = Date.now();
+
+ const server = await start(
+ undefined,
+ false,
+ async (_agent, _cwd, options) => {
+ launched.resolve(options?.env);
+ throw new Error("No agent here.");
+ },
+ );
+
+ const startMs = Date.now() - started;
+ const { worktree } = await reviewsOfBothKinds(server.client);
+
+ const { agents } = await (
+ await askCall(server, `${worktree}/ask/agents`)
+ ).json();
+
+ await askCall(server, `${worktree}/ask`, {
+ agent: "claude",
+ question: { text: "Why?" },
+ selection: { target: { kind: "text", quote: "value" }, title: "value" },
+ });
+
+ return {
+ claude: agents.find((entry: { id: string }) => entry.id === "claude"),
+ env: await launched.promise,
+ startMs,
+ };
+}
+
+it("finds and launches Ask agents on the login shell's PATH, with its own node last", async () => {
+ const login = path.join(root, "login-bin");
+ await executable(path.join(login, "claude"), "exit 0");
+ await executable(
+ path.join(root, "shell"),
+ `echo Welcome\nprintf '\\nWHITEBOARD-PATH=%s\\n' '${login}'\necho Bye`,
+ );
+ vi.stubEnv("SHELL", path.join(root, "shell"));
+ vi.stubEnv("PATH", "/usr/bin:/bin");
+
+ const { claude, env } = await askAgentsAndEnv();
+
+ expect(claude).toMatchObject({ available: true });
+ expect(env?.PATH).toBe(
+ [login, "/usr/bin:/bin", path.dirname(process.execPath)].join(
+ path.delimiter,
+ ),
+ );
+});
+
+it("starts with its own PATH when the login shell hangs", async () => {
+ const bin = path.join(root, "bin");
+ await executable(path.join(bin, "claude"), "exit 0");
+ await executable(path.join(root, "shell"), "sleep 60");
+ vi.stubEnv("SHELL", path.join(root, "shell"));
+ vi.stubEnv("PATH", `${bin}:/usr/bin:/bin`);
+
+ const { claude, env, startMs } = await askAgentsAndEnv();
+
+ expect(startMs).toBeLessThan(8_000);
+ expect(claude).toMatchObject({ available: true });
+ expect(env?.PATH).toBe(
+ [bin, "/usr/bin:/bin", path.dirname(process.execPath)].join(path.delimiter),
+ );
+}, 20_000);
+
+it("opens an Ask thread in the review's checkout and closes it on stop", async () => {
+ const launched = Promise.withResolvers();
+ let stopped = 0;
+
+ const fake = agent({ name: "fake" })
+ .onRequest(methods.agent.initialize, () => ({
+ protocolVersion: 1,
+ agentCapabilities: {},
+ authMethods: [],
+ }))
+ .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
+ .onRequest(methods.agent.session.prompt, () => ({
+ stopReason: "end_turn" as const,
+ }));
+
+ const server = await start(undefined, false, async () => {
+ let connection: ClientConnection | undefined;
+ launched.resolve();
+
+ return {
+ connect: (client) => (connection = client.connect(fake)),
+ diagnostics: () => "",
+ stop: () => {
+ stopped++;
+ connection?.close();
+ },
+ };
+ });
+
+ const { root: checkout, worktree } = await reviewsOfBothKinds(server.client);
+
+ const opened = await askCall(server, `${worktree}/ask`, {
+ agent: "claude",
+ question: { text: "Why?" },
+ selection: { target: { kind: "text", quote: "value" }, title: "value" },
+ });
+
+ expect(opened.status).toBe(200);
+
+ const { threadId } = await opened.json();
+
+ const reader = (
+ await askCall(server, `${worktree}/ask/watch`, { threads: [threadId] })
+ )
+ .body!.pipeThrough(new TextDecoderStream())
+ .getReader();
+
+ const { value = "" } = await reader.read();
+ expect(JSON.parse(value.split("\n")[0]!).update.snapshot.cwd).toBe(checkout);
+
+ await launched.promise;
+ expect(stopped).toBe(0);
+ await server.stop();
+ expect(stopped).toBe(1);
+
+ while (!(await reader.read()).done);
+});
+
+it("closes an Ask thread ten minutes after its last watcher leaves and reopens it from history", async () => {
+ let launches = 0,
+ stopped = 0;
+
+ const fake = agent({ name: "fake" })
+ .onRequest(methods.agent.initialize, () => ({
+ protocolVersion: 1,
+ agentCapabilities: { loadSession: true },
+ authMethods: [],
+ }))
+ .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
+ .onRequest(methods.agent.session.load, () => ({}))
+ .onRequest(methods.agent.session.prompt, () => ({
+ stopReason: "end_turn" as const,
+ }));
+
+ const server = await start(undefined, false, async () => {
+ let connection: ClientConnection | undefined;
+ launches++;
+
+ return {
+ connect: (client) => (connection = client.connect(fake)),
+ diagnostics: () => "",
+ stop: () => {
+ stopped++;
+ connection?.close();
+ },
+ };
+ });
+
+ const { worktree } = await reviewsOfBothKinds(server.client);
+
+ vi.useFakeTimers({
+ shouldAdvanceTime: true,
+ toFake: ["setInterval", "clearInterval", "Date"],
+ });
+ onTestFinished(() => {
+ vi.useRealTimers();
+ });
+
+ const { threadId } = await (
+ await askCall(server, `${worktree}/ask`, {
+ agent: "claude",
+ question: { text: "Why?" },
+ selection: { target: { kind: "text", quote: "value" }, title: "value" },
+ })
+ ).json();
+
+ const reader = (
+ await askCall(server, `${worktree}/ask/watch`, { threads: [threadId] })
+ )
+ .body!.pipeThrough(new TextDecoderStream())
+ .getReader();
+
+ let seen = "";
+
+ while (!seen.includes('"status":"idle"'))
+ seen += (await reader.read()).value ?? "";
+
+ await vi.advanceTimersByTimeAsync(60 * 60_000);
+ expect(stopped).toBe(0);
+
+ await reader.cancel();
+ await vi.waitFor(async () => {
+ await vi.advanceTimersByTimeAsync(11 * 60_000);
+ expect(stopped).toBe(1);
+ });
+
+ const { threads } = await (
+ await askCall(server, `${worktree}/ask/threads`)
+ ).json();
+
+ expect(threads).toMatchObject([{ id: threadId }]);
+
+ expect(
+ (await askCall(server, `${worktree}/ask/${threadId}/open`, {})).status,
+ ).toBe(200);
+ await vi.waitFor(() => expect(launches).toBe(2));
+});
diff --git a/packages/review/src/server/headless-host.ts b/packages/review/src/server/headless-host.ts
index 7d589aa1e..5d79b34a6 100644
--- a/packages/review/src/server/headless-host.ts
+++ b/packages/review/src/server/headless-host.ts
@@ -1,12 +1,18 @@
+import { spawn } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import { once } from "node:events";
+import { existsSync } from "node:fs";
import { mkdir, realpath, rm } from "node:fs/promises";
import { createServer } from "node:http";
+import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { isObjectValue } from "@dev.fast/json";
import { withFileLock, writePrivateJsonAtomic } from "@dev.fast/trace-core";
+import type { AskAgentLauncher } from "@review/ask/agents.js";
+import { cliAskTools } from "@review/ask/threads.js";
import { desktopUsesHome } from "@review/desktop-discovery.js";
+import { findReviewPackageRoot } from "@review/package-paths.js";
import { openReviewProfile } from "@review/review-api/profile.js";
import {
DESKTOP_TAKEOVER_SIGNAL,
@@ -40,6 +46,7 @@ interface HeadlessServerInput {
/** The CLI's instance, already on the `headless` surface. */
telemetry?: Pick;
onReady(discovery: ReviewServerDiscovery): void;
+ launchAskAgent?: AskAgentLauncher;
}
export class HeadlessServerBusyError extends Error {}
@@ -114,6 +121,61 @@ export async function takeOverServerHome(
}
}
+const LOGIN_PATH_TIMEOUT_MS = 4_000;
+
+/** Started over ssh, this server has sshd's PATH; agents are on the login
+ * shell's. Its own node comes last. */
+async function agentEnv(): Promise {
+ const login = process.env.SHELL && (await loginPath(process.env.SHELL));
+
+ return {
+ ...process.env,
+ PATH: [login, process.env.PATH, path.dirname(process.execPath)]
+ .filter(Boolean)
+ .join(path.delimiter),
+ };
+}
+
+/** Only its marked line is read, so start-up output is ignored. */
+function loginPath(shell: string) {
+ return new Promise((resolve) => {
+ const child = spawn(
+ shell,
+ ["-lic", 'printf "\\nWHITEBOARD-PATH=%s\\n" "$PATH"'],
+ { detached: true, stdio: ["ignore", "pipe", "ignore"] },
+ );
+
+ let output = "";
+
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk: string) => (output += chunk));
+
+ const timer = setTimeout(() => {
+ try {
+ process.kill(-child.pid!, "SIGKILL");
+ } catch {}
+
+ resolve(undefined);
+ }, LOGIN_PATH_TIMEOUT_MS);
+
+ child.once("error", () => {
+ clearTimeout(timer);
+ resolve(undefined);
+ });
+ child.once("close", () => {
+ clearTimeout(timer);
+
+ const found = output
+ .replaceAll("\r", "")
+ .split("\n")
+ .findLast((line) => line.startsWith("WHITEBOARD-PATH="))
+ ?.slice("WHITEBOARD-PATH=".length);
+
+ resolve(found?.startsWith("/") ? found : undefined);
+ });
+ });
+}
+
async function serve(input: HeadlessServerInput) {
if (input.signal.aborted) return;
@@ -139,7 +201,15 @@ async function serve(input: HeadlessServerInput) {
const relay = new GlobalReviewDesktopVerbRelay();
- const { app, api } = createWhiteboardCore({
+ const cliPath = path.join(
+ findReviewPackageRoot(import.meta.url),
+ "dist",
+ "cli.js",
+ );
+
+ const env = await agentEnv();
+
+ const { app, api, close } = createWhiteboardCore({
profile: local,
relay,
token: discovery.token,
@@ -148,6 +218,14 @@ async function serve(input: HeadlessServerInput) {
// The scratchpad is the laptop's alone, even with a Desktop attached.
scratchpad: () => false,
status: () => ({ key: "headless", home: input.stateDir }),
+ ask: {
+ tools: cliAskTools(() => (existsSync(cliPath) ? cliPath : undefined), {
+ name: "DEV_REVIEW_SERVER_DIR",
+ value: input.stateDir,
+ }),
+ launch: input.launchAskAgent,
+ env,
+ },
});
app.route("/reviews-api", api);
@@ -177,6 +255,8 @@ async function serve(input: HeadlessServerInput) {
input.signal.addEventListener("abort", () => resolve(), { once: true });
});
} finally {
+ close();
+
// Watch streams may live forever. Drain ordinary requests, then bound shutdown.
const forceClose = setTimeout(() => server.closeAllConnections(), 5_000);
forceClose.unref();
diff --git a/packages/review/src/server/headless-sharing.test.ts b/packages/review/src/server/headless-sharing.test.ts
index 010ab7816..525f6b4c1 100644
--- a/packages/review/src/server/headless-sharing.test.ts
+++ b/packages/review/src/server/headless-sharing.test.ts
@@ -37,6 +37,7 @@ it("commits and uploads through a real headless server and CLI without Desktop,
vi.stubEnv("DEV_REVIEW_SHARE_ORIGIN", "https://sharing.test");
vi.stubEnv("DEV_REVIEW_HOME", stateDir);
vi.stubEnv("DEV_FAST_REVIEW_TELEMETRY_DISABLED", "1");
+ vi.stubEnv("SHELL", "");
const realFetch = globalThis.fetch;
const requests: { url: string; headers: Headers; body?: string }[] = [];
const blobs = new Map();
diff --git a/packages/review/src/server/review-gateway.test.ts b/packages/review/src/server/review-gateway.test.ts
index 41b2f02af..a6d22d573 100644
--- a/packages/review/src/server/review-gateway.test.ts
+++ b/packages/review/src/server/review-gateway.test.ts
@@ -649,3 +649,188 @@ it("answers 503 with an offline host's reason for a review on its last list, aft
error: "wb-a is offline: it refused the connection.",
});
});
+
+const ASK_ROUTES: [string, string][] = [
+ ["GET", "ask/mentions?query=f"],
+ ["POST", "ask/t1/prompt"],
+ ["DELETE", "ask/t1"],
+];
+
+it("forwards Ask routes of a remote review and marks the caller remote", async () => {
+ const reviewId = randomUUID();
+ const seen: string[] = [];
+
+ const fake = await startFake({
+ version,
+ reviewIds: [reviewId],
+ handle(request, response) {
+ if (!request.url?.startsWith(`/reviews-api/${reviewId}/ask`))
+ return false;
+ seen.push(
+ `${request.method} ${request.url.split(`/${reviewId}/`)[1]} ${request.headers["x-review-client"]}`,
+ );
+ response.setHeader("content-type", "application/json");
+ response.end('{"ok":true}');
+
+ return true;
+ },
+ });
+
+ const { request, gateway } = await startGateway([
+ { alias: "wb-a", endpoint: fake.endpoint },
+ ]);
+
+ await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
+
+ for (const [method, route] of ASK_ROUTES) {
+ const response = await request(`/${reviewId}/${route}`, {
+ method,
+ body: method === "GET" ? undefined : "{}",
+ });
+
+ expect(response.status, `${method} ${route}`).toBe(200);
+ }
+
+ expect(seen).toEqual(
+ ASK_ROUTES.map(([method, route]) => `${method} ${route} remote`),
+ );
+});
+
+it("keeps an Ask watch stream open across a 12 s silence and ends it when the host goes", async () => {
+ const reviewId = randomUUID();
+ let hang = false;
+ let remote: import("node:http").ServerResponse | undefined;
+
+ const fake = await startFake({
+ version,
+ reviewIds: [reviewId],
+ handle(request, response) {
+ if (request.url === "/health") return hang;
+
+ if (
+ request.method !== "POST" ||
+ request.url !== `/reviews-api/${reviewId}/ask/watch`
+ )
+ return false;
+ remote = response;
+ response.setHeader("content-type", "text/event-stream");
+ response.write('data: {"n":1}\n\n');
+
+ return true;
+ },
+ });
+
+ const { request, gateway } = await startGateway([
+ { alias: "wb-a", endpoint: fake.endpoint },
+ ]);
+
+ await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
+
+ const response = await request(`/${reviewId}/ask/watch`, {
+ method: "POST",
+ body: JSON.stringify({ threads: ["t1"] }),
+ });
+
+ expect(response.status).toBe(200);
+
+ const reader = response
+ .body!.pipeThrough(new TextDecoderStream())
+ .getReader();
+
+ expect((await reader.read()).value).toContain('{"n":1}');
+ await new Promise((resolve) => setTimeout(resolve, 12_000));
+ remote!.write('data: {"n":2}\n\n');
+ expect((await reader.read()).value).toContain('{"n":2}');
+
+ hang = true;
+ await expect
+ .poll(() => gateway.hosts()[0]?.state, { timeout: 15_000 })
+ .toBe("offline");
+ await expect(reader.read()).rejects.toThrow("terminated");
+}, 40_000);
+
+it("passes a 2 MB Ask files body to the remote intact", async () => {
+ const reviewId = randomUUID();
+ let received = 0;
+
+ const fake = await startFake({
+ version,
+ reviewIds: [reviewId],
+ handle(request, response) {
+ if (!request.url?.startsWith(`/reviews-api/${reviewId}/ask/t1/files`))
+ return false;
+ request.on("data", (chunk: Buffer) => (received += chunk.length));
+ request.on("end", () => response.end('{"ok":true}'));
+
+ return true;
+ },
+ });
+
+ const { request, gateway } = await startGateway([
+ { alias: "wb-a", endpoint: fake.endpoint },
+ ]);
+
+ await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
+
+ const body = JSON.stringify({ paths: ["x".repeat(2 * 1024 * 1024)] });
+
+ const response = await request(`/${reviewId}/ask/t1/files`, {
+ method: "POST",
+ body,
+ });
+
+ expect(response.status).toBe(200);
+ expect(received).toBe(Buffer.byteLength(body));
+});
+
+it.each([
+ ["GET", "ask/agents/codex/offer"],
+ ["POST", "ask/t1/permissions"],
+ ["POST", "ask/t1/choice"],
+])(
+ "waits past the 10 s limit for a slow %s %s and keeps the host online when it never answers",
+ async (method, route) => {
+ const slow = randomUUID();
+ const hung = randomUUID();
+
+ const fake = await startFake({
+ version,
+ reviewIds: [slow, hung],
+ handle(request, response) {
+ if (request.url?.startsWith(`/reviews-api/${hung}/${route}`))
+ return true;
+
+ if (!request.url?.startsWith(`/reviews-api/${slow}/${route}`))
+ return false;
+
+ setTimeout(() => {
+ response.setHeader("content-type", "application/json");
+ response.end('{"ok":true}');
+ }, 11_000);
+
+ return true;
+ },
+ });
+
+ const { request, gateway } = await startLocalGateway(
+ root,
+ [{ alias: "wb-a", endpoint: fake.endpoint }],
+ { slowRouteMs: 14_000 },
+ );
+
+ await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
+
+ const init = method === "POST" ? { method, body: "{}" } : {};
+
+ const [answered, unanswered] = await Promise.all([
+ request(`/${slow}/${route}`, init),
+ request(`/${hung}/${route}`, init),
+ ]);
+
+ expect(answered.status).toBe(200);
+ expect(unanswered.status).toBe(504);
+ expect(unanswered.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
+ expect(gateway.hosts()[0]?.state).toBe("online");
+ },
+ 30_000,
+);
diff --git a/packages/review/src/server/review-gateway.ts b/packages/review/src/server/review-gateway.ts
index 0791cde59..5b1c779dd 100644
--- a/packages/review/src/server/review-gateway.ts
+++ b/packages/review/src/server/review-gateway.ts
@@ -51,7 +51,7 @@ const LOCAL_ROUTES = new Set([
interface RemoteRoute {
method: string;
path: string;
- /** May wait on preparing a checkout. */
+ /** May wait on preparing a checkout or launching an agent. */
slow?: true;
}
@@ -69,6 +69,23 @@ const REMOTE_ROUTES: readonly RemoteRoute[] = [
{ method: "GET", path: "maps/:name" },
{ method: "POST", path: "copy-context" },
{ method: "GET", path: "language-context", slow: true },
+ { method: "GET", path: "ask/agents" },
+ { method: "GET", path: "ask/agents/:agent/offer", slow: true },
+ { method: "GET", path: "ask/mentions", slow: true },
+ { method: "GET", path: "ask/threads" },
+ { method: "POST", path: "ask/watch" },
+ { method: "GET", path: "ask/:thread" },
+ { method: "POST", path: "ask", slow: true },
+ {
+ method: "POST",
+ path: "ask/:thread/:action{open|choice|permissions}",
+ slow: true,
+ },
+ {
+ method: "POST",
+ path: "ask/:thread/:action{prompt|permission|files|retry|cancel|close}",
+ },
+ { method: "DELETE", path: "ask/:thread" },
];
const SLOW_ROUTE_TIMEOUT_MS = 120_000;
diff --git a/packages/review/src/server/review-server-core.test.ts b/packages/review/src/server/review-server-core.test.ts
index b020e9343..197d72550 100644
--- a/packages/review/src/server/review-server-core.test.ts
+++ b/packages/review/src/server/review-server-core.test.ts
@@ -26,6 +26,7 @@ beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "review-server-core-"));
vi.stubEnv("DEV_REVIEW_HOME", root);
vi.stubEnv("DEV_FAST_REVIEW_TELEMETRY_DISABLED", "1");
+ vi.stubEnv("SHELL", "");
});
afterEach(async () => {
diff --git a/packages/review/src/server/review-server-core.ts b/packages/review/src/server/review-server-core.ts
index 0df82a7b7..6368837df 100644
--- a/packages/review/src/server/review-server-core.ts
+++ b/packages/review/src/server/review-server-core.ts
@@ -4,7 +4,11 @@ import type {
ReviewServerHealthWithToken,
} from "@dev.fast/review-protocol";
import { traceMachineEnabled } from "@dev.fast/trace-core";
-import { detectAskAgents, launchAskAgent } from "@review/ask/agents.js";
+import {
+ type AskAgentLauncher,
+ detectAskAgents,
+ launchAskAgent,
+} from "@review/ask/agents.js";
import { AskThreads, type AskTools } from "@review/ask/threads.js";
import {
readBuildCommit,
@@ -128,7 +132,13 @@ export interface WhiteboardCoreInput {
scratchpad: () => boolean;
status: () => JsonObject;
hooks?: ReviewApiHooks;
- ask?: { tools: AskTools };
+ ask?: {
+ tools: AskTools;
+ /** Tests only: an in-process agent. */
+ launch?: AskAgentLauncher;
+ /** Where agents are found, and the environment they run in. */
+ env?: NodeJS.ProcessEnv;
+ };
}
export function createWhiteboardCore(input: WhiteboardCoreInput) {
@@ -143,8 +153,16 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled);
+ const { ask } = input;
+
+ const launch = ask?.launch ?? launchAskAgent;
+
const askThreads =
- input.ask && new AskThreads(launchAskAgent, input.ask.tools);
+ ask &&
+ new AskThreads(
+ (agent, cwd, options) => launch(agent, cwd, { ...options, env: ask.env }),
+ ask.tools,
+ );
const closeDeletedThreads = () =>
askThreads?.closeDeletedReviews((id) =>
@@ -171,7 +189,10 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
() => traceMachineEnabled(),
input.status,
input.hooks,
- askThreads && { threads: askThreads, agents: () => detectAskAgents() },
+ askThreads && {
+ threads: askThreads,
+ agents: () => detectAskAgents(ask?.env),
+ },
);
// A shared store mounts the publisher with the rest of sharing.
From 5de3fd146442f294d6dde46eb20c2c8e924f62c0 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 12:32:38 -0400
Subject: [PATCH 02/39] Start the remote server without waiting on the login
shell
Ask's agent environment is resolved on first use, so a slow login shell no longer delays startup.
---
packages/review/src/server/headless-host.ts | 4 +---
packages/review/src/server/review-server-core.ts | 7 ++++---
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/packages/review/src/server/headless-host.ts b/packages/review/src/server/headless-host.ts
index 5d79b34a6..d28b3a187 100644
--- a/packages/review/src/server/headless-host.ts
+++ b/packages/review/src/server/headless-host.ts
@@ -207,8 +207,6 @@ async function serve(input: HeadlessServerInput) {
"cli.js",
);
- const env = await agentEnv();
-
const { app, api, close } = createWhiteboardCore({
profile: local,
relay,
@@ -224,7 +222,7 @@ async function serve(input: HeadlessServerInput) {
value: input.stateDir,
}),
launch: input.launchAskAgent,
- env,
+ env: agentEnv(),
},
});
diff --git a/packages/review/src/server/review-server-core.ts b/packages/review/src/server/review-server-core.ts
index 6368837df..f910bfdaf 100644
--- a/packages/review/src/server/review-server-core.ts
+++ b/packages/review/src/server/review-server-core.ts
@@ -137,7 +137,7 @@ export interface WhiteboardCoreInput {
/** Tests only: an in-process agent. */
launch?: AskAgentLauncher;
/** Where agents are found, and the environment they run in. */
- env?: NodeJS.ProcessEnv;
+ env?: Promise;
};
}
@@ -160,7 +160,8 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
const askThreads =
ask &&
new AskThreads(
- (agent, cwd, options) => launch(agent, cwd, { ...options, env: ask.env }),
+ async (agent, cwd, options) =>
+ launch(agent, cwd, { ...options, env: await ask.env }),
ask.tools,
);
@@ -191,7 +192,7 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
input.hooks,
askThreads && {
threads: askThreads,
- agents: () => detectAskAgents(ask?.env),
+ agents: async () => detectAskAgents(await ask?.env),
},
);
From 7a95e25e1a99578f10ebb736e6b4886c12dad980 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 12:32:38 -0400
Subject: [PATCH 03/39] Share one fake Ask agent across server tests
---
packages/review/src/ask/ask-test-utils.ts | 40 +++++++++
.../review/src/review-api/local-data.test.ts | 32 +------
.../review/src/server/headless-host.test.ts | 83 +++----------------
3 files changed, 55 insertions(+), 100 deletions(-)
create mode 100644 packages/review/src/ask/ask-test-utils.ts
diff --git a/packages/review/src/ask/ask-test-utils.ts b/packages/review/src/ask/ask-test-utils.ts
new file mode 100644
index 000000000..b3c9a6156
--- /dev/null
+++ b/packages/review/src/ask/ask-test-utils.ts
@@ -0,0 +1,40 @@
+import {
+ type ClientConnection,
+ agent,
+ methods,
+} from "@agentclientprotocol/sdk";
+
+import type { AskAgentLauncher } from "./agents.js";
+
+/** An ACP agent that ends every turn at once; counts launches and stops. */
+export function idleAskAgent() {
+ const counts = { launches: 0, stopped: 0 };
+
+ const fake = agent({ name: "fake" })
+ .onRequest(methods.agent.initialize, () => ({
+ protocolVersion: 1,
+ agentCapabilities: { loadSession: true },
+ authMethods: [],
+ }))
+ .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
+ .onRequest(methods.agent.session.load, () => ({}))
+ .onRequest(methods.agent.session.prompt, () => ({
+ stopReason: "end_turn" as const,
+ }));
+
+ const launch: AskAgentLauncher = async () => {
+ let connection: ClientConnection | undefined;
+ counts.launches++;
+
+ return {
+ connect: (client) => (connection = client.connect(fake)),
+ diagnostics: () => "",
+ stop: () => {
+ counts.stopped++;
+ connection?.close();
+ },
+ };
+ };
+
+ return { launch, counts };
+}
diff --git a/packages/review/src/review-api/local-data.test.ts b/packages/review/src/review-api/local-data.test.ts
index bc658e1e4..dffde7f5d 100644
--- a/packages/review/src/review-api/local-data.test.ts
+++ b/packages/review/src/review-api/local-data.test.ts
@@ -18,11 +18,6 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
-import {
- type ClientConnection,
- agent,
- methods,
-} from "@agentclientprotocol/sdk";
import { setLocalVcsCommandObserver } from "@dev.fast/local-vcs";
import {
type JsonValue,
@@ -30,7 +25,8 @@ import {
REVIEW_CLIENT_REMOTE,
} from "@dev.fast/review-protocol";
import { type SubscribeCallback, subscribe } from "@parcel/watcher";
-import { type AskAgentLauncher, detectAskAgents } from "@review/ask/agents.js";
+import { detectAskAgents } from "@review/ask/agents.js";
+import { idleAskAgent } from "@review/ask/ask-test-utils.js";
import { AskThreads } from "@review/ask/threads.js";
import { rangeAnchor } from "@review/lens-selection";
import { createGlobalReviewServer } from "@review/server/desktop-server.js";
@@ -1033,29 +1029,7 @@ it("gives a remote caller Ask answers with checkout-relative mentions", async ()
local.store.read(reviewId),
);
- const fake = agent({ name: "fake" })
- .onRequest(methods.agent.initialize, () => ({
- protocolVersion: 1,
- agentCapabilities: { loadSession: true },
- authMethods: [],
- }))
- .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
- .onRequest(methods.agent.session.load, () => ({}))
- .onRequest(methods.agent.session.prompt, () => ({
- stopReason: "end_turn" as const,
- }));
-
- const launch: AskAgentLauncher = async () => {
- let connection: ClientConnection | undefined;
-
- return {
- connect: (client) => (connection = client.connect(fake)),
- diagnostics: () => "",
- stop: () => connection?.close(),
- };
- };
-
- const threads = new AskThreads(launch);
+ const threads = new AskThreads(idleAskAgent().launch);
const app = createReviewApi(
local.store,
diff --git a/packages/review/src/server/headless-host.test.ts b/packages/review/src/server/headless-host.test.ts
index 4cccb8776..c719d043a 100644
--- a/packages/review/src/server/headless-host.test.ts
+++ b/packages/review/src/server/headless-host.test.ts
@@ -17,11 +17,6 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { PassThrough } from "node:stream";
-import {
- type ClientConnection,
- agent,
- methods,
-} from "@agentclientprotocol/sdk";
import * as diffr from "@dev.fast/diffr";
import {
type JsonValue,
@@ -32,6 +27,7 @@ import {
parseReviewDesktopVerbFrame,
} from "@dev.fast/review-protocol";
import type { AskAgentLauncher } from "@review/ask/agents.js";
+import { idleAskAgent } from "@review/ask/ask-test-utils.js";
import { runReviewCli } from "@review/cli-runner.js";
import {
connectReviewApi,
@@ -1336,7 +1332,6 @@ async function executable(file: string, body: string) {
async function askAgentsAndEnv() {
const launched = Promise.withResolvers();
- const started = Date.now();
const server = await start(
undefined,
@@ -1347,7 +1342,6 @@ async function askAgentsAndEnv() {
},
);
- const startMs = Date.now() - started;
const { worktree } = await reviewsOfBothKinds(server.client);
const { agents } = await (
@@ -1363,7 +1357,6 @@ async function askAgentsAndEnv() {
return {
claude: agents.find((entry: { id: string }) => entry.id === "claude"),
env: await launched.promise,
- startMs,
};
}
@@ -1394,9 +1387,8 @@ it("starts with its own PATH when the login shell hangs", async () => {
vi.stubEnv("SHELL", path.join(root, "shell"));
vi.stubEnv("PATH", `${bin}:/usr/bin:/bin`);
- const { claude, env, startMs } = await askAgentsAndEnv();
+ const { claude, env } = await askAgentsAndEnv();
- expect(startMs).toBeLessThan(8_000);
expect(claude).toMatchObject({ available: true });
expect(env?.PATH).toBe(
[bin, "/usr/bin:/bin", path.dirname(process.execPath)].join(path.delimiter),
@@ -1404,33 +1396,8 @@ it("starts with its own PATH when the login shell hangs", async () => {
}, 20_000);
it("opens an Ask thread in the review's checkout and closes it on stop", async () => {
- const launched = Promise.withResolvers();
- let stopped = 0;
-
- const fake = agent({ name: "fake" })
- .onRequest(methods.agent.initialize, () => ({
- protocolVersion: 1,
- agentCapabilities: {},
- authMethods: [],
- }))
- .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
- .onRequest(methods.agent.session.prompt, () => ({
- stopReason: "end_turn" as const,
- }));
-
- const server = await start(undefined, false, async () => {
- let connection: ClientConnection | undefined;
- launched.resolve();
-
- return {
- connect: (client) => (connection = client.connect(fake)),
- diagnostics: () => "",
- stop: () => {
- stopped++;
- connection?.close();
- },
- };
- });
+ const { launch, counts } = idleAskAgent();
+ const server = await start(undefined, false, launch);
const { root: checkout, worktree } = await reviewsOfBothKinds(server.client);
@@ -1453,43 +1420,17 @@ it("opens an Ask thread in the review's checkout and closes it on stop", async (
const { value = "" } = await reader.read();
expect(JSON.parse(value.split("\n")[0]!).update.snapshot.cwd).toBe(checkout);
- await launched.promise;
- expect(stopped).toBe(0);
+ await vi.waitFor(() => expect(counts.launches).toBe(1));
+ expect(counts.stopped).toBe(0);
await server.stop();
- expect(stopped).toBe(1);
+ expect(counts.stopped).toBe(1);
while (!(await reader.read()).done);
});
it("closes an Ask thread ten minutes after its last watcher leaves and reopens it from history", async () => {
- let launches = 0,
- stopped = 0;
-
- const fake = agent({ name: "fake" })
- .onRequest(methods.agent.initialize, () => ({
- protocolVersion: 1,
- agentCapabilities: { loadSession: true },
- authMethods: [],
- }))
- .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
- .onRequest(methods.agent.session.load, () => ({}))
- .onRequest(methods.agent.session.prompt, () => ({
- stopReason: "end_turn" as const,
- }));
-
- const server = await start(undefined, false, async () => {
- let connection: ClientConnection | undefined;
- launches++;
-
- return {
- connect: (client) => (connection = client.connect(fake)),
- diagnostics: () => "",
- stop: () => {
- stopped++;
- connection?.close();
- },
- };
- });
+ const { launch, counts } = idleAskAgent();
+ const server = await start(undefined, false, launch);
const { worktree } = await reviewsOfBothKinds(server.client);
@@ -1521,12 +1462,12 @@ it("closes an Ask thread ten minutes after its last watcher leaves and reopens i
seen += (await reader.read()).value ?? "";
await vi.advanceTimersByTimeAsync(60 * 60_000);
- expect(stopped).toBe(0);
+ expect(counts.stopped).toBe(0);
await reader.cancel();
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(11 * 60_000);
- expect(stopped).toBe(1);
+ expect(counts.stopped).toBe(1);
});
const { threads } = await (
@@ -1538,5 +1479,5 @@ it("closes an Ask thread ten minutes after its last watcher leaves and reopens i
expect(
(await askCall(server, `${worktree}/ask/${threadId}/open`, {})).status,
).toBe(200);
- await vi.waitFor(() => expect(launches).toBe(2));
+ await vi.waitFor(() => expect(counts.launches).toBe(2));
});
From 5819baa2aecd971d087f371bf209255222577736 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 15:15:52 -0400
Subject: [PATCH 04/39] Drop Ask tests that other suites already cover
The gateway's slow-route and stream-silence handling, thread idle close and reopen, and the install's API checks are tested where they live; what stays covers Ask's own routes and wiring.
---
.../reviewRemoteInstaller.containers.test.ts | 40 +------
packages/review/src/server/desktop-server.ts | 3 -
.../review/src/server/headless-host.test.ts | 56 +---------
.../review/src/server/review-gateway.test.ts | 105 ------------------
4 files changed, 3 insertions(+), 201 deletions(-)
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts
index 1e0b0b2ca..265f4d47c 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts
@@ -5,7 +5,7 @@
import assert from "node:assert/strict";
import { execFile, execFileSync, spawn } from "node:child_process";
-import { randomBytes, randomUUID } from "node:crypto";
+import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
@@ -137,13 +137,7 @@ test("a host with no Node downloads the tarball, with diffr inside it; whiteboar
assert.equal(await inContainer("fresh", "ls -d ~/.dev/whiteboard-remote/install.lock 2>/dev/null | wc -l"), "0");
});
-const FETCH = `const [url, token, method, path, body] = process.argv.slice(1);
-const response = await fetch(url + path, { method, headers: { "x-review-token": token, "content-type": "application/json" }, body: method === "GET" ? undefined : body });
-const bytes = Buffer.from(await response.arrayBuffer());
-console.log(JSON.stringify({ status: response.status, type: response.headers.get("content-type"), size: bytes.length, text: bytes.toString() }));`;
-const PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
-
-test("an install has the adapters but not the bundled agent binaries; instructions, images and structural diff answer", { skip, timeout: 10 * 60_000 }, async (t) => {
+test("an install has the Ask adapters but not the bundled agent binaries", { skip, timeout: 10 * 60_000 }, async () => {
await installRemote(input("fresh").value);
const modules = `${installed}/node_modules`;
@@ -152,36 +146,6 @@ test("an install has the adapters but not the bundled agent binaries; instructio
assert.equal(await inContainer("fresh", `ls -d ${modules}/@openai/codex-* ${modules}/@anthropic-ai/claude-agent-sdk-* 2>/dev/null | wc -l`), "0");
assert.equal(await inContainer("fresh", `test -x ${modules}/@dev.fast/diffr-linux-*/diffr && echo yes`), "yes");
assert.equal(await version("fresh"), VERSION);
-
- await inContainer(
- "fresh",
- `git init -q -b main ~/repo && cd ~/repo && printf 'export const a = 1;\\n' > a.ts && git add . && git -c user.name=t -c user.email=t@t commit -qm one && printf 'export const a = 2;\\n' > a.ts && git -c user.name=t -c user.email=t@t commit -qam two`,
- );
- t.after(() => inContainer("fresh", "~/.local/bin/whiteboard server stop; rm -rf ~/repo"));
- const attach = JSON.parse((await inContainer("fresh", "~/.local/bin/whiteboard remote attach --json")).split("\n").find((line) => line.startsWith("{"))!);
- const api = async (method: string, path: string, body?: unknown) =>
- JSON.parse((await run("docker", ["exec", "-u", "dev", `wb-test-${runId}-fresh`, `${installed}/bin/node`, "--input-type=module", "-e", FETCH, attach.url, attach.token, method, `/reviews-api${path}`, JSON.stringify(body ?? null)])).stdout);
- const instructions = await api("GET", "/instructions?topic=authoring");
- assert.equal(instructions.status, 200, instructions.text);
- assert.ok(instructions.size > 0);
- const [base, head] = (await inContainer("fresh", "cd ~/repo && git rev-parse HEAD~1 HEAD")).split("\n");
- const repositoryId = JSON.parse((await api("POST", "/repositories", { path: "/home/dev/repo" })).text).id;
- const { reviewId } = JSON.parse(
- (await api("POST", "/commands", { operation: { type: "create", title: "Install check", target: { kind: "commits", repositoryId, base, head } } })).text,
- );
-
- const id = randomUUID();
- assert.equal((await api("POST", "/resources", { id, repositoryId, kind: "image", base64: PIXEL })).status, 200);
- const image = await api("GET", `/${reviewId}/resources/${id}`);
- assert.deepEqual([image.status, image.type], [200, "image/png"]);
- assert.ok(image.size > 0);
-
- const diff = await api("GET", `/${reviewId}/structural-diff`);
- assert.equal(diff.status, 200);
- const events = diff.text.trim().split("\n").map((line: string) => JSON.parse(line));
- assert.ok(events.some((event: { type: string }) => event.type === "file"));
- assert.equal(events.at(-1).type, "complete");
- assert.equal(events.at(-1).failed, 0);
});
test("a tarball the host cannot download fails the install with the URL and leaves nothing", { skip, timeout: 10 * 60_000 }, async () => {
diff --git a/packages/review/src/server/desktop-server.ts b/packages/review/src/server/desktop-server.ts
index ba584c35c..d3de0471d 100644
--- a/packages/review/src/server/desktop-server.ts
+++ b/packages/review/src/server/desktop-server.ts
@@ -149,9 +149,6 @@ export function createGlobalReviewServer(
const relay =
input.relay ?? new GlobalReviewDesktopVerbRelay({ maxClients: 1 });
- // Ask sessions get this Desktop's own CLI, pinned to this instance so
- // another running Whiteboard never answers it: its MCP server, or
- // `whiteboard api` from the shell for an agent whose model would not get it.
const askTools = cliAskTools(() => discovery.cliPath, {
name: REVIEW_INSTANCE_ENV,
value: identity.key,
diff --git a/packages/review/src/server/headless-host.test.ts b/packages/review/src/server/headless-host.test.ts
index c719d043a..c92950ccf 100644
--- a/packages/review/src/server/headless-host.test.ts
+++ b/packages/review/src/server/headless-host.test.ts
@@ -53,7 +53,7 @@ import {
reviewServerIsHealthy,
} from "@review/server-discovery.js";
import sharp from "sharp";
-import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { z } from "zod";
import { createGlobalReviewServer } from "./desktop-server.js";
@@ -1427,57 +1427,3 @@ it("opens an Ask thread in the review's checkout and closes it on stop", async (
while (!(await reader.read()).done);
});
-
-it("closes an Ask thread ten minutes after its last watcher leaves and reopens it from history", async () => {
- const { launch, counts } = idleAskAgent();
- const server = await start(undefined, false, launch);
-
- const { worktree } = await reviewsOfBothKinds(server.client);
-
- vi.useFakeTimers({
- shouldAdvanceTime: true,
- toFake: ["setInterval", "clearInterval", "Date"],
- });
- onTestFinished(() => {
- vi.useRealTimers();
- });
-
- const { threadId } = await (
- await askCall(server, `${worktree}/ask`, {
- agent: "claude",
- question: { text: "Why?" },
- selection: { target: { kind: "text", quote: "value" }, title: "value" },
- })
- ).json();
-
- const reader = (
- await askCall(server, `${worktree}/ask/watch`, { threads: [threadId] })
- )
- .body!.pipeThrough(new TextDecoderStream())
- .getReader();
-
- let seen = "";
-
- while (!seen.includes('"status":"idle"'))
- seen += (await reader.read()).value ?? "";
-
- await vi.advanceTimersByTimeAsync(60 * 60_000);
- expect(counts.stopped).toBe(0);
-
- await reader.cancel();
- await vi.waitFor(async () => {
- await vi.advanceTimersByTimeAsync(11 * 60_000);
- expect(counts.stopped).toBe(1);
- });
-
- const { threads } = await (
- await askCall(server, `${worktree}/ask/threads`)
- ).json();
-
- expect(threads).toMatchObject([{ id: threadId }]);
-
- expect(
- (await askCall(server, `${worktree}/ask/${threadId}/open`, {})).status,
- ).toBe(200);
- await vi.waitFor(() => expect(counts.launches).toBe(2));
-});
diff --git a/packages/review/src/server/review-gateway.test.ts b/packages/review/src/server/review-gateway.test.ts
index a6d22d573..35c65371d 100644
--- a/packages/review/src/server/review-gateway.test.ts
+++ b/packages/review/src/server/review-gateway.test.ts
@@ -696,59 +696,6 @@ it("forwards Ask routes of a remote review and marks the caller remote", async (
);
});
-it("keeps an Ask watch stream open across a 12 s silence and ends it when the host goes", async () => {
- const reviewId = randomUUID();
- let hang = false;
- let remote: import("node:http").ServerResponse | undefined;
-
- const fake = await startFake({
- version,
- reviewIds: [reviewId],
- handle(request, response) {
- if (request.url === "/health") return hang;
-
- if (
- request.method !== "POST" ||
- request.url !== `/reviews-api/${reviewId}/ask/watch`
- )
- return false;
- remote = response;
- response.setHeader("content-type", "text/event-stream");
- response.write('data: {"n":1}\n\n');
-
- return true;
- },
- });
-
- const { request, gateway } = await startGateway([
- { alias: "wb-a", endpoint: fake.endpoint },
- ]);
-
- await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
-
- const response = await request(`/${reviewId}/ask/watch`, {
- method: "POST",
- body: JSON.stringify({ threads: ["t1"] }),
- });
-
- expect(response.status).toBe(200);
-
- const reader = response
- .body!.pipeThrough(new TextDecoderStream())
- .getReader();
-
- expect((await reader.read()).value).toContain('{"n":1}');
- await new Promise((resolve) => setTimeout(resolve, 12_000));
- remote!.write('data: {"n":2}\n\n');
- expect((await reader.read()).value).toContain('{"n":2}');
-
- hang = true;
- await expect
- .poll(() => gateway.hosts()[0]?.state, { timeout: 15_000 })
- .toBe("offline");
- await expect(reader.read()).rejects.toThrow("terminated");
-}, 40_000);
-
it("passes a 2 MB Ask files body to the remote intact", async () => {
const reviewId = randomUUID();
let received = 0;
@@ -782,55 +729,3 @@ it("passes a 2 MB Ask files body to the remote intact", async () => {
expect(response.status).toBe(200);
expect(received).toBe(Buffer.byteLength(body));
});
-
-it.each([
- ["GET", "ask/agents/codex/offer"],
- ["POST", "ask/t1/permissions"],
- ["POST", "ask/t1/choice"],
-])(
- "waits past the 10 s limit for a slow %s %s and keeps the host online when it never answers",
- async (method, route) => {
- const slow = randomUUID();
- const hung = randomUUID();
-
- const fake = await startFake({
- version,
- reviewIds: [slow, hung],
- handle(request, response) {
- if (request.url?.startsWith(`/reviews-api/${hung}/${route}`))
- return true;
-
- if (!request.url?.startsWith(`/reviews-api/${slow}/${route}`))
- return false;
-
- setTimeout(() => {
- response.setHeader("content-type", "application/json");
- response.end('{"ok":true}');
- }, 11_000);
-
- return true;
- },
- });
-
- const { request, gateway } = await startLocalGateway(
- root,
- [{ alias: "wb-a", endpoint: fake.endpoint }],
- { slowRouteMs: 14_000 },
- );
-
- await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
-
- const init = method === "POST" ? { method, body: "{}" } : {};
-
- const [answered, unanswered] = await Promise.all([
- request(`/${slow}/${route}`, init),
- request(`/${hung}/${route}`, init),
- ]);
-
- expect(answered.status).toBe(200);
- expect(unanswered.status).toBe(504);
- expect(unanswered.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
- expect(gateway.hosts()[0]?.state).toBe("online");
- },
- 30_000,
-);
From 7867b88a18e614e40b65b1df8855208aec3e0eab Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 15:24:19 -0400
Subject: [PATCH 05/39] Keep only the Ask tests on what this change adds
The remote-ask journey drives the gateway's Ask routes end to end; route-table, copy and package-listing checks and tests of unchanged Ask code are gone.
---
.../reviewRemoteInstaller.containers.test.ts | 11 ---
packages/review/app/src/ask-panel.test.tsx | 52 ----------
packages/review/src/ask/ask-test-utils.ts | 40 --------
.../review/src/review-api/local-data.test.ts | 95 -------------------
.../review/src/server/headless-host.test.ts | 56 +++++++----
.../review/src/server/review-gateway.test.ts | 80 ----------------
6 files changed, 35 insertions(+), 299 deletions(-)
delete mode 100644 packages/review/src/ask/ask-test-utils.ts
diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts
index 265f4d47c..a41912dbb 100644
--- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteInstaller.containers.test.ts
@@ -137,17 +137,6 @@ test("a host with no Node downloads the tarball, with diffr inside it; whiteboar
assert.equal(await inContainer("fresh", "ls -d ~/.dev/whiteboard-remote/install.lock 2>/dev/null | wc -l"), "0");
});
-test("an install has the Ask adapters but not the bundled agent binaries", { skip, timeout: 10 * 60_000 }, async () => {
- await installRemote(input("fresh").value);
- const modules = `${installed}/node_modules`;
-
- assert.equal(await inContainer("fresh", `test -f ${modules}/@agentclientprotocol/codex-acp/dist/index.js && echo yes`), "yes");
- assert.equal(await inContainer("fresh", `test -f ${modules}/@agentclientprotocol/claude-agent-acp/dist/index.js && echo yes`), "yes");
- assert.equal(await inContainer("fresh", `ls -d ${modules}/@openai/codex-* ${modules}/@anthropic-ai/claude-agent-sdk-* 2>/dev/null | wc -l`), "0");
- assert.equal(await inContainer("fresh", `test -x ${modules}/@dev.fast/diffr-linux-*/diffr && echo yes`), "yes");
- assert.equal(await version("fresh"), VERSION);
-});
-
test("a tarball the host cannot download fails the install with the URL and leaves nothing", { skip, timeout: 10 * 60_000 }, async () => {
await reset("fresh");
const { value } = input("fresh");
diff --git a/packages/review/app/src/ask-panel.test.tsx b/packages/review/app/src/ask-panel.test.tsx
index 5abe5854d..0a2b21914 100644
--- a/packages/review/app/src/ask-panel.test.tsx
+++ b/packages/review/app/src/ask-panel.test.tsx
@@ -12,7 +12,6 @@ import {
import { AskHistoryProvider, useAskHistory } from "./ask-history";
import { AskHistoryList } from "./ask-history-list";
import { AskPanelContent, AskReadOnlyThread } from "./ask-panel";
-import { AskSignIn } from "./ask-setup";
import { TestCanvasQuery } from "./canvas-query-test-utils";
import { ReviewSessionProvider } from "./host/review-session";
import { ReviewPanelProvider, useReviewPanel } from "./review-panel";
@@ -1338,57 +1337,6 @@ it("moves from setup to asking once an agent is installed", async () => {
}
});
-it("names the host a remote review's agents run on (wb-test-a)", async () => {
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- const remote = testReviewSession();
- remote.review = { ...remote.review!, host: "wb-test-a" };
- const laptop = testReviewSession();
-
- for (const session of [remote, laptop])
- vi.spyOn(session, "fetch").mockImplementation(async (endpoint) =>
- endpoint === "/ask/agents"
- ? Response.json({
- agents: [{ id: "claude", name: "Claude Code", available: false }],
- })
- : Response.json({ ok: true }, { status: 404 }),
- );
-
- const container = document.createElement("div");
- document.body.append(container);
- const root = createRoot(container);
-
- const render = async (session: typeof remote) =>
- act(async () =>
- root.render(
-
-
- {}}
- />
- ,
- ),
- );
-
- try {
- await render(remote);
- expect(container.textContent).toContain("Not installed on wb-test-a");
- expect(
- container.querySelector('[aria-label="Sign in"]')?.textContent,
- ).toContain("wb-test-a");
-
- await render(laptop);
- expect(container.textContent).toContain("Not installed");
- expect(container.textContent).not.toContain("wb-test-a");
- } finally {
- await act(async () => root.unmount());
- container.remove();
- vi.restoreAllMocks();
- vi.unstubAllGlobals();
- }
-});
-
it("closes an agent that starts after its panel closed", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const session = testReviewSession();
diff --git a/packages/review/src/ask/ask-test-utils.ts b/packages/review/src/ask/ask-test-utils.ts
deleted file mode 100644
index b3c9a6156..000000000
--- a/packages/review/src/ask/ask-test-utils.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import {
- type ClientConnection,
- agent,
- methods,
-} from "@agentclientprotocol/sdk";
-
-import type { AskAgentLauncher } from "./agents.js";
-
-/** An ACP agent that ends every turn at once; counts launches and stops. */
-export function idleAskAgent() {
- const counts = { launches: 0, stopped: 0 };
-
- const fake = agent({ name: "fake" })
- .onRequest(methods.agent.initialize, () => ({
- protocolVersion: 1,
- agentCapabilities: { loadSession: true },
- authMethods: [],
- }))
- .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
- .onRequest(methods.agent.session.load, () => ({}))
- .onRequest(methods.agent.session.prompt, () => ({
- stopReason: "end_turn" as const,
- }));
-
- const launch: AskAgentLauncher = async () => {
- let connection: ClientConnection | undefined;
- counts.launches++;
-
- return {
- connect: (client) => (connection = client.connect(fake)),
- diagnostics: () => "",
- stop: () => {
- counts.stopped++;
- connection?.close();
- },
- };
- };
-
- return { launch, counts };
-}
diff --git a/packages/review/src/review-api/local-data.test.ts b/packages/review/src/review-api/local-data.test.ts
index dffde7f5d..0d0d868a8 100644
--- a/packages/review/src/review-api/local-data.test.ts
+++ b/packages/review/src/review-api/local-data.test.ts
@@ -25,9 +25,6 @@ import {
REVIEW_CLIENT_REMOTE,
} from "@dev.fast/review-protocol";
import { type SubscribeCallback, subscribe } from "@parcel/watcher";
-import { detectAskAgents } from "@review/ask/agents.js";
-import { idleAskAgent } from "@review/ask/ask-test-utils.js";
-import { AskThreads } from "@review/ask/threads.js";
import { rangeAnchor } from "@review/lens-selection";
import { createGlobalReviewServer } from "@review/server/desktop-server.js";
import { Hono } from "hono";
@@ -1016,98 +1013,6 @@ it("gives a remote caller a commit review's checkout as remoteRootPath, with the
expect(await read({ [REVIEW_CLIENT_HEADER]: "local" })).toEqual(ownMachine);
});
-it("gives a remote caller Ask answers with checkout-relative mentions", async () => {
- const { reviewId } = await local.store.execute(
- command({
- type: "create",
- title: "Ask",
- target: { kind: "commits", ...pins },
- }),
- );
-
- const { rootPath } = await local.data.agentCheckout(
- local.store.read(reviewId),
- );
-
- const threads = new AskThreads(idleAskAgent().launch);
-
- const app = createReviewApi(
- local.store,
- local.data,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- { threads, agents: () => detectAskAgents({}) },
- );
-
- const remote = { [REVIEW_CLIENT_HEADER]: REVIEW_CLIENT_REMOTE };
-
- const request = async (route: string, body?: JsonValue) => {
- const response = await app.request(`/${reviewId}/${route}`, {
- headers: { ...remote, "content-type": "application/json" },
- ...(body !== undefined && {
- method: "POST",
- body: JSON.stringify(body),
- }),
- });
-
- expect(response.status).toBe(200);
-
- return response;
- };
-
- const read = async (route: string, body?: JsonValue) =>
- (await request(route, body)).json();
-
- const snapshot = async (threadId: string) => {
- const reader = (await request("ask/watch", { threads: [threadId] }))
- .body!.pipeThrough(new TextDecoderStream())
- .getReader();
-
- const { value = "" } = await reader.read();
- await reader.cancel();
-
- return JSON.parse(value.split("\n")[0]!).update.snapshot;
- };
-
- const agents = await read("ask/agents");
- expect(agents.agents.length).toBeGreaterThan(0);
-
- const { threadId } = await read("ask", {
- agent: "claude",
- question: { text: "Why?" },
- selection: { target: { kind: "text", quote: "value" }, title: "value" },
- });
-
- const asked = await snapshot(threadId);
- expect(asked.cwd).toBe(rootPath);
-
- await vi.waitFor(async () =>
- expect((await read("ask/threads")).threads).toHaveLength(1),
- );
-
- for (const route of [
- "ask/mentions?query=example",
- `ask/mentions?query=example&thread=${threadId}`,
- ]) {
- const mentions = await read(route);
- expect(mentions.paths).toContain(source.file);
- expect(mentions.paths.every((file: string) => !file.startsWith("/"))).toBe(
- true,
- );
- }
-
- threads.close(threadId);
- await read(`ask/${threadId}/open`, {});
- const reopened = await snapshot(threadId);
- expect(reopened.cwd).toBe(rootPath);
- threads.close(threadId);
-});
-
it("opens a stable native workspace on the Review's pinned checkout at the selected version", async () => {
const { reviewId } = await local.store.execute(
command({
diff --git a/packages/review/src/server/headless-host.test.ts b/packages/review/src/server/headless-host.test.ts
index c92950ccf..120492751 100644
--- a/packages/review/src/server/headless-host.test.ts
+++ b/packages/review/src/server/headless-host.test.ts
@@ -17,6 +17,11 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { PassThrough } from "node:stream";
+import {
+ type ClientConnection,
+ agent,
+ methods,
+} from "@agentclientprotocol/sdk";
import * as diffr from "@dev.fast/diffr";
import {
type JsonValue,
@@ -27,7 +32,6 @@ import {
parseReviewDesktopVerbFrame,
} from "@dev.fast/review-protocol";
import type { AskAgentLauncher } from "@review/ask/agents.js";
-import { idleAskAgent } from "@review/ask/ask-test-utils.js";
import { runReviewCli } from "@review/cli-runner.js";
import {
connectReviewApi,
@@ -1380,24 +1384,34 @@ it("finds and launches Ask agents on the login shell's PATH, with its own node l
);
});
-it("starts with its own PATH when the login shell hangs", async () => {
- const bin = path.join(root, "bin");
- await executable(path.join(bin, "claude"), "exit 0");
- await executable(path.join(root, "shell"), "sleep 60");
- vi.stubEnv("SHELL", path.join(root, "shell"));
- vi.stubEnv("PATH", `${bin}:/usr/bin:/bin`);
-
- const { claude, env } = await askAgentsAndEnv();
-
- expect(claude).toMatchObject({ available: true });
- expect(env?.PATH).toBe(
- [bin, "/usr/bin:/bin", path.dirname(process.execPath)].join(path.delimiter),
- );
-}, 20_000);
-
it("opens an Ask thread in the review's checkout and closes it on stop", async () => {
- const { launch, counts } = idleAskAgent();
- const server = await start(undefined, false, launch);
+ let launches = 0,
+ stopped = 0;
+
+ const fake = agent({ name: "fake" })
+ .onRequest(methods.agent.initialize, () => ({
+ protocolVersion: 1,
+ agentCapabilities: {},
+ authMethods: [],
+ }))
+ .onRequest(methods.agent.session.new, () => ({ sessionId: "session" }))
+ .onRequest(methods.agent.session.prompt, () => ({
+ stopReason: "end_turn" as const,
+ }));
+
+ const server = await start(undefined, false, async () => {
+ let connection: ClientConnection | undefined;
+ launches++;
+
+ return {
+ connect: (client) => (connection = client.connect(fake)),
+ diagnostics: () => "",
+ stop: () => {
+ stopped++;
+ connection?.close();
+ },
+ };
+ });
const { root: checkout, worktree } = await reviewsOfBothKinds(server.client);
@@ -1420,10 +1434,10 @@ it("opens an Ask thread in the review's checkout and closes it on stop", async (
const { value = "" } = await reader.read();
expect(JSON.parse(value.split("\n")[0]!).update.snapshot.cwd).toBe(checkout);
- await vi.waitFor(() => expect(counts.launches).toBe(1));
- expect(counts.stopped).toBe(0);
+ await vi.waitFor(() => expect(launches).toBe(1));
+ expect(stopped).toBe(0);
await server.stop();
- expect(counts.stopped).toBe(1);
+ expect(stopped).toBe(1);
while (!(await reader.read()).done);
});
diff --git a/packages/review/src/server/review-gateway.test.ts b/packages/review/src/server/review-gateway.test.ts
index 35c65371d..41b2f02af 100644
--- a/packages/review/src/server/review-gateway.test.ts
+++ b/packages/review/src/server/review-gateway.test.ts
@@ -649,83 +649,3 @@ it("answers 503 with an offline host's reason for a review on its last list, aft
error: "wb-a is offline: it refused the connection.",
});
});
-
-const ASK_ROUTES: [string, string][] = [
- ["GET", "ask/mentions?query=f"],
- ["POST", "ask/t1/prompt"],
- ["DELETE", "ask/t1"],
-];
-
-it("forwards Ask routes of a remote review and marks the caller remote", async () => {
- const reviewId = randomUUID();
- const seen: string[] = [];
-
- const fake = await startFake({
- version,
- reviewIds: [reviewId],
- handle(request, response) {
- if (!request.url?.startsWith(`/reviews-api/${reviewId}/ask`))
- return false;
- seen.push(
- `${request.method} ${request.url.split(`/${reviewId}/`)[1]} ${request.headers["x-review-client"]}`,
- );
- response.setHeader("content-type", "application/json");
- response.end('{"ok":true}');
-
- return true;
- },
- });
-
- const { request, gateway } = await startGateway([
- { alias: "wb-a", endpoint: fake.endpoint },
- ]);
-
- await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
-
- for (const [method, route] of ASK_ROUTES) {
- const response = await request(`/${reviewId}/${route}`, {
- method,
- body: method === "GET" ? undefined : "{}",
- });
-
- expect(response.status, `${method} ${route}`).toBe(200);
- }
-
- expect(seen).toEqual(
- ASK_ROUTES.map(([method, route]) => `${method} ${route} remote`),
- );
-});
-
-it("passes a 2 MB Ask files body to the remote intact", async () => {
- const reviewId = randomUUID();
- let received = 0;
-
- const fake = await startFake({
- version,
- reviewIds: [reviewId],
- handle(request, response) {
- if (!request.url?.startsWith(`/reviews-api/${reviewId}/ask/t1/files`))
- return false;
- request.on("data", (chunk: Buffer) => (received += chunk.length));
- request.on("end", () => response.end('{"ok":true}'));
-
- return true;
- },
- });
-
- const { request, gateway } = await startGateway([
- { alias: "wb-a", endpoint: fake.endpoint },
- ]);
-
- await expect.poll(() => gateway.hosts()[0]?.state).toBe("online");
-
- const body = JSON.stringify({ paths: ["x".repeat(2 * 1024 * 1024)] });
-
- const response = await request(`/${reviewId}/ask/t1/files`, {
- method: "POST",
- body,
- });
-
- expect(response.status).toBe(200);
- expect(received).toBe(Buffer.byteLength(body));
-});
From f365a6ad7a37d7936ab936ccfbdae08159d54412 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 15:30:32 -0400
Subject: [PATCH 06/39] Trim Ask comments
---
packages/review/src/ask/agents.ts | 1 -
packages/review/src/server/headless-host.ts | 3 +--
2 files changed, 1 insertion(+), 3 deletions(-)
diff --git a/packages/review/src/ask/agents.ts b/packages/review/src/ask/agents.ts
index 661333704..109fc1d3e 100644
--- a/packages/review/src/ask/agents.ts
+++ b/packages/review/src/ask/agents.ts
@@ -206,7 +206,6 @@ export type AskAgentLauncher = (
bypass?: boolean;
/** The MCP servers its sessions are given. */
mcpServers?: McpServer[];
- /** Where it is found, and the environment it runs in. */
env?: NodeJS.ProcessEnv;
},
) => Promise;
diff --git a/packages/review/src/server/headless-host.ts b/packages/review/src/server/headless-host.ts
index d28b3a187..a33c5c0a4 100644
--- a/packages/review/src/server/headless-host.ts
+++ b/packages/review/src/server/headless-host.ts
@@ -124,7 +124,7 @@ export async function takeOverServerHome(
const LOGIN_PATH_TIMEOUT_MS = 4_000;
/** Started over ssh, this server has sshd's PATH; agents are on the login
- * shell's. Its own node comes last. */
+ * shell's. */
async function agentEnv(): Promise {
const login = process.env.SHELL && (await loginPath(process.env.SHELL));
@@ -136,7 +136,6 @@ async function agentEnv(): Promise {
};
}
-/** Only its marked line is read, so start-up output is ignored. */
function loginPath(shell: string) {
return new Promise((resolve) => {
const child = spawn(
From aa952657656c2102144b8f469da6da5b1d1b1eb5 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 15:35:58 -0400
Subject: [PATCH 07/39] Say a laptop connects to a host in the Ask docs
---
docs/remote-hosts.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md
index 41e508b39..833715da9 100644
--- a/docs/remote-hosts.md
+++ b/docs/remote-hosts.md
@@ -121,8 +121,8 @@ the host.
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 attaches it sees the same history for its reviews. A file that an
-answer names shows as text, because Source windows are not available for a
+laptop that connects to it sees the same history for its reviews. A file that
+an answer names shows as text, because Source windows are not available for a
remote review.
## Remove Whiteboard from a host
From 912b613aef13534d340fa027e0886c2544d1bf75 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 7 Oct 2026 09:00:39 -0400
Subject: [PATCH 08/39] Resolve and configure Source windows for remote hosts
---
apps/review-desktop/UPSTREAM | 4 +
.../remote/reviewRemoteHosts.test.ts | 47 ++++-
.../electron-main/remote/reviewRemoteHosts.ts | 34 ++++
.../electron-main/reviewDesktopChannel.ts | 4 +
.../review/electron-main/reviewDesktopHost.ts | 28 ++-
.../src/vs/review/navigator.desktop.main.ts | 52 +++++
.../services/remote/reviewRemoteAuthority.ts | 14 +-
.../reviewWindowAuthorityResolver.test.ts | 185 ++++++++++++++++++
.../remote/reviewWindowAuthorityResolver.ts | 98 ++++++++++
.../reviewDesktopConnectionService.ts | 8 +-
10 files changed, 459 insertions(+), 15 deletions(-)
create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts
create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts
diff --git a/apps/review-desktop/UPSTREAM b/apps/review-desktop/UPSTREAM
index 5a35951ab..56fc5bda8 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
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..963dcaad7 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, remoteHostState, 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 window waiting on a machine sees its alias and state, or connecting while an alias has not reported its id", () => {
+ assert.deepEqual(remoteHostState("s1", [
+ { alias: "copy", serverId: "s1", state: "duplicate" },
+ { alias: "devbox", serverId: "s1", state: "online" },
+ ]), { alias: "devbox", state: "online" });
+ assert.deepEqual(remoteHostState("s1", [{ alias: "devbox", serverId: "s1", state: "offline" }]), { alias: "devbox", state: "offline" });
+ assert.deepEqual(remoteHostState("s1", [{ alias: "new", state: "connecting" }]), { state: "connecting" });
+ assert.equal(remoteHostState("s1", [{ alias: "gone", state: "unreachable" }, { alias: "other", serverId: "s2", state: "connecting" }]), undefined);
+});
+
+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("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..8aa3ad00a 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; state: ReviewGatewayHostState["state"] } | 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 { alias: best.alias, state: best.state };
+ if (configured) return { alias: configured, state: states.find((state) => state.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,12 @@ export class ReviewRemoteHosts {
return online && this.hosts.get(online.alias)?.languageEndpoint(serverId);
}
+ /** The state of the machine `serverId`, through the alias in the setting that served it this session. */
+ hostState(serverId: string, states: readonly ReviewGatewayHostState[]): ReturnType {
+ const configured = [...this.hosts.values()].find((host) => host.serverId === serverId)?.state.alias;
+ return remoteHostState(serverId, states, configured);
+ }
+
resume(): void {
for (const host of this.hosts.values()) void host.resume();
}
@@ -239,6 +268,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/reviewDesktopChannel.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts
index a538b0703..e2621582d 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,10 @@ export class ReviewDesktopChannel implements IServerChannel {
if (typeof arg !== "string") return undefined as T;
return (await this.host.getRemoteLanguageEndpoint(arg)) as T;
}
+ if (command === "getRemoteHostState") {
+ if (typeof arg !== "string") return undefined as T;
+ return (await this.host.getRemoteHostState(arg)) 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..a49f7b91b 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) {
+ const manager = this.remoteHosts;
+ const states = manager && (await this.remoteHostStates());
+ return states && manager.hostState(serverId, states);
+ }
+
+ 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..0863dea68 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,19 @@ 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 { 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) {
@@ -60,6 +73,45 @@ class NavigatorDefaults {
registerWorkbenchContribution2('review.navigator.defaults', NavigatorDefaults, WorkbenchPhase.BlockStartup);
+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);
const references = accessor.get(ILanguageFeaturesService).referenceProvider;
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/reviewWindowAuthorityResolver.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts
new file mode 100644
index 000000000..05a4b6984
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts
@@ -0,0 +1,185 @@
+/*---------------------------------------------------------------------------------------------
+ * 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 alias left the setting fails after the wait", async () => {
+ const { hosts, next } = fakeMain([{ endpoint: 41000, states: [{ alias: "devbox", serverId: "abc-1", state: "online" }], configured: "devbox" }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), hosts, { wait: 20, poll: 5 });
+ await resolver.resolveAuthority(AUTHORITY);
+
+ next({ states: [{ alias: "devbox", state: "unreachable" }] });
+ const started = Date.now();
+ await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => error._code === RemoteAuthorityResolverErrorCode.NotAvailable);
+ assert.ok(Date.now() - started >= 20);
+});
+
+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("before the host list exists, a window waits rather than calling its host offline", async () => {
+ const { channel } = fakeChannel([{}, {}, { endpoint: endpoint(44000) }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait: 1_000, poll: 1 });
+
+ assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 44000);
+});
+
+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 } = fakeChannel([{ state: { alias: "devbox", state } }]);
+ const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait, poll: 5 });
+ const started = Date.now();
+
+ 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;
+ });
+ assert.ok(Date.now() - started < 1_000);
+ }
+});
+
+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 once, when the host's forward answers", async () => {
+ let polls = 0;
+ const hosts: ReviewWindowHosts = { endpoint: async () => (++polls >= 3 ? endpoint(45000) : undefined), state: async () => undefined };
+ let reloads = 0;
+ const watch = reloadWhenOnline(hosts, "abc-1", () => reloads++, 2);
+
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ watch.dispose();
+ assert.equal(reloads, 1);
+ 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..49b813759
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts
@@ -0,0 +1,98 @@
+/*---------------------------------------------------------------------------------------------
+ * 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 { 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): Promise<{ alias?: string; state: string } | undefined>;
+}
+
+export function parseRemoteLanguageEndpoint(endpoint: unknown): ReviewRemoteLanguageEndpoint | undefined {
+ 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;
+}
+
+/** 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: async (serverId) => parseRemoteLanguageEndpoint(await channel.call("getRemoteLanguageEndpoint", serverId)),
+ state: async (serverId) => {
+ const state: unknown = await channel.call("getRemoteHostState", serverId);
+ if (typeof state !== "object" || state === null) return undefined;
+ const { alias, state: name } = state as Record;
+ return typeof name === "string" ? { alias: typeof alias === "string" ? alias : undefined, state: name } : undefined;
+ },
+ };
+}
+
+/** 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/reviewDesktopConnectionService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts
index 8c214ffea..a41794726 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
@@ -37,6 +37,7 @@ import {
REVIEW_SERVER_STARTUP_TIMEOUT_MS,
reconnectUntilAborted,
} from "../common/reviewReconnect.js";
+import { parseRemoteLanguageEndpoint } from "./remote/reviewWindowAuthorityResolver.js";
const REVIEW_TUTORIAL_AUTOPREPARE_SUPPRESSED_KEY = "review.tutorial.autoPrepareSuppressed.v1";
@@ -258,12 +259,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 parseRemoteLanguageEndpoint(await this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("getRemoteLanguageEndpoint", serverId));
}
async installRemoteHost(alias: string): Promise {
From 87b374306c91f34baaf1558ddeaf7c7529b6d860 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 12:35:42 -0400
Subject: [PATCH 09/39] Pass whole host entries to Source windows and trust
main's answers
getRemoteHostState returns the gateway's entry, detail included, and the window reads main's IPC answers with typed calls.
---
.../remote/reviewRemoteHosts.test.ts | 4 ++--
.../electron-main/remote/reviewRemoteHosts.ts | 6 +++---
.../remote/reviewWindowAuthorityResolver.ts | 20 ++++---------------
.../reviewDesktopConnectionService.ts | 3 +--
4 files changed, 10 insertions(+), 23 deletions(-)
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 963dcaad7..11363aa1c 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
@@ -123,8 +123,8 @@ test("a window waiting on a machine sees its alias and state, or connecting whil
assert.deepEqual(remoteHostState("s1", [
{ alias: "copy", serverId: "s1", state: "duplicate" },
{ alias: "devbox", serverId: "s1", state: "online" },
- ]), { alias: "devbox", state: "online" });
- assert.deepEqual(remoteHostState("s1", [{ alias: "devbox", serverId: "s1", state: "offline" }]), { alias: "devbox", state: "offline" });
+ ]), { alias: "devbox", serverId: "s1", state: "online" });
+ assert.deepEqual(remoteHostState("s1", [{ alias: "devbox", serverId: "s1", state: "offline", detail: "gone" }]), { alias: "devbox", serverId: "s1", state: "offline", detail: "gone" });
assert.deepEqual(remoteHostState("s1", [{ alias: "new", state: "connecting" }]), { state: "connecting" });
assert.equal(remoteHostState("s1", [{ alias: "gone", state: "unreachable" }, { alias: "other", serverId: "s2", state: "connecting" }]), undefined);
});
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 8aa3ad00a..db1709ad9 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
@@ -58,11 +58,11 @@ export function remoteHostState(
serverId: string,
states: readonly ReviewGatewayHostState[],
configured?: string,
-): { alias?: string; state: ReviewGatewayHostState["state"] } | undefined {
+): ({ 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 { alias: best.alias, state: best.state };
- if (configured) return { alias: configured, state: states.find((state) => state.alias === configured)?.state ?? "connecting" };
+ 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;
}
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
index 49b813759..25c8ca500 100644
--- 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
@@ -13,6 +13,7 @@ import {
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";
@@ -21,15 +22,7 @@ export const REMOTE_WINDOW_RESOLVE_WAIT_MS = 60_000;
export interface ReviewWindowHosts {
endpoint(serverId: string): Promise;
- state(serverId: string): Promise<{ alias?: string; state: string } | undefined>;
-}
-
-export function parseRemoteLanguageEndpoint(endpoint: unknown): ReviewRemoteLanguageEndpoint | undefined {
- 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;
+ state(serverId: string): Promise<({ alias?: string } & Pick) | undefined>;
}
/** Reloads a window that could not reach its host, once the host's forward answers. */
@@ -51,13 +44,8 @@ export function reloadWhenOnline(hosts: ReviewWindowHosts, serverId: string, rel
export function reviewWindowHosts(channel: IChannel): ReviewWindowHosts {
return {
- endpoint: async (serverId) => parseRemoteLanguageEndpoint(await channel.call("getRemoteLanguageEndpoint", serverId)),
- state: async (serverId) => {
- const state: unknown = await channel.call("getRemoteHostState", serverId);
- if (typeof state !== "object" || state === null) return undefined;
- const { alias, state: name } = state as Record;
- return typeof name === "string" ? { alias: typeof alias === "string" ? alias : undefined, state: name } : undefined;
- },
+ endpoint: (serverId) => channel.call("getRemoteLanguageEndpoint", serverId),
+ state: (serverId) => channel.call("getRemoteHostState", serverId),
};
}
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 a41794726..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
@@ -37,7 +37,6 @@ import {
REVIEW_SERVER_STARTUP_TIMEOUT_MS,
reconnectUntilAborted,
} from "../common/reviewReconnect.js";
-import { parseRemoteLanguageEndpoint } from "./remote/reviewWindowAuthorityResolver.js";
const REVIEW_TUTORIAL_AUTOPREPARE_SUPPRESSED_KEY = "review.tutorial.autoPrepareSuppressed.v1";
@@ -259,7 +258,7 @@ export class ReviewDesktopConnectionService extends Disposable implements IRevie
}
async getRemoteLanguageEndpoint(serverId: string): Promise {
- return parseRemoteLanguageEndpoint(await this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("getRemoteLanguageEndpoint", serverId));
+ return this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("getRemoteLanguageEndpoint", serverId);
}
async installRemoteHost(alias: string): Promise {
From 4811602874c98f55e6e3379b761d23306740e2f7 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 12:35:43 -0400
Subject: [PATCH 10/39] Drop wall-clock checks from the window authority tests
---
.../remote/reviewWindowAuthorityResolver.test.ts | 16 ++++++----------
1 file changed, 6 insertions(+), 10 deletions(-)
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
index 05a4b6984..a380980b5 100644
--- 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
@@ -106,9 +106,7 @@ test("a resolved authority whose alias left the setting fails after the wait", a
await resolver.resolveAuthority(AUTHORITY);
next({ states: [{ alias: "devbox", state: "unreachable" }] });
- const started = Date.now();
await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => error._code === RemoteAuthorityResolverErrorCode.NotAvailable);
- assert.ok(Date.now() - started >= 20);
});
test("a resolved authority whose machine is gone after the wait fails; one still known keeps waiting", async () => {
@@ -141,9 +139,8 @@ test("before the host list exists, a window waits rather than calling its host o
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 } = fakeChannel([{ state: { alias: "devbox", state } }]);
+ const { channel, calls } = fakeChannel([{ state: { alias: "devbox", state } }]);
const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait, poll: 5 });
- const started = Date.now();
await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => {
assert.ok(error instanceof RemoteAuthorityResolverError);
@@ -151,7 +148,7 @@ test("a host still connecting after the wait, or not connecting at all, is offli
assert.equal(error._detail, "devbox is offline");
return true;
});
- assert.ok(Date.now() - started < 1_000);
+ if (state === "offline") assert.equal(calls.length, 2);
}
});
@@ -172,14 +169,13 @@ test("canonical URIs of whiteboard+ are themselves; every other authority is the
assert.deepEqual(calls, []);
});
-test("a window that could not reach its host reloads once, when the host's forward answers", async () => {
+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 };
- let reloads = 0;
- const watch = reloadWhenOnline(hosts, "abc-1", () => reloads++, 2);
+ const reloaded = Promise.withResolvers();
+ const watch = reloadWhenOnline(hosts, "abc-1", reloaded.resolve, 1);
- await new Promise((resolve) => setTimeout(resolve, 60));
+ await reloaded.promise;
watch.dispose();
- assert.equal(reloads, 1);
assert.equal(polls, 3);
});
From e2156f7265aa3939e8785dc7d6a543defe97540b Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 15:42:55 -0400
Subject: [PATCH 11/39] Drop Source window tests a sibling test already covers
---
.../remote/reviewRemoteHosts.test.ts | 12 +-----------
.../remote/reviewWindowAuthorityResolver.test.ts | 16 ----------------
2 files changed, 1 insertion(+), 27 deletions(-)
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 11363aa1c..ce6b6cfb1 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 { closeRemoteHostWindows, remoteHostState, ReviewRemoteHosts, type ReviewRemoteHostsOptions } 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";
@@ -119,16 +119,6 @@ test("removing a machine's last alias closes the windows bound to it, and no oth
assert.deepEqual(closed, [`whiteboard+${FAKE_SERVER_ID}`]);
});
-test("a window waiting on a machine sees its alias and state, or connecting while an alias has not reported its id", () => {
- assert.deepEqual(remoteHostState("s1", [
- { alias: "copy", serverId: "s1", state: "duplicate" },
- { alias: "devbox", serverId: "s1", state: "online" },
- ]), { alias: "devbox", serverId: "s1", state: "online" });
- assert.deepEqual(remoteHostState("s1", [{ alias: "devbox", serverId: "s1", state: "offline", detail: "gone" }]), { alias: "devbox", serverId: "s1", state: "offline", detail: "gone" });
- assert.deepEqual(remoteHostState("s1", [{ alias: "new", state: "connecting" }]), { state: "connecting" });
- assert.equal(remoteHostState("s1", [{ alias: "gone", state: "unreachable" }, { alias: "other", serverId: "s2", state: "connecting" }]), undefined);
-});
-
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"]);
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
index a380980b5..342fb84a4 100644
--- 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
@@ -100,15 +100,6 @@ test("a resolved authority waits through offline and unreachable for the moved f
assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 42000);
});
-test("a resolved authority whose alias left the setting fails after the wait", async () => {
- const { hosts, next } = fakeMain([{ endpoint: 41000, states: [{ alias: "devbox", serverId: "abc-1", state: "online" }], configured: "devbox" }]);
- const resolver = reviewWindowAuthorityResolver(fakeBase(), hosts, { wait: 20, poll: 5 });
- await resolver.resolveAuthority(AUTHORITY);
-
- next({ states: [{ alias: "devbox", state: "unreachable" }] });
- await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => error._code === RemoteAuthorityResolverErrorCode.NotAvailable);
-});
-
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);
@@ -130,13 +121,6 @@ test("a connecting host is waited for, and resolves once its forward appears", a
assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 43000);
});
-test("before the host list exists, a window waits rather than calling its host offline", async () => {
- const { channel } = fakeChannel([{}, {}, { endpoint: endpoint(44000) }]);
- const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait: 1_000, poll: 1 });
-
- assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 44000);
-});
-
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 } }]);
From 8a15c5e2b421ba03318b51bbcd04e3faa83da416 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 7 Oct 2026 09:00:40 -0400
Subject: [PATCH 12/39] Open Source windows for remote reviews
---
.../canvas/reviewApiEditorSerializer.test.ts | 9 +-
.../parts/canvas/reviewRemoteEntry.test.ts | 2 +-
.../src/vs/review/navigator.desktop.main.ts | 7 +
...iewRemoteWindowExtensionManagement.test.ts | 35 +
.../reviewRemoteWindowExtensionManagement.ts | 46 ++
.../reviewSourceWindowHostState.test.ts | 130 +++
.../remote/reviewSourceWindowHostState.ts | 106 +++
.../reviewCanvasEditorTabsService.test.ts | 82 +-
.../services/reviewCanvasEditorTabsService.ts | 57 +-
.../reviewEditorResolverService.test.ts | 4 +-
apps/review-desktop/scripts/e2e/harness.mjs | 9 +-
.../scripts/e2e/journeys/remote-ask.mjs | 163 ++--
.../scripts/e2e/journeys/remote-host.mjs | 79 +-
.../e2e/journeys/remote-source-window.mjs | 754 ++++++++++++++++++
.../e2e/remote/source-window-helpers.mjs | 166 ++++
docs/remote-hosts.md | 27 +-
packages/review-protocol/src/contracts.ts | 9 +
.../review-protocol/src/review-api-client.ts | 2 +-
packages/review/app/src/api-canvas.test.tsx | 48 +-
packages/review/app/src/api-canvas.tsx | 37 +-
packages/review/app/src/ask-files.test.tsx | 1 -
.../app/src/review-home-view.browser.test.tsx | 2 +-
packages/review/src/ask/agents.test.ts | 43 +
packages/review/src/ask/agents.ts | 15 +-
packages/review/src/ask/pi-mcp.ts | 10 +-
.../src/server/review-gateway-list.test.ts | 13 +-
.../review/src/server/review-gateway-list.ts | 7 +-
.../src/server/review-gateway-streams.test.ts | 2 +-
.../review/src/server/review-gateway.test.ts | 132 +++
packages/review/src/server/review-gateway.ts | 93 ++-
.../review/src/server/review-server-core.ts | 6 +-
31 files changed, 1934 insertions(+), 162 deletions(-)
create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts
create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts
create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.test.ts
create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.ts
create mode 100644 apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs
create mode 100644 apps/review-desktop/scripts/e2e/remote/source-window-helpers.mjs
create mode 100644 packages/review/src/ask/agents.test.ts
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/reviewRemoteEntry.test.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewRemoteEntry.test.ts
index 1fc09d905..c1b336aa8 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", () => {
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 0863dea68..af18c18bb 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
@@ -49,11 +49,15 @@ import { CommandsRegistry, ICommandService } from '../platform/commands/common/c
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 { ReviewExtensionGalleryManifestService, ReviewExtensionManagementServerService } from './services/remote/reviewRemoteWindowExtensionManagement.js';
+import { IExtensionManagementServerService } from '../workbench/services/extensionManagement/common/extensionManagement.js';
+import { IExtensionGalleryManifestService } from '../platform/extensionManagement/common/extensionGalleryManifest.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';
@@ -72,6 +76,7 @@ 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)));
@@ -95,6 +100,8 @@ class NavigatorExtensionService extends NativeExtensionService {
}
registerSingleton(IExtensionService, NavigatorExtensionService, InstantiationType.Eager);
+registerSingleton(IExtensionManagementServerService, ReviewExtensionManagementServerService, InstantiationType.Delayed);
+registerSingleton(IExtensionGalleryManifestService, ReviewExtensionGalleryManifestService, InstantiationType.Eager);
/** Every file in a Source window's folders is read-only, whatever a folder's settings say. */
class NavigatorFilesConfigurationService extends FilesConfigurationService {
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts
new file mode 100644
index 000000000..f25962226
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { IChannel } from "../../../base/parts/ipc/common/ipc.js";
+import type { IInstantiationService } from "../../../platform/instantiation/common/instantiation.js";
+import type { ISharedProcessService } from "../../../platform/ipc/electron-browser/services.js";
+import type { ILabelService } from "../../../platform/label/common/label.js";
+import type { IRemoteAgentService } from "../../../workbench/services/remote/common/remoteAgentService.js";
+import { ReviewExtensionManagementServerService, withoutRemoteExtensionManagement } from "./reviewRemoteWindowExtensionManagement.js";
+
+function remote(authority: string) {
+ const asked: string[] = [];
+ const channel = (name: string) => ({ name, call: async () => { throw new Error(`Unknown channel: ${name}`); }, listen: () => { throw new Error(`Unknown channel: ${name}`); } });
+ const connection = { remoteAuthority: authority, getChannel: (name: string) => (asked.push(name), channel(name)) };
+ return { asked, service: { getConnection: () => connection } as unknown as IRemoteAgentService };
+}
+
+function serverService(remoteAgentService: IRemoteAgentService) {
+ const created: unknown[][] = [];
+ const instantiation = { createInstance: (...args: unknown[]) => (created.push(args), { dispose() { } }) } as unknown as IInstantiationService;
+ const sharedProcess = { getChannel: (name: string) => ({ name }) } as unknown as ISharedProcessService;
+ const service = new ReviewExtensionManagementServerService(sharedProcess, remoteAgentService, { getHostLabel: () => "wb-test-a" } as unknown as ILabelService, instantiation);
+ return { service, remoteChannel: created[1]?.[1] as IChannel };
+}
+
+test("a whiteboard+ window manages no extensions on its server and asks it nothing", async () => {
+ const { asked, service: remoteAgent } = remote("whiteboard+abc-1");
+ const { service, remoteChannel } = serverService(remoteAgent);
+ assert.equal(service.remoteExtensionManagementServer?.label, "wb-test-a");
+ assert.deepEqual(await remoteChannel.call("getInstalled", []), []);
+ assert.doesNotThrow(() => remoteChannel.listen("onDidInstallExtensions")(() => { }).dispose());
+ await assert.rejects(remoteChannel.call("installFromGallery", []), /not managed from a Source window \(installFromGallery\)/);
+ await withoutRemoteExtensionManagement(remoteAgent).getConnection()!.getChannel("extensionGalleryManifest").call("setExtensionGalleryManifest", [null]);
+ withoutRemoteExtensionManagement(remoteAgent).getConnection()!.getChannel("remoteFilesystem");
+ assert.deepEqual(asked, ["remoteFilesystem"]);
+});
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts
new file mode 100644
index 000000000..6b72549a0
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts
@@ -0,0 +1,46 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Event } from "../../../base/common/event.js";
+import type { IChannel } from "../../../base/parts/ipc/common/ipc.js";
+import { WorkbenchExtensionGalleryManifestService } from "../../../workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.js";
+import { ExtensionManagementServerService } from "../../../workbench/services/extensionManagement/electron-browser/extensionManagementServerService.js";
+import type { IRemoteAgentService } from "../../../workbench/services/remote/common/remoteAgentService.js";
+import { isReviewRemoteAuthority, override } from "./reviewRemoteAuthority.js";
+
+const UNSERVED = new Set(["extensions", "extensionGalleryManifest"]);
+
+/** The trimmed server manages no extensions. */
+const NO_EXTENSIONS: IChannel = {
+ call: async (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.None as Event,
+};
+
+export function withoutRemoteExtensionManagement(remoteAgentService: IRemoteAgentService): IRemoteAgentService {
+ const connection = remoteAgentService.getConnection();
+ if (!connection || !isReviewRemoteAuthority(connection.remoteAuthority)) return remoteAgentService;
+ const quiet = override(connection, {
+ getChannel: (name: string) => (UNSERVED.has(name) ? NO_EXTENSIONS : connection.getChannel(name)) as T,
+ });
+ return override(remoteAgentService, { getConnection: () => quiet });
+}
+
+export class ReviewExtensionManagementServerService extends ExtensionManagementServerService {
+ constructor(...args: ConstructorParameters) {
+ args[1] = withoutRemoteExtensionManagement(args[1]);
+ super(...args);
+ }
+}
+
+export class ReviewExtensionGalleryManifestService extends WorkbenchExtensionGalleryManifestService {
+ constructor(...args: ConstructorParameters) {
+ args[5] = withoutRemoteExtensionManagement(args[5]);
+ super(...args);
+ }
+}
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..1d53f47bc
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.test.ts
@@ -0,0 +1,130 @@
+/*---------------------------------------------------------------------------------------------
+ * 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 } from "../../../base/common/event.js";
+import type { IMainProcessService } from "../../../platform/ipc/common/mainProcessService.js";
+import type { INotification, 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 { ReviewSourceWindowHostState, showSourceWindowHostState } from "./reviewSourceWindowHostState.js";
+import type { ReviewWindowHosts } from "./reviewWindowAuthorityResolver.js";
+
+const OFFLINE = "devbox — offline, reconnecting…";
+
+function fakes() {
+ const entries: IStatusbarEntry[] = [];
+ const shown: INotification[] = [];
+ 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) => {
+ shown.push(notification);
+ return { close: () => void closed++ };
+ },
+ } as unknown as INotificationService;
+ let state = "online";
+ const hosts: ReviewWindowHosts = { endpoint: async () => undefined, state: async () => ({ alias: "devbox", state }) };
+ const connection = new Emitter();
+ return {
+ statusbar,
+ notifications,
+ hosts,
+ connection: { onDidStateChange: connection.event } as unknown as IRemoteAgentConnection,
+ fire: (type: PersistentConnectionEventType) => connection.fire({ type } as PersistentConnectionEvent),
+ set: (next: string) => void (state = next),
+ last: () => entries.at(-1),
+ shown,
+ closed: () => closed,
+ };
+}
+
+const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
+
+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, undefined, 5);
+ try {
+ await tick();
+ assert.equal(f.last()?.text, "devbox");
+ assert.notEqual(f.last()?.kind, "warning");
+ assert.equal(f.shown.length, 0);
+
+ f.set("offline");
+ await tick();
+ assert.equal(f.last()?.text, OFFLINE);
+ assert.equal(f.last()?.kind, "warning");
+ assert.deepEqual(f.shown.map((n) => [n.message, n.actions]), [["devbox is offline. The window reconnects when it is back.", undefined]]);
+
+ f.fire(PersistentConnectionEventType.ConnectionLost);
+ f.set("online");
+ await tick();
+ 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");
+ await tick();
+ assert.equal(f.last()?.text, OFFLINE);
+ assert.equal(f.shown.length, 1, "no second notification in the same window");
+ } finally {
+ watch.dispose();
+ }
+});
+
+test("a window on another authority shows nothing and never asks main", () => {
+ for (const remoteAuthority of [undefined, "ssh-remote+x", "Whiteboard+ABC"]) {
+ const contribution = new ReviewSourceWindowHostState(
+ { remoteAuthority } as IWorkbenchEnvironmentService,
+ { getChannel: () => assert.fail("asked main") } as unknown as IMainProcessService,
+ { addEntry: () => assert.fail("added an entry") } as unknown as IStatusbarService,
+ { notify: () => assert.fail("notified") } as unknown as INotificationService,
+ { getConnection: () => null } as unknown as IRemoteAgentService,
+ { get: () => assert.fail("read storage") } as unknown as IStorageService,
+ );
+ contribution.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 tick();
+ first.contribution.dispose();
+ const { f, contribution } = open({ state: "offline" });
+ try {
+ await tick();
+ assert.equal(f.last()?.text, "wb-test-a — offline, reconnecting…");
+ assert.equal(f.shown[0]?.message, "wb-test-a is offline. The window reconnects when it is back.");
+ } 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..44ec092cc
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewSourceWindowHostState.ts
@@ -0,0 +1,106 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Disposable, DisposableStore, toDisposable, type IDisposable } from "../../../base/common/lifecycle.js";
+import Severity from "../../../base/common/severity.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 { 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;
+
+const HOST_ALIAS_KEY = "review.sourceWindow.hostAlias";
+
+/** Shows a Source window's host as main sees it: its alias, or that it is offline and the window is reconnecting. */
+export function showSourceWindowHostState(
+ serverId: string,
+ hosts: ReviewWindowHosts,
+ statusbar: IStatusbarService,
+ notifications: INotificationService,
+ connection: Pick | null,
+ stored?: { get(): string | undefined; set(alias: string): void },
+ every = HOST_STATE_POLL_MS,
+): IDisposable {
+ const store = new DisposableStore();
+ let mainAlias: string | undefined;
+ let known = false;
+ let online = true;
+ let lost = false;
+ let notified = false;
+ let entry: IStatusbarEntryAccessor | undefined;
+ let notice: INotificationHandle | undefined;
+ const render = () => {
+ if (!known) return;
+ const alias = mainAlias ?? stored?.get() ?? serverId.slice(0, 8);
+ const offline = !online || lost;
+ const text = offline ? `${alias} — offline, reconnecting…` : alias;
+ const props: IStatusbarEntry = { name: "Remote Host", text, ariaLabel: text, kind: offline ? "warning" : undefined };
+ if (entry) entry.update(props);
+ else entry = store.add(statusbar.addEntry(props, "review.sourceWindow.host", StatusbarAlignment.LEFT, Number.MAX_VALUE));
+ if (offline && !notified) {
+ notified = true;
+ notice = notifications.notify({ severity: Severity.Warning, message: `${alias} is offline. The window reconnects when it is back.` });
+ } else if (!offline) {
+ notice?.close();
+ 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).catch(() => undefined);
+ if (stopped) return;
+ if (state) {
+ if (state.alias && state.alias !== mainAlias) stored?.set(state.alias);
+ mainAlias = state.alias ?? mainAlias;
+ online = state.state === "online";
+ known = true;
+ 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 hosts = reviewWindowHosts(mainProcess.getChannel(REVIEW_DESKTOP_CHANNEL));
+ this._register(showSourceWindowHostState(reviewRemoteServerId(authority), hosts, statusbar, notifications, remoteAgent.getConnection(), {
+ 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/reviewCanvasEditorTabsService.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewCanvasEditorTabsService.test.ts
index 3476334a2..03557b7d7 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,13 @@
*--------------------------------------------------------------------------------------------*/
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 { apiSourceUri } from "../common/reviewSourceView.js";
import { ReviewCanvasEditorTabsService } from "./reviewCanvasEditorTabsService.js";
async function closeWelcome(updateNeeded: boolean): Promise {
@@ -34,6 +37,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 +54,78 @@ 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" }; } } 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 = {
+ workspaceUri: `vscode-remote://${HOST}/home/dev/navigator/repo.code-workspace`,
+ fileUri: "vscode-remote://whiteboard%2Bc0ffee/home/dev/repo/src/a%20b.ts",
+ remoteAuthority: HOST,
+};
+
+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 named by the laptop, never by the host", async (t) => {
+ for (const file of ["..\\..\\x.ts", "../../x.ts"]) {
+ const { tabs, opened, written } = sourceTabs(t, url => url.searchParams.has("empty")
+ ? { workspaceUri: remoteAnswer.workspaceUri, remoteAuthority: HOST, emptySide: true }
+ : remoteAnswer);
+ await tabs.openSourceEditor({
+ original: { resource: apiSourceUri({ view: view("wt1"), side: "base", file }, true) },
+ modified: { resource: apiSourceUri({ view: view("wt1"), side: "head", file }) },
+ });
+ 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(toOpen.some(item => item.fileUri?.scheme === "file"), false);
+ assert.equal(options.remoteAuthority, HOST);
+ assert.equal(options.diffMode, true);
+ }
+});
+
+test("references and the source tree open a remote review's window on its host", async (t) => {
+ const { tabs, opened } = sourceTabs(t, () => remoteAnswer);
+ tabs.inputFor({ kind: "api", reviewId: "r1", title: "Fix the parser" });
+ await tabs.openSourceReferences(apiSourceUri({ view: view("wt1"), side: "head", file: "src/a b.ts" }), { lineNumber: 4, column: 5 });
+ await tabs.openApiSource({ reviewId: "r1", kind: "current" }, "Fix the parser");
+ const [references, tree] = opened;
+ assert.deepEqual(references.toOpen.map(item => item.workspaceUri!.toString()), [remote("/home/dev/navigator/repo.code-workspace")]);
+ const { reviewReferencesToShow, remoteAuthority } = references.options as { reviewReferencesToShow: { resource: URI; lineNumber: number; column: number }; remoteAuthority: string };
+ assert.equal(remoteAuthority, HOST);
+ assert.deepEqual({ ...reviewReferencesToShow, resource: reviewReferencesToShow.resource.toString() }, { resource: remote("/home/dev/repo/src/a b.ts"), lineNumber: 4, column: 5 });
+ assert.deepEqual(tree.toOpen.map(item => [item.workspaceUri!.toString(), item.label]), [[remote("/home/dev/navigator/repo.code-workspace"), "Fix the parser"]]);
+ assert.equal(tree.options.remoteAuthority, HOST);
+});
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..1073663a3 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";
@@ -19,11 +24,19 @@ import {
type ReviewCanvasEditorTarget,
} from "../browser/parts/canvas/reviewCanvasEditorInput.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 { IReviewDesktopConnectionService, reviewResponseError } from "./reviewDesktopConnectionService.js";
+type NavigatorAnswer = { workspacePath: string; filePath?: string } | ReviewRemoteNavigatorAnswer;
+
+function workspace(answer: NavigatorAnswer): { workspaceUri: URI; remote: { remoteAuthority?: string } } {
+ return "remoteAuthority" in answer
+ ? { workspaceUri: URI.parse(answer.workspaceUri), remote: { remoteAuthority: answer.remoteAuthority } }
+ : { workspaceUri: URI.file(answer.workspacePath), remote: {} };
+}
+
export const IReviewCanvasEditorTabsService = createDecorator(
"reviewCanvasEditorTabsService",
);
@@ -59,6 +72,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 +124,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 { workspaceUri, remote } = workspace(result);
+ await this.host.openWindow([{ workspaceUri, label: title }], { forceNewWindow: true, ...remote });
}
/** Hand source opens to the native workspace before Review creates an editor group. */
@@ -118,27 +134,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 +163,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 { workspaceUri, remote } = workspace(result);
+ const fileUri = "remoteAuthority" in result
+ ? result.emptySide ? await this.emptyFile() : result.fileUri && URI.parse(result.fileUri)
+ : local ? URI.file(resource.fsPath) : result.filePath && URI.file(result.filePath);
+ if (!fileUri) throw new Error("The navigator did not resolve the source file.");
+ return { workspaceUri, fileUri, remote };
+ }
+
+ 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<{ workspacePath: string; filePath?: string }> {
+ 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}` : ""}`, {
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/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
index 5e389d20d..3241639de 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs
@@ -3,18 +3,21 @@ import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
-import { openHome, openSettings } from "../harness.mjs";
+import { closeSourceWindow, openHome, openSettings } from "../harness.mjs";
import {
alias,
+ assertUriAnswers,
closeDesktop,
createRemoteReview,
masterPid,
onRemote,
prepared,
+ recordNavigator,
recordRequests,
remote,
remoteToken,
runDir,
+ sourcePages,
} from "./remote-host.mjs";
import { removeHost } from "./remote-install.mjs";
@@ -76,6 +79,58 @@ async function freeze() {
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 = {};
@@ -91,55 +146,17 @@ async function journey(ctx) {
timeout,
);
- const canvas = () =>
- ctx.page.locator(".review-canvas-root [data-review-api]");
+ const canvas = () => reviewCanvas(ctx);
- // Docked in the side panel or floating, by the layout.
- const panel = () =>
- ctx.page.locator(
- '[role=complementary][aria-label="Ask"], [role=dialog][aria-label="Ask"]',
- );
+ const panel = () => askPanel(ctx);
const composer = () => panel().getByRole("combobox", { name: "Question" });
- const homeRow = () =>
- ctx.page
- .locator("main.review-home [data-home-row]")
- .filter({ hasText: title });
-
- async function openReview() {
- await openHome(ctx);
- await homeRow().waitFor({ timeout: 60000 });
- await homeRow().getByTitle(title, { exact: true }).click();
- await canvas().getByRole("heading", { name: title }).waitFor({
- timeout: 60000,
- });
- }
+ const homeRow = () => sessionRow(ctx, title);
- /** Selects the document's sentence and opens Ask on it with ⌘L. */
- async function askAboutSentence() {
- // 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 = canvas().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 composer().waitFor({ timeout: 30000 });
- }
+ const openReview = () => openRemoteReview(ctx, title);
+
+ const askAboutSentence = () => askAboutReviewSentence(ctx);
/** Resolves with the ms until the panel shows the answer. */
async function send(text) {
@@ -208,6 +225,9 @@ async function journey(ctx) {
// 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 }];
+ const navigator = [];
+
+ await recordNavigator(ctx.page, navigator);
await openReview();
await askAboutSentence();
@@ -263,28 +283,36 @@ async function journey(ctx) {
`5. OpenCode on ${alias} answered "You asked: …" ${timings.firstAnswer} ms after Ask`,
);
- // 6. A review on another machine has no Source window: the answer's f.ts is text that says so, and a click asks nothing.
- const reference = panel().getByTitle(
- "Source windows are not available for a review on another machine.",
+ // 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$/ });
+
+ const { serverId } = (await ctx.apiOk("/remote-hosts")).find(
+ (host) => host.alias === alias,
);
+ const authority = `whiteboard+${serverId.toLowerCase()}`;
+
await reference.waitFor();
- assert.equal(await reference.innerText(), "f.ts");
- assert.equal(
- await reference.evaluate(
- (element) =>
- element.closest("a, button, [role=link], [role=button]") !== null,
- ),
- false,
- "the f.ts reference is a link or a button",
+ await reference.click();
+
+ const source = await until(
+ () => sourcePages(ctx)[0],
+ "a Source window for the answer's f.ts",
+ 60000,
);
- assert.equal(await panel().getByRole("link", { name: "f.ts" }).count(), 0);
- // Step 11 audits the whole run for the requests a click could send.
- await reference.click();
- ctx.check(
- "6. the answer's f.ts is text titled Source windows are not available…, not a link or button",
+ 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);
@@ -363,17 +391,10 @@ async function journey(ctx) {
);
const allAsks = audit(recorders, remoteTokens);
- assert.deepEqual(
- recorders.flatMap(({ requests: recorded }) =>
- [...recorded.values()].flatMap((r) =>
- /\/(navigator|files)(\?|$)/.test(r.url ?? "") ? [r.url] : [],
- ),
- ),
- [],
- "navigator or files requests",
- );
+ const answers = assertUriAnswers(navigator, authority);
+
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, and no navigator or files request`,
+ `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, and ${answers} navigator answer(s), each with URIs only`,
);
}
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
index 21077540a..ce6952c25 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
@@ -161,6 +161,61 @@ export 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());
+}
+
+/** Pushes every `navigator` answer `page` receives, with its body, to `answers`. */
+export async function recordNavigator(page, answers) {
+ const cdp = await page.context().newCDPSession(page);
+ const urls = new Map();
+
+ cdp.on("Network.responseReceived", ({ requestId, response }) => {
+ if (/\/reviews-api\/[^/]+\/navigator(\?|$)/.test(response.url))
+ urls.set(requestId, { url: response.url, status: response.status });
+ });
+ cdp.on("Network.loadingFinished", ({ requestId }) => {
+ const answer = urls.get(requestId);
+
+ if (answer)
+ cdp
+ .send("Network.getResponseBody", { requestId })
+ .then(({ body }) => answers.push({ ...answer, body }))
+ .catch((error) => answers.push({ ...answer, error: error.message }));
+ });
+ await cdp.send("Network.enable");
+}
+
+/** Asserts each 200 `navigator` answer named URIs on `authority` and no host path; returns how many there were. */
+export function assertUriAnswers(answers, authority) {
+ const bodies = answers.flatMap((answer) =>
+ answer.status === 200 ? [JSON.parse(answer.body)] : [],
+ );
+
+ assert.ok(bodies.length > 0, "no navigator answer was recorded");
+
+ for (const body of bodies) {
+ assert.deepEqual(
+ Object.keys(body).filter((key) => /Path$/.test(key)),
+ [],
+ JSON.stringify(body),
+ );
+ assert.equal(body.remoteAuthority, authority);
+ assert.ok(body.workspaceUri.startsWith(`vscode-remote://${authority}/`));
+ assert.ok(
+ body.emptySide === true ||
+ body.fileUri.startsWith(`vscode-remote://${authority}/`),
+ JSON.stringify(body),
+ );
+ }
+
+ return bodies.length;
+}
+
const DESKTOP_ROUTE =
/^\/(app|control|crash-reports|diffr-config|install|preferences|remote-hosts|telemetry|tutorial)(\/|$)|^\/reviews-api\/[^/]+\/telemetry\//;
@@ -409,12 +464,15 @@ 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 ↗"]');
+
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,15 +483,18 @@ 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.
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..406279bcd
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs
@@ -0,0 +1,754 @@
+/** 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,
+ assertUriAnswers,
+ closeDesktop,
+ onRemote,
+ prepared,
+ recordNavigator,
+ 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 navigator = [];
+
+ 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");
+ await recordNavigator(ctx.page, navigator);
+
+ 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, reconnecting…`;
+
+ 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 recordNavigator(ctx.page, navigator);
+
+ 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. Every navigator answer the UI received named URIs, never host paths.
+ const answers = assertUriAnswers(navigator, authority);
+
+ assert.ok(answers >= 4, `${answers} navigator answers`);
+
+ check(
+ ctx,
+ `16. ${answers} navigator answers reached the UI, each with workspaceUri and fileUri (or emptySide) on ${authority}, none with workspacePath or filePath`,
+ );
+
+ // 17. 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,
+ `17. 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/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/docs/remote-hosts.md b/docs/remote-hosts.md
index 833715da9..eaaad0890 100644
--- a/docs/remote-hosts.md
+++ b/docs/remote-hosts.md
@@ -122,8 +122,8 @@ 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 shows as text, because Source windows are not available for a
-remote review.
+an answer names opens in a Source window on the host; see
+[Source windows](#source-windows).
## Remove Whiteboard from a host
@@ -221,9 +221,30 @@ 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 Source window bound
+to the host. The window shows the host's checkout read-only, with the
+explorer, quick open, text search and the language features above. Its title
+is ` — Source — Whiteboard`.
+
+When the host stops answering, the status bar reads ` — offline,
+reconnecting…` within about 15 seconds, and a warning says the window
+reconnects when the host is back. A window restored while its host is down
+shows the same. The window reconnects by itself, usually within a few seconds
+of the host returning, and the entry goes back to the alias.
+
+Not available in a Source window:
+
+- Terminals and source control.
+- Watching on musl hosts such as Alpine. The explorer refreshes when the
+ window gets focus. On glibc hosts it updates as files change.
+
+Each Source window runs its own extension host on the host, about 130 MB with
+TypeScript. Close windows 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/src/contracts.ts b/packages/review-protocol/src/contracts.ts
index 61eb359e7..b875e34bc 100644
--- a/packages/review-protocol/src/contracts.ts
+++ b/packages/review-protocol/src/contracts.ts
@@ -986,6 +986,15 @@ export interface ReviewGatewayHostState {
declined?: true;
}
+/** `POST /:id/navigator` for a review on another machine: its host paths as
+ * `vscode-remote` URIs. `emptySide`: the diff side has no file; open one locally. */
+export interface ReviewRemoteNavigatorAnswer {
+ workspaceUri: string;
+ fileUri?: string;
+ remoteAuthority: 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/review-api-client.ts b/packages/review-protocol/src/review-api-client.ts
index dfd290424..3a3b00f11 100644
--- a/packages/review-protocol/src/review-api-client.ts
+++ b/packages/review-protocol/src/review-api-client.ts
@@ -51,7 +51,7 @@ export interface ReviewApiSummary {
working?: boolean;
host?: string;
hostState?: Exclude;
- available?: { sourceWindows: boolean; languageFeatures: boolean };
+ available?: { sourceWindows: boolean };
}
/**
diff --git a/packages/review/app/src/api-canvas.test.tsx b/packages/review/app/src/api-canvas.test.tsx
index 219167dbf..ffcddc1f8 100644
--- a/packages/review/app/src/api-canvas.test.tsx
+++ b/packages/review/app/src/api-canvas.test.tsx
@@ -1294,7 +1294,7 @@ it("gives a review on another machine no tutorial controls, even a tutorial revi
async function mountPeekReview(content: {
host?: string;
- available?: { sourceWindows: boolean; languageFeatures: boolean };
+ available?: { sourceWindows: boolean };
}) {
const review = await command({
type: "create",
@@ -1387,19 +1387,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 +1413,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 +1437,26 @@ it.each([
},
);
+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 +1468,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..9c344f1ff 100644
--- a/packages/review/app/src/api-canvas.tsx
+++ b/packages/review/app/src/api-canvas.tsx
@@ -250,26 +250,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 = {
diff --git a/packages/review/app/src/ask-files.test.tsx b/packages/review/app/src/ask-files.test.tsx
index 052da414b..08cb40e04 100644
--- a/packages/review/app/src/ask-files.test.tsx
+++ b/packages/review/app/src/ask-files.test.tsx
@@ -131,7 +131,6 @@ it("shows the files an answer names as text where Source windows are unavailable
session.review!.available = {
sourceWindows: false,
- languageFeatures: false,
};
const entries: AgentEntry[] = [
diff --git a/packages/review/app/src/review-home-view.browser.test.tsx b/packages/review/app/src/review-home-view.browser.test.tsx
index 519d792e5..fc45a138b 100644
--- a/packages/review/app/src/review-home-view.browser.test.tsx
+++ b/packages/review/app/src/review-home-view.browser.test.tsx
@@ -1006,7 +1006,7 @@ function remote(
return summary(title, {
host: "devbox",
hostState: "online",
- available: { sourceWindows: false, languageFeatures: false },
+ available: { sourceWindows: false },
...extra,
});
}
diff --git a/packages/review/src/ask/agents.test.ts b/packages/review/src/ask/agents.test.ts
new file mode 100644
index 000000000..fafb8f329
--- /dev/null
+++ b/packages/review/src/ask/agents.test.ts
@@ -0,0 +1,43 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+import { askAgentTakesMcp } from "./agents.js";
+
+let root: string;
+
+beforeEach(async () => {
+ root = await mkdtemp(path.join(tmpdir(), "review-ask-agents-"));
+ vi.stubEnv("HOME", root);
+ vi.stubEnv("PATH", "/usr/bin:/bin");
+});
+
+afterEach(async () => {
+ vi.unstubAllEnvs();
+ await rm(root, { recursive: true, force: true });
+});
+
+it.skipIf(process.platform === "win32")(
+ "gives MCP to a Pi found, and run, only on the PATH it is handed",
+ async () => {
+ const bin = path.join(root, "login-bin");
+ await mkdir(bin);
+ await writeFile(
+ path.join(bin, "wbtest-node"),
+ '#!/bin/sh\nexec /bin/sh "$@"\n',
+ { mode: 0o755 },
+ );
+ await writeFile(
+ path.join(bin, "pi"),
+ "#!/usr/bin/env wbtest-node\necho 0.99.0\n",
+ { mode: 0o755 },
+ );
+
+ expect(await askAgentTakesMcp("pi", { PATH: `${bin}:/usr/bin:/bin` })).toBe(
+ true,
+ );
+ expect(await askAgentTakesMcp("pi")).toBe(false);
+ },
+);
diff --git a/packages/review/src/ask/agents.ts b/packages/review/src/ask/agents.ts
index 109fc1d3e..f1ecfc761 100644
--- a/packages/review/src/ask/agents.ts
+++ b/packages/review/src/ask/agents.ts
@@ -61,7 +61,7 @@ interface AskAgentSpec {
interface AskAgentMcp {
/** Whether this install can take them. */
- supported(executable: string): Promise;
+ supported(executable: string, env: NodeJS.ProcessEnv): Promise;
/** What the adapter runs in place of the user's CLI to give them, with
* what it needs in `env`. */
launch(
@@ -257,13 +257,16 @@ async function findAgent(
}
/** Whether the agent's model gets the MCP servers its sessions are given. */
-export async function askAgentTakesMcp(agent: AskAgentId): Promise {
+export async function askAgentTakesMcp(
+ agent: AskAgentId,
+ env: NodeJS.ProcessEnv = process.env,
+): Promise {
const spec = askAgents[agent];
if (!spec.mcp) return true;
- const executable = await findAgent(spec);
+ const executable = await findAgent(spec, env);
- return executable !== undefined && spec.mcp.supported(executable);
+ return executable !== undefined && spec.mcp.supported(executable, env);
}
export async function detectAskAgents(
@@ -310,7 +313,9 @@ export const launchAskAgent: AskAgentLauncher = async (
if ("adapter" in launch) {
env[launch.executableEnv] =
- spec.mcp && mcpServers.length && (await spec.mcp.supported(executable))
+ spec.mcp &&
+ mcpServers.length &&
+ (await spec.mcp.supported(executable, env))
? await spec.mcp.launch(executable, mcpServers, env)
: executable;
diff --git a/packages/review/src/ask/pi-mcp.ts b/packages/review/src/ask/pi-mcp.ts
index 4f7de4427..0a1a479f9 100644
--- a/packages/review/src/ask/pi-mcp.ts
+++ b/packages/review/src/ask/pi-mcp.ts
@@ -49,13 +49,14 @@ const versions = new Map<
{ read: number; version: Promise }
>();
-function piVersion(executable: string) {
+function piVersion(executable: string, env: NodeJS.ProcessEnv) {
const cached = versions.get(executable);
if (cached && Date.now() - cached.read < VERSION_TTL_MS)
return cached.version;
const version = promisify(execFile)(executable, ["--version"], {
+ env,
timeout: 5_000,
})
.then(({ stdout }) =>
@@ -69,9 +70,12 @@ function piVersion(executable: string) {
}
/** Whether this Pi can be given MCP servers. */
-export async function piTakesMcp(executable: string): Promise {
+export async function piTakesMcp(
+ executable: string,
+ env: NodeJS.ProcessEnv,
+): Promise {
if (process.platform === "win32") return false;
- const version = await piVersion(executable);
+ const version = await piVersion(executable, env);
if (!version) return false;
diff --git a/packages/review/src/server/review-gateway-list.test.ts b/packages/review/src/server/review-gateway-list.test.ts
index 94fc5343f..fc7d1b6a1 100644
--- a/packages/review/src/server/review-gateway-list.test.ts
+++ b/packages/review/src/server/review-gateway-list.test.ts
@@ -64,11 +64,10 @@ it("lists this machine first, then each machine in the setting's order", () => {
]);
expect(merged[1]?.available).toEqual({
sourceWindows: false,
- languageFeatures: false,
});
});
-it("offers language features only for an online host whose Desktop reported them", () => {
+it("offers language features and Source windows only for an online host whose Desktop reported language features", () => {
const merged = mergeLists(
"structural",
[],
@@ -83,12 +82,10 @@ it("offers language features only for an online host whose Desktop reported them
),
);
- expect(
- merged.map((review) => [review.host, review.available?.languageFeatures]),
- ).toEqual([
- ["a", true],
- ["b", false],
- ["c", false],
+ expect(merged.map((review) => [review.host, review.available])).toEqual([
+ ["a", { sourceWindows: true }],
+ ["b", { sourceWindows: false }],
+ ["c", { sourceWindows: false }],
]);
});
diff --git a/packages/review/src/server/review-gateway-list.ts b/packages/review/src/server/review-gateway-list.ts
index 9be14aee1..ffdc9cef0 100644
--- a/packages/review/src/server/review-gateway-list.ts
+++ b/packages/review/src/server/review-gateway-list.ts
@@ -18,6 +18,7 @@ function decorate(
hostState: NonNullable,
): ReviewApiSummary {
const { alias } = state;
+ const features = hostState === "online" && state.languageFeatures === true;
return {
...entry,
@@ -29,11 +30,7 @@ function decorate(
}),
host: alias,
hostState,
- available: {
- sourceWindows: false,
- languageFeatures:
- hostState === "online" && state.languageFeatures === true,
- },
+ available: { sourceWindows: features },
};
}
diff --git a/packages/review/src/server/review-gateway-streams.test.ts b/packages/review/src/server/review-gateway-streams.test.ts
index d176c46b4..5546154bf 100644
--- a/packages/review/src/server/review-gateway-streams.test.ts
+++ b/packages/review/src/server/review-gateway-streams.test.ts
@@ -186,7 +186,7 @@ it("lists this machine's reviews, then each host's in the setting's order, with
},
host: "wb-a",
hostState: "online",
- available: { sourceWindows: false, languageFeatures: false },
+ available: { sourceWindows: false },
});
const onLocal = merged.find((review) => review.title === "On this machine")!;
diff --git a/packages/review/src/server/review-gateway.test.ts b/packages/review/src/server/review-gateway.test.ts
index 41b2f02af..5aff61e89 100644
--- a/packages/review/src/server/review-gateway.test.ts
+++ b/packages/review/src/server/review-gateway.test.ts
@@ -352,6 +352,138 @@ it("streams a remote answer line by line and closes the remote connection when t
await closed.promise;
});
+/** A remote whose navigator answers `answers[file]` for `?file=`; `gone` with 409. */
+async function startNavigator(
+ answers: Record,
+ serverId = "C0FFEE-42",
+) {
+ const reviewId = randomUUID();
+
+ const fake = await startFake({
+ version,
+ serverId,
+ reviewIds: [reviewId],
+ handle(request, response) {
+ if (!request.url?.startsWith(`/reviews-api/${reviewId}/navigator`))
+ return false;
+
+ const file = new URL(request.url, "http://remote").searchParams.get(
+ "file",
+ );
+
+ response.statusCode = file === "gone" ? 409 : 200;
+ response.setHeader("content-type", "application/json");
+ response.end(JSON.stringify(answers[file ?? ""]));
+
+ return true;
+ },
+ });
+
+ const started = await startGateway([
+ { alias: "wb-a", endpoint: fake.endpoint },
+ ]);
+
+ await expect.poll(() => started.gateway.hosts()[0]?.state).toBe("online");
+
+ const open = (file: string) =>
+ started.request(`/${reviewId}/navigator?file=${file}`, {
+ method: "POST",
+ body: "{}",
+ });
+
+ return { ...started, open };
+}
+
+it("answers a remote navigator with vscode-remote URIs on its host", async () => {
+ const { open } = await startNavigator({
+ file: {
+ workspacePath: "/home/dev/.review/ws/a b.code-workspace",
+ filePath: "/home/dev/repo (1)/src/a#b+c@d!'*.ts",
+ },
+ empty: {
+ workspacePath: "/home/dev/.review/ws/x.code-workspace",
+ emptySide: true,
+ },
+ unicode: { workspacePath: "/home/dév/ws.code-workspace" },
+ });
+
+ const response = await open("file");
+ expect(response.status).toBe(200);
+ expect(response.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
+ expect(await response.json()).toEqual({
+ workspaceUri:
+ "vscode-remote://whiteboard+c0ffee-42/home/dev/.review/ws/a%20b.code-workspace",
+ fileUri:
+ "vscode-remote://whiteboard+c0ffee-42/home/dev/repo%20%281%29/src/a%23b%2Bc%40d%21%27%2A.ts",
+ remoteAuthority: "whiteboard+c0ffee-42",
+ });
+
+ expect(await (await open("empty")).json()).toEqual({
+ workspaceUri:
+ "vscode-remote://whiteboard+c0ffee-42/home/dev/.review/ws/x.code-workspace",
+ remoteAuthority: "whiteboard+c0ffee-42",
+ emptySide: true,
+ });
+
+ expect(await (await open("unicode")).json()).toMatchObject({
+ workspaceUri:
+ "vscode-remote://whiteboard+c0ffee-42/home/d%C3%A9v/ws.code-workspace",
+ });
+});
+
+it.each([
+ ["a relative path", "home/dev/repo/a.ts"],
+ ["a lone surrogate", "/home/dev/\ud800.ts"],
+ ["a scheme", "file:///home/dev/a.ts"],
+ ["a : before the first /", "c:/home/dev/a.ts"],
+ ["no string", 7],
+])(
+ "refuses a remote navigator answer whose path has %s",
+ async (_, hostile) => {
+ const { open } = await startNavigator({
+ good: {
+ workspacePath: "/home/dev/ws.code-workspace",
+ filePath: "/home/dev/a.ts",
+ },
+ workspace: { workspacePath: hostile },
+ file: { workspacePath: "/home/dev/ws.code-workspace", filePath: hostile },
+ });
+
+ expect((await open("good")).status).toBe(200);
+
+ for (const file of ["workspace", "file"]) {
+ const response = await open(file);
+ expect(response.status).toBe(502);
+ expect(response.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
+ const text = await response.text();
+ expect(text).not.toContain("vscode-remote");
+ expect(text).not.toContain(String(hostile));
+ }
+ },
+);
+
+it("refuses a remote navigator answer from a host with an unusable server id", async () => {
+ const id = await startNavigator(
+ { good: { workspacePath: "/home/dev/ws.code-workspace" } },
+ "not_an+authority",
+ );
+
+ expect((await id.open("good")).status).toBe(502);
+});
+
+it("passes a remote navigator's refusal through", async () => {
+ const { open } = await startNavigator({
+ good: { workspacePath: "/home/dev/ws.code-workspace" },
+ gone: { ok: false, error: "The file is gone." },
+ });
+
+ expect((await open("good")).status).toBe(200);
+ expect(await (await open("gone")).json()).toEqual({
+ ok: false,
+ error: "The file is gone.",
+ });
+});
+
it("answers 503 naming the version to install for a review on another version, also from its memory file", async () => {
const a = await startRemote(path.join(root, "a"));
const onA = await seed(a, "On a");
diff --git a/packages/review/src/server/review-gateway.ts b/packages/review/src/server/review-gateway.ts
index 5b1c779dd..0fd007459 100644
--- a/packages/review/src/server/review-gateway.ts
+++ b/packages/review/src/server/review-gateway.ts
@@ -6,6 +6,7 @@ import {
REVIEW_HOST_HEADER,
type ReviewGatewayHost,
type ReviewGatewayHostState,
+ type ReviewRemoteNavigatorAnswer,
parseJsonText,
} from "@dev.fast/review-protocol";
import { Hono } from "hono";
@@ -31,6 +32,7 @@ import {
FIRST_BYTE_TIMEOUT_MS,
errorCode,
errorText,
+ readBody,
remoteHeaders,
send,
} from "./review-gateway-transport.js";
@@ -67,6 +69,7 @@ const REMOTE_ROUTES: readonly RemoteRoute[] = [
{ method: "GET", path: "file" },
{ method: "GET", path: "resources/:name" },
{ method: "GET", path: "maps/:name" },
+ { method: "POST", path: "navigator" },
{ method: "POST", path: "copy-context" },
{ method: "GET", path: "language-context", slow: true },
{ method: "GET", path: "ask/agents" },
@@ -114,6 +117,23 @@ const DROPPED_REQUEST_HEADERS = new Set([
REVIEW_HOST_HEADER,
]);
+/** Absolute POSIX; a `:` before the first `/` (a scheme) is not absolute. */
+const hostPath = z
+ .string()
+ .refine((value) => value.startsWith("/") && value.isWellFormed());
+
+const remoteNavigator = z.object({
+ workspacePath: hostPath,
+ filePath: hostPath.optional(),
+ emptySide: z.literal(true).optional(),
+});
+
+const NAVIGATOR_MAX_BYTES = 1024 * 1024;
+
+/** VS Code's `URI.toString()` path encoding: unreserved characters and `/`
+ * stay, everything else is percent-encoded. */
+const URI_PATH_KEPT = /[A-Za-z0-9\-._~/]/;
+
const commandTarget = z.object({
operation: z.object({ reviewId: z.string().optional() }).optional(),
});
@@ -205,7 +225,7 @@ export function createReviewGateway(input: {
function answer(
alias: string,
status: number,
- body: { ok: boolean; error?: string },
+ body: { ok: boolean; error?: string } | ReviewRemoteNavigatorAnswer,
) {
const response = serverJson(status, body);
response.headers.set(REVIEW_HOST_HEADER, alias);
@@ -282,6 +302,30 @@ export function createReviewGateway(input: {
out.set(REVIEW_HOST_HEADER, remote.alias);
+ // A remote Source window opens the host's paths as vscode-remote URIs.
+ if (route?.path === "navigator" && status === 200) {
+ let uris: ReviewRemoteNavigatorAnswer | undefined;
+
+ try {
+ uris = navigatorUris(
+ await readBody(response.body, NAVIGATOR_MAX_BYTES),
+ remote.serverId,
+ );
+ } catch (error) {
+ return answer(remote.alias, 502, {
+ ok: false,
+ error: `${remote.alias} did not answer: ${errorText(error)}.`,
+ });
+ }
+
+ if (uris) return answer(remote.alias, 200, uris);
+
+ return answer(remote.alias, 502, {
+ ok: false,
+ error: `${remote.alias} answered with a path or server id that cannot be opened.`,
+ });
+ }
+
if (request.method === "HEAD" || status === 204 || status === 304) {
await response.body.dump();
@@ -430,3 +474,50 @@ export function createReviewGateway(input: {
}
export type ReviewGateway = ReturnType;
+
+/** The remote navigator's host paths as `vscode-remote` URIs on its
+ * `whiteboard+` authority, as the fork's `reviewRemoteAuthority`
+ * names it. */
+function navigatorUris(
+ body: Buffer,
+ serverId: string | undefined,
+): ReviewRemoteNavigatorAnswer | undefined {
+ let value;
+
+ try {
+ value = parseJsonText(body.toString());
+ } catch {
+ return undefined;
+ }
+
+ const parsed = remoteNavigator.safeParse(value);
+
+ if (!parsed.success || !serverId || !/^[0-9a-z-]+$/i.test(serverId))
+ return undefined;
+ const { workspacePath, filePath, emptySide } = parsed.data;
+ const remoteAuthority = `whiteboard+${serverId.toLowerCase()}`;
+
+ const uri = (host: string) =>
+ `vscode-remote://${remoteAuthority}${uriPath(host)}`;
+
+ return {
+ workspaceUri: uri(workspacePath),
+ ...(filePath !== undefined && { fileUri: uri(filePath) }),
+ remoteAuthority,
+ ...(emptySide && { emptySide }),
+ };
+}
+
+function uriPath(value: string) {
+ let out = "";
+
+ for (const char of value)
+ out += URI_PATH_KEPT.test(char)
+ ? char
+ : encodeURIComponent(char).replace(
+ /[!'()*]/g,
+ (kept) => `%${kept.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+
+ return out;
+}
diff --git a/packages/review/src/server/review-server-core.ts b/packages/review/src/server/review-server-core.ts
index f910bfdaf..6d6620640 100644
--- a/packages/review/src/server/review-server-core.ts
+++ b/packages/review/src/server/review-server-core.ts
@@ -6,6 +6,7 @@ import type {
import { traceMachineEnabled } from "@dev.fast/trace-core";
import {
type AskAgentLauncher,
+ askAgentTakesMcp,
detectAskAgents,
launchAskAgent,
} from "@review/ask/agents.js";
@@ -162,7 +163,10 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
new AskThreads(
async (agent, cwd, options) =>
launch(agent, cwd, { ...options, env: await ask.env }),
- ask.tools,
+ {
+ takesMcp: async (agent) => askAgentTakesMcp(agent, await ask.env),
+ ...ask.tools,
+ },
);
const closeDeletedThreads = () =>
From 94d941dba966f8e90046f121885f8d462b2da16c Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 10:06:01 -0400
Subject: [PATCH 13/39] Answer a remote caller's empty navigator side with
emptySide
A remote caller asking for a side with no file gets only the host workspace path and emptySide, so the laptop opens a local empty file instead of a vscode-remote path that does not exist.
---
packages/review/src/review-api/http.ts | 21 ++++++----
.../review/src/review-api/local-data.test.ts | 41 +++++++++++++++++++
2 files changed, 54 insertions(+), 8 deletions(-)
diff --git a/packages/review/src/review-api/http.ts b/packages/review/src/review-api/http.ts
index c8d3aa54d..0b6596b06 100644
--- a/packages/review/src/review-api/http.ts
+++ b/packages/review/src/review-api/http.ts
@@ -739,15 +739,20 @@ export function createReviewApi(
})
.parse(context.req.query());
+ const answer = await data.navigatorWorkspace(
+ readReview(context.req.param("id"), input.version),
+ {
+ ...input,
+ empty: input.empty === "true",
+ anchor: queryAnchor(input),
+ },
+ );
+
return context.json(
- await data.navigatorWorkspace(
- readReview(context.req.param("id"), input.version),
- {
- ...input,
- empty: input.empty === "true",
- anchor: queryAnchor(input),
- },
- ),
+ context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE &&
+ input.empty === "true"
+ ? { workspacePath: answer.workspacePath, emptySide: true }
+ : answer,
);
});
app.get("/:id/tree", async (context) => {
diff --git a/packages/review/src/review-api/local-data.test.ts b/packages/review/src/review-api/local-data.test.ts
index 0d0d868a8..9ff3a0581 100644
--- a/packages/review/src/review-api/local-data.test.ts
+++ b/packages/review/src/review-api/local-data.test.ts
@@ -1013,6 +1013,47 @@ it("gives a remote caller a commit review's checkout as remoteRootPath, with the
expect(await read({ [REVIEW_CLIENT_HEADER]: "local" })).toEqual(ownMachine);
});
+it("answers a remote caller's navigator with the host paths a local caller gets", async () => {
+ const { reviewId } = await local.store.execute(
+ command({
+ type: "create",
+ title: "Remote navigator",
+ target: { kind: "commits", ...pins },
+ }),
+ );
+
+ const app = createReviewApi(local.store, local.data);
+
+ const open = async (
+ query: Record,
+ headers: Record = {},
+ ) => {
+ const response = await app.request(
+ `/${reviewId}/navigator?${new URLSearchParams(query)}`,
+ { method: "POST", headers },
+ );
+
+ expect(response.status).toBe(200);
+
+ return response.json();
+ };
+
+ const remote = { [REVIEW_CLIENT_HEADER]: REVIEW_CLIENT_REMOTE };
+ const file = { version: "0", side: "base", file: source.file };
+ const answer = await open(file, remote);
+ const { folders } = JSON.parse(readFileSync(answer.workspacePath, "utf8"));
+ expect(answer).toEqual(await open(file));
+ expect(answer.filePath).toBe(path.join(folders[0].path, source.file));
+ expect(readFileSync(answer.filePath, "utf8")).toContain("value = 1");
+
+ const added = { ...file, file: "added.ts", empty: "true" };
+ expect(await open(added, remote)).toEqual({
+ workspacePath: answer.workspacePath,
+ emptySide: true,
+ });
+ expect((await open(added)).filePath).toEqual(expect.any(String));
+});
+
it("opens a stable native workspace on the Review's pinned checkout at the selected version", async () => {
const { reviewId } = await local.store.execute(
command({
From 124ca22975474643c7135a665d8bf9258403402e Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 12:43:28 -0400
Subject: [PATCH 14/39] Forward the remote navigator's host paths and build
URIs in the fork
A remote caller's navigator answer carries its server id, as language-context does; the gateway forwards it untouched and the Source window opens the paths on whiteboard+ with URI.from.
---
.../reviewCanvasEditorTabsService.test.ts | 45 ++++-----
.../services/reviewCanvasEditorTabsService.ts | 25 ++---
.../scripts/e2e/journeys/remote-ask.mjs | 16 +---
.../scripts/e2e/journeys/remote-host.mjs | 47 ---------
.../e2e/journeys/remote-source-window.mjs | 19 +---
packages/review-protocol/src/contracts.ts | 10 +-
packages/review/src/review-api/http.ts | 14 ++-
.../review/src/review-api/local-data.test.ts | 6 +-
.../review/src/server/review-gateway.test.ts | 96 +++----------------
packages/review/src/server/review-gateway.ts | 92 +-----------------
10 files changed, 69 insertions(+), 301 deletions(-)
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 03557b7d7..4719884b9 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
@@ -78,9 +78,9 @@ function sourceTabs(t: TestContext, answer: (url: URL) => object) {
}
const remoteAnswer = {
- workspaceUri: `vscode-remote://${HOST}/home/dev/navigator/repo.code-workspace`,
- fileUri: "vscode-remote://whiteboard%2Bc0ffee/home/dev/repo/src/a%20b.ts",
- remoteAuthority: HOST,
+ 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) => {
@@ -93,27 +93,24 @@ test("a remote answer opens the host's workspace and file at the position, title
assert.equal(options.remoteAuthority, HOST);
});
-test("a remote diff opens its empty side from a laptop empty file named by the laptop, never by the host", async (t) => {
- for (const file of ["..\\..\\x.ts", "../../x.ts"]) {
- const { tabs, opened, written } = sourceTabs(t, url => url.searchParams.has("empty")
- ? { workspaceUri: remoteAnswer.workspaceUri, remoteAuthority: HOST, emptySide: true }
- : remoteAnswer);
- await tabs.openSourceEditor({
- original: { resource: apiSourceUri({ view: view("wt1"), side: "base", file }, true) },
- modified: { resource: apiSourceUri({ view: view("wt1"), side: "head", file }) },
- });
- 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(toOpen.some(item => item.fileUri?.scheme === "file"), false);
- assert.equal(options.remoteAuthority, HOST);
- assert.equal(options.diffMode, true);
- }
+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("references and the source tree open a remote review's window on its host", async (t) => {
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 1073663a3..16952fca5 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
@@ -27,14 +27,17 @@ import {
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 workspace(answer: NavigatorAnswer): { workspaceUri: URI; remote: { remoteAuthority?: string } } {
- return "remoteAuthority" in answer
- ? { workspaceUri: URI.parse(answer.workspaceUri), remote: { remoteAuthority: answer.remoteAuthority } }
- : { workspaceUri: URI.file(answer.workspacePath), remote: {} };
+/** A remote answer's host paths open on its server's `whiteboard+` authority. */
+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(
@@ -124,8 +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 } : {});
- const { workspaceUri, remote } = workspace(result);
- await this.host.openWindow([{ workspaceUri, label: title }], { forceNewWindow: true, ...remote });
+ 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. */
@@ -163,12 +166,12 @@ export class ReviewCanvasEditorTabsService extends Disposable implements IReview
file: local ? undefined : target.file,
empty: new URLSearchParams(resource.query).has("empty") ? "true" : undefined,
});
- const { workspaceUri, remote } = workspace(result);
- const fileUri = "remoteAuthority" in result
- ? result.emptySide ? await this.emptyFile() : result.fileUri && URI.parse(result.fileUri)
- : local ? URI.file(resource.fsPath) : result.filePath && URI.file(result.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, fileUri, remote };
+ return { workspaceUri: uri(result.workspacePath), fileUri, remote };
}
private async emptyFile(): Promise {
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs
index 3241639de..13cadc30d 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs
@@ -6,13 +6,11 @@ import { promisify } from "node:util";
import { closeSourceWindow, openHome, openSettings } from "../harness.mjs";
import {
alias,
- assertUriAnswers,
closeDesktop,
createRemoteReview,
masterPid,
onRemote,
prepared,
- recordNavigator,
recordRequests,
remote,
remoteToken,
@@ -225,10 +223,6 @@ async function journey(ctx) {
// 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 }];
- const navigator = [];
-
- await recordNavigator(ctx.page, navigator);
-
await openReview();
await askAboutSentence();
ctx.check("3. ⌘L on a selected sentence of the remote review opened Ask");
@@ -288,12 +282,6 @@ async function journey(ctx) {
.getByRole("link")
.filter({ hasText: /^f\.ts$/ });
- const { serverId } = (await ctx.apiOk("/remote-hosts")).find(
- (host) => host.alias === alias,
- );
-
- const authority = `whiteboard+${serverId.toLowerCase()}`;
-
await reference.waitFor();
await reference.click();
@@ -391,10 +379,8 @@ async function journey(ctx) {
);
const allAsks = audit(recorders, remoteTokens);
- const answers = assertUriAnswers(navigator, authority);
-
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, and ${answers} navigator answer(s), each with URIs only`,
+ `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`,
);
}
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
index ce6952c25..c7530f55a 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
@@ -169,53 +169,6 @@ export function sourcePages(ctx) {
.filter((page) => page !== ctx.page && !page.isClosed());
}
-/** Pushes every `navigator` answer `page` receives, with its body, to `answers`. */
-export async function recordNavigator(page, answers) {
- const cdp = await page.context().newCDPSession(page);
- const urls = new Map();
-
- cdp.on("Network.responseReceived", ({ requestId, response }) => {
- if (/\/reviews-api\/[^/]+\/navigator(\?|$)/.test(response.url))
- urls.set(requestId, { url: response.url, status: response.status });
- });
- cdp.on("Network.loadingFinished", ({ requestId }) => {
- const answer = urls.get(requestId);
-
- if (answer)
- cdp
- .send("Network.getResponseBody", { requestId })
- .then(({ body }) => answers.push({ ...answer, body }))
- .catch((error) => answers.push({ ...answer, error: error.message }));
- });
- await cdp.send("Network.enable");
-}
-
-/** Asserts each 200 `navigator` answer named URIs on `authority` and no host path; returns how many there were. */
-export function assertUriAnswers(answers, authority) {
- const bodies = answers.flatMap((answer) =>
- answer.status === 200 ? [JSON.parse(answer.body)] : [],
- );
-
- assert.ok(bodies.length > 0, "no navigator answer was recorded");
-
- for (const body of bodies) {
- assert.deepEqual(
- Object.keys(body).filter((key) => /Path$/.test(key)),
- [],
- JSON.stringify(body),
- );
- assert.equal(body.remoteAuthority, authority);
- assert.ok(body.workspaceUri.startsWith(`vscode-remote://${authority}/`));
- assert.ok(
- body.emptySide === true ||
- body.fileUri.startsWith(`vscode-remote://${authority}/`),
- JSON.stringify(body),
- );
- }
-
- return bodies.length;
-}
-
const DESKTOP_ROUTE =
/^\/(app|control|crash-reports|diffr-config|install|preferences|remote-hosts|telemetry|tutorial)(\/|$)|^\/reviews-api\/[^/]+\/telemetry\//;
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs
index 406279bcd..eed87b1f1 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-source-window.mjs
@@ -11,11 +11,9 @@ import {
} from "./remote-ask.mjs";
import {
alias,
- assertUriAnswers,
closeDesktop,
onRemote,
prepared,
- recordNavigator,
remote,
runDir,
sourcePages,
@@ -153,7 +151,6 @@ const hostEntry = (page) =>
async function journey(ctx) {
const { until } = ctx;
const timings = {};
- const navigator = [];
const hostState = async () =>
(await ctx.apiOk("/remote-hosts")).find((host) => host.alias === alias);
@@ -232,7 +229,6 @@ async function journey(ctx) {
assert.match(reviewId, /^[0-9a-f-]{36}$/, "the remote review's id");
assert.match(secondId, /^[0-9a-f-]{36}$/, "the second review's id");
- await recordNavigator(ctx.page, navigator);
const section = (await openSettings(ctx)).getByRole("region", {
name: "Remote hosts",
@@ -524,7 +520,6 @@ async function journey(ctx) {
.flatMap((c) => c.pages())
.find((page) => page !== restored && page.url().includes("workbench"));
await ctx.watchPage(ctx.page);
- await recordNavigator(ctx.page, navigator);
await until(
async () => (await hostEntry(restored)) === offlineText,
@@ -570,23 +565,13 @@ async function journey(ctx) {
assert.deepEqual(sourcePages(ctx), []);
check(ctx, `15. removing ${alias} in Settings closed its Source window`);
- // 16. Every navigator answer the UI received named URIs, never host paths.
- const answers = assertUriAnswers(navigator, authority);
-
- assert.ok(answers >= 4, `${answers} navigator answers`);
-
- check(
- ctx,
- `16. ${answers} navigator answers reached the UI, each with workspaceUri and fileUri (or emptySide) on ${authority}, none with workspacePath or filePath`,
- );
-
- // 17. The host's extension hosts with one Source window, then two.
+ // 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,
- `17. 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(", ")})`,
+ `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(", ")})`,
);
}
diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts
index b875e34bc..309c6f2de 100644
--- a/packages/review-protocol/src/contracts.ts
+++ b/packages/review-protocol/src/contracts.ts
@@ -986,12 +986,12 @@ export interface ReviewGatewayHostState {
declined?: true;
}
-/** `POST /:id/navigator` for a review on another machine: its host paths as
- * `vscode-remote` URIs. `emptySide`: the diff side has no file; open one locally. */
+/** `POST /:id/navigator` for a remote caller: host paths, and the server they
+ * are on. `emptySide`: the diff side has no file; open one locally. */
export interface ReviewRemoteNavigatorAnswer {
- workspaceUri: string;
- fileUri?: string;
- remoteAuthority: string;
+ workspacePath: string;
+ filePath?: string;
+ serverId: string;
emptySide?: true;
}
diff --git a/packages/review/src/review-api/http.ts b/packages/review/src/review-api/http.ts
index 0b6596b06..9195e6e56 100644
--- a/packages/review/src/review-api/http.ts
+++ b/packages/review/src/review-api/http.ts
@@ -3,6 +3,7 @@ import {
REVIEW_CLIENT_HEADER,
REVIEW_CLIENT_REMOTE,
type ReviewRemoteLanguageEnvironment,
+ type ReviewRemoteNavigatorAnswer,
type ReviewStreamLine,
type ReviewStructuralDiffEvent,
} from "@dev.fast/review-protocol";
@@ -748,12 +749,15 @@ export function createReviewApi(
},
);
- return context.json(
- context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE &&
- input.empty === "true"
+ if (context.req.header(REVIEW_CLIENT_HEADER) !== REVIEW_CLIENT_REMOTE)
+ return context.json(answer);
+
+ return context.json({
+ ...(input.empty === "true"
? { workspacePath: answer.workspacePath, emptySide: true }
- : answer,
- );
+ : answer),
+ serverId: store.serverId(),
+ } satisfies ReviewRemoteNavigatorAnswer);
});
app.get("/:id/tree", async (context) => {
const input = readQuerySchemas.tree.parse(context.req.query());
diff --git a/packages/review/src/review-api/local-data.test.ts b/packages/review/src/review-api/local-data.test.ts
index 9ff3a0581..111280578 100644
--- a/packages/review/src/review-api/local-data.test.ts
+++ b/packages/review/src/review-api/local-data.test.ts
@@ -1013,7 +1013,7 @@ it("gives a remote caller a commit review's checkout as remoteRootPath, with the
expect(await read({ [REVIEW_CLIENT_HEADER]: "local" })).toEqual(ownMachine);
});
-it("answers a remote caller's navigator with the host paths a local caller gets", async () => {
+it("answers a remote caller's navigator with the host paths a local caller gets, and its server id", async () => {
const { reviewId } = await local.store.execute(
command({
type: "create",
@@ -1040,8 +1040,9 @@ it("answers a remote caller's navigator with the host paths a local caller gets"
const remote = { [REVIEW_CLIENT_HEADER]: REVIEW_CLIENT_REMOTE };
const file = { version: "0", side: "base", file: source.file };
- const answer = await open(file, remote);
+ const { serverId, ...answer } = await open(file, remote);
const { folders } = JSON.parse(readFileSync(answer.workspacePath, "utf8"));
+ expect(serverId).toBe(local.store.serverId());
expect(answer).toEqual(await open(file));
expect(answer.filePath).toBe(path.join(folders[0].path, source.file));
expect(readFileSync(answer.filePath, "utf8")).toContain("value = 1");
@@ -1050,6 +1051,7 @@ it("answers a remote caller's navigator with the host paths a local caller gets"
expect(await open(added, remote)).toEqual({
workspacePath: answer.workspacePath,
emptySide: true,
+ serverId,
});
expect((await open(added)).filePath).toEqual(expect.any(String));
});
diff --git a/packages/review/src/server/review-gateway.test.ts b/packages/review/src/server/review-gateway.test.ts
index 5aff61e89..35ec32dab 100644
--- a/packages/review/src/server/review-gateway.test.ts
+++ b/packages/review/src/server/review-gateway.test.ts
@@ -353,15 +353,12 @@ it("streams a remote answer line by line and closes the remote connection when t
});
/** A remote whose navigator answers `answers[file]` for `?file=`; `gone` with 409. */
-async function startNavigator(
- answers: Record,
- serverId = "C0FFEE-42",
-) {
+async function startNavigator(answers: Record) {
const reviewId = randomUUID();
const fake = await startFake({
version,
- serverId,
+ serverId: "c0ffee-42",
reviewIds: [reviewId],
handle(request, response) {
if (!request.url?.startsWith(`/reviews-api/${reviewId}/navigator`))
@@ -394,90 +391,21 @@ async function startNavigator(
return { ...started, open };
}
-it("answers a remote navigator with vscode-remote URIs on its host", async () => {
- const { open } = await startNavigator({
- file: {
- workspacePath: "/home/dev/.review/ws/a b.code-workspace",
- filePath: "/home/dev/repo (1)/src/a#b+c@d!'*.ts",
- },
- empty: {
- workspacePath: "/home/dev/.review/ws/x.code-workspace",
- emptySide: true,
- },
- unicode: { workspacePath: "/home/dév/ws.code-workspace" },
- });
-
- const response = await open("file");
- expect(response.status).toBe(200);
- expect(response.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
- expect(await response.json()).toEqual({
- workspaceUri:
- "vscode-remote://whiteboard+c0ffee-42/home/dev/.review/ws/a%20b.code-workspace",
- fileUri:
- "vscode-remote://whiteboard+c0ffee-42/home/dev/repo%20%281%29/src/a%23b%2Bc%40d%21%27%2A.ts",
- remoteAuthority: "whiteboard+c0ffee-42",
- });
-
- expect(await (await open("empty")).json()).toEqual({
- workspaceUri:
- "vscode-remote://whiteboard+c0ffee-42/home/dev/.review/ws/x.code-workspace",
- remoteAuthority: "whiteboard+c0ffee-42",
- emptySide: true,
- });
-
- expect(await (await open("unicode")).json()).toMatchObject({
- workspaceUri:
- "vscode-remote://whiteboard+c0ffee-42/home/d%C3%A9v/ws.code-workspace",
- });
-});
-
-it.each([
- ["a relative path", "home/dev/repo/a.ts"],
- ["a lone surrogate", "/home/dev/\ud800.ts"],
- ["a scheme", "file:///home/dev/a.ts"],
- ["a : before the first /", "c:/home/dev/a.ts"],
- ["no string", 7],
-])(
- "refuses a remote navigator answer whose path has %s",
- async (_, hostile) => {
- const { open } = await startNavigator({
- good: {
- workspacePath: "/home/dev/ws.code-workspace",
- filePath: "/home/dev/a.ts",
- },
- workspace: { workspacePath: hostile },
- file: { workspacePath: "/home/dev/ws.code-workspace", filePath: hostile },
- });
-
- expect((await open("good")).status).toBe(200);
-
- for (const file of ["workspace", "file"]) {
- const response = await open(file);
- expect(response.status).toBe(502);
- expect(response.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
- const text = await response.text();
- expect(text).not.toContain("vscode-remote");
- expect(text).not.toContain(String(hostile));
- }
- },
-);
-
-it("refuses a remote navigator answer from a host with an unusable server id", async () => {
- const id = await startNavigator(
- { good: { workspacePath: "/home/dev/ws.code-workspace" } },
- "not_an+authority",
- );
-
- expect((await id.open("good")).status).toBe(502);
-});
+it("forwards a remote navigator's answer and refusal untouched", async () => {
+ const file = {
+ workspacePath: "/home/dev/ws.code-workspace",
+ filePath: "/home/dev/a.ts",
+ serverId: "c0ffee-42",
+ };
-it("passes a remote navigator's refusal through", async () => {
const { open } = await startNavigator({
- good: { workspacePath: "/home/dev/ws.code-workspace" },
+ file,
gone: { ok: false, error: "The file is gone." },
});
- expect((await open("good")).status).toBe(200);
+ const response = await open("file");
+ expect(response.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
+ expect(await response.json()).toEqual(file);
expect(await (await open("gone")).json()).toEqual({
ok: false,
error: "The file is gone.",
diff --git a/packages/review/src/server/review-gateway.ts b/packages/review/src/server/review-gateway.ts
index 0fd007459..877e06560 100644
--- a/packages/review/src/server/review-gateway.ts
+++ b/packages/review/src/server/review-gateway.ts
@@ -6,7 +6,6 @@ import {
REVIEW_HOST_HEADER,
type ReviewGatewayHost,
type ReviewGatewayHostState,
- type ReviewRemoteNavigatorAnswer,
parseJsonText,
} from "@dev.fast/review-protocol";
import { Hono } from "hono";
@@ -32,7 +31,6 @@ import {
FIRST_BYTE_TIMEOUT_MS,
errorCode,
errorText,
- readBody,
remoteHeaders,
send,
} from "./review-gateway-transport.js";
@@ -117,23 +115,6 @@ const DROPPED_REQUEST_HEADERS = new Set([
REVIEW_HOST_HEADER,
]);
-/** Absolute POSIX; a `:` before the first `/` (a scheme) is not absolute. */
-const hostPath = z
- .string()
- .refine((value) => value.startsWith("/") && value.isWellFormed());
-
-const remoteNavigator = z.object({
- workspacePath: hostPath,
- filePath: hostPath.optional(),
- emptySide: z.literal(true).optional(),
-});
-
-const NAVIGATOR_MAX_BYTES = 1024 * 1024;
-
-/** VS Code's `URI.toString()` path encoding: unreserved characters and `/`
- * stay, everything else is percent-encoded. */
-const URI_PATH_KEPT = /[A-Za-z0-9\-._~/]/;
-
const commandTarget = z.object({
operation: z.object({ reviewId: z.string().optional() }).optional(),
});
@@ -225,7 +206,7 @@ export function createReviewGateway(input: {
function answer(
alias: string,
status: number,
- body: { ok: boolean; error?: string } | ReviewRemoteNavigatorAnswer,
+ body: { ok: boolean; error?: string },
) {
const response = serverJson(status, body);
response.headers.set(REVIEW_HOST_HEADER, alias);
@@ -302,30 +283,6 @@ export function createReviewGateway(input: {
out.set(REVIEW_HOST_HEADER, remote.alias);
- // A remote Source window opens the host's paths as vscode-remote URIs.
- if (route?.path === "navigator" && status === 200) {
- let uris: ReviewRemoteNavigatorAnswer | undefined;
-
- try {
- uris = navigatorUris(
- await readBody(response.body, NAVIGATOR_MAX_BYTES),
- remote.serverId,
- );
- } catch (error) {
- return answer(remote.alias, 502, {
- ok: false,
- error: `${remote.alias} did not answer: ${errorText(error)}.`,
- });
- }
-
- if (uris) return answer(remote.alias, 200, uris);
-
- return answer(remote.alias, 502, {
- ok: false,
- error: `${remote.alias} answered with a path or server id that cannot be opened.`,
- });
- }
-
if (request.method === "HEAD" || status === 204 || status === 304) {
await response.body.dump();
@@ -474,50 +431,3 @@ export function createReviewGateway(input: {
}
export type ReviewGateway = ReturnType;
-
-/** The remote navigator's host paths as `vscode-remote` URIs on its
- * `whiteboard+` authority, as the fork's `reviewRemoteAuthority`
- * names it. */
-function navigatorUris(
- body: Buffer,
- serverId: string | undefined,
-): ReviewRemoteNavigatorAnswer | undefined {
- let value;
-
- try {
- value = parseJsonText(body.toString());
- } catch {
- return undefined;
- }
-
- const parsed = remoteNavigator.safeParse(value);
-
- if (!parsed.success || !serverId || !/^[0-9a-z-]+$/i.test(serverId))
- return undefined;
- const { workspacePath, filePath, emptySide } = parsed.data;
- const remoteAuthority = `whiteboard+${serverId.toLowerCase()}`;
-
- const uri = (host: string) =>
- `vscode-remote://${remoteAuthority}${uriPath(host)}`;
-
- return {
- workspaceUri: uri(workspacePath),
- ...(filePath !== undefined && { fileUri: uri(filePath) }),
- remoteAuthority,
- ...(emptySide && { emptySide }),
- };
-}
-
-function uriPath(value: string) {
- let out = "";
-
- for (const char of value)
- out += URI_PATH_KEPT.test(char)
- ? char
- : encodeURIComponent(char).replace(
- /[!'()*]/g,
- (kept) => `%${kept.charCodeAt(0).toString(16).toUpperCase()}`,
- );
-
- return out;
-}
From 058e9a56036ec79455fdee473756442e6172ac14 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 12:43:29 -0400
Subject: [PATCH 15/39] Wait on host state changes, not fixed sleeps, in the
Source window test
---
.../reviewSourceWindowHostState.test.ts | 30 ++++++++++---------
1 file changed, 16 insertions(+), 14 deletions(-)
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
index 1d53f47bc..a1a380d75 100644
--- 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
@@ -13,6 +13,7 @@ import type { IStorageService } from "../../../platform/storage/common/storage.j
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 type { ReviewGatewayHostState } from "../../common/reviewProtocol.js";
import { ReviewSourceWindowHostState, showSourceWindowHostState } from "./reviewSourceWindowHostState.js";
import type { ReviewWindowHosts } from "./reviewWindowAuthorityResolver.js";
@@ -34,8 +35,9 @@ function fakes() {
return { close: () => void closed++ };
},
} as unknown as INotificationService;
- let state = "online";
- const hosts: ReviewWindowHosts = { endpoint: async () => undefined, state: async () => ({ alias: "devbox", state }) };
+ let state: ReviewGatewayHostState["state"] = "online";
+ let polls = 0;
+ const hosts: ReviewWindowHosts = { endpoint: async () => undefined, state: async () => (polls++, { alias: "devbox", state }) };
const connection = new Emitter();
return {
statusbar,
@@ -43,33 +45,35 @@ function fakes() {
hosts,
connection: { onDidStateChange: connection.event } as unknown as IRemoteAgentConnection,
fire: (type: PersistentConnectionEventType) => connection.fire({ type } as PersistentConnectionEvent),
- set: (next: string) => void (state = next),
+ set: (next: ReviewGatewayHostState["state"]) => void (state = next),
+ polls: () => polls,
last: () => entries.at(-1),
shown,
closed: () => closed,
};
}
-const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
+async function until(check: () => boolean) {
+ while (!check()) await new Promise((resolve) => setTimeout(resolve, 1));
+}
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, undefined, 5);
try {
- await tick();
- assert.equal(f.last()?.text, "devbox");
+ await until(() => f.last()?.text === "devbox");
assert.notEqual(f.last()?.kind, "warning");
assert.equal(f.shown.length, 0);
f.set("offline");
- await tick();
- assert.equal(f.last()?.text, OFFLINE);
+ await until(() => f.last()?.text === OFFLINE);
assert.equal(f.last()?.kind, "warning");
assert.deepEqual(f.shown.map((n) => [n.message, n.actions]), [["devbox is offline. The window reconnects when it is back.", undefined]]);
f.fire(PersistentConnectionEventType.ConnectionLost);
f.set("online");
- await tick();
+ 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);
@@ -79,8 +83,7 @@ test("shows the alias while online, the offline state within one poll, and resto
assert.equal(f.closed(), 1);
f.set("unreachable");
- await tick();
- assert.equal(f.last()?.text, OFFLINE);
+ await until(() => f.last()?.text === OFFLINE);
assert.equal(f.shown.length, 1, "no second notification in the same window");
} finally {
watch.dispose();
@@ -117,12 +120,11 @@ test("a window restored before main knows its host's alias names it by the alias
return { f, contribution };
};
const first = open({ alias: "wb-test-a", state: "online" });
- await tick();
+ await until(() => first.f.last()?.text === "wb-test-a");
first.contribution.dispose();
const { f, contribution } = open({ state: "offline" });
try {
- await tick();
- assert.equal(f.last()?.text, "wb-test-a — offline, reconnecting…");
+ await until(() => f.last()?.text === "wb-test-a — offline, reconnecting…");
assert.equal(f.shown[0]?.message, "wb-test-a is offline. The window reconnects when it is back.");
} finally {
contribution.dispose();
From 4147ab3ef4686e9a7cbbd48abbe5c8ab171c4622 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 12:45:44 -0400
Subject: [PATCH 16/39] Serve empty extension channels instead of proxying them
in Source windows
The trimmed server answers extensions and extensionGalleryManifest itself, so the workbench keeps its own extension management services.
---
apps/review-desktop/UPSTREAM | 3 +-
.../src/vs/review/navigator.desktop.main.ts | 5 --
...iewRemoteWindowExtensionManagement.test.ts | 35 --------------
.../reviewRemoteWindowExtensionManagement.ts | 46 -------------------
.../server/node/reviewServerHandshake.test.ts | 4 ++
.../src/vs/server/node/serverServices.ts | 13 ++++++
6 files changed, 19 insertions(+), 87 deletions(-)
delete mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts
delete mode 100644 apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts
diff --git a/apps/review-desktop/UPSTREAM b/apps/review-desktop/UPSTREAM
index 56fc5bda8..842cc0b53 100644
--- a/apps/review-desktop/UPSTREAM
+++ b/apps/review-desktop/UPSTREAM
@@ -618,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/navigator.desktop.main.ts b/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts
index af18c18bb..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
@@ -49,9 +49,6 @@ import { CommandsRegistry, ICommandService } from '../platform/commands/common/c
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 { ReviewExtensionGalleryManifestService, ReviewExtensionManagementServerService } from './services/remote/reviewRemoteWindowExtensionManagement.js';
-import { IExtensionManagementServerService } from '../workbench/services/extensionManagement/common/extensionManagement.js';
-import { IExtensionGalleryManifestService } from '../platform/extensionManagement/common/extensionGalleryManifest.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';
@@ -100,8 +97,6 @@ class NavigatorExtensionService extends NativeExtensionService {
}
registerSingleton(IExtensionService, NavigatorExtensionService, InstantiationType.Eager);
-registerSingleton(IExtensionManagementServerService, ReviewExtensionManagementServerService, InstantiationType.Delayed);
-registerSingleton(IExtensionGalleryManifestService, ReviewExtensionGalleryManifestService, InstantiationType.Eager);
/** Every file in a Source window's folders is read-only, whatever a folder's settings say. */
class NavigatorFilesConfigurationService extends FilesConfigurationService {
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts
deleted file mode 100644
index f25962226..000000000
--- a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-import type { IChannel } from "../../../base/parts/ipc/common/ipc.js";
-import type { IInstantiationService } from "../../../platform/instantiation/common/instantiation.js";
-import type { ISharedProcessService } from "../../../platform/ipc/electron-browser/services.js";
-import type { ILabelService } from "../../../platform/label/common/label.js";
-import type { IRemoteAgentService } from "../../../workbench/services/remote/common/remoteAgentService.js";
-import { ReviewExtensionManagementServerService, withoutRemoteExtensionManagement } from "./reviewRemoteWindowExtensionManagement.js";
-
-function remote(authority: string) {
- const asked: string[] = [];
- const channel = (name: string) => ({ name, call: async () => { throw new Error(`Unknown channel: ${name}`); }, listen: () => { throw new Error(`Unknown channel: ${name}`); } });
- const connection = { remoteAuthority: authority, getChannel: (name: string) => (asked.push(name), channel(name)) };
- return { asked, service: { getConnection: () => connection } as unknown as IRemoteAgentService };
-}
-
-function serverService(remoteAgentService: IRemoteAgentService) {
- const created: unknown[][] = [];
- const instantiation = { createInstance: (...args: unknown[]) => (created.push(args), { dispose() { } }) } as unknown as IInstantiationService;
- const sharedProcess = { getChannel: (name: string) => ({ name }) } as unknown as ISharedProcessService;
- const service = new ReviewExtensionManagementServerService(sharedProcess, remoteAgentService, { getHostLabel: () => "wb-test-a" } as unknown as ILabelService, instantiation);
- return { service, remoteChannel: created[1]?.[1] as IChannel };
-}
-
-test("a whiteboard+ window manages no extensions on its server and asks it nothing", async () => {
- const { asked, service: remoteAgent } = remote("whiteboard+abc-1");
- const { service, remoteChannel } = serverService(remoteAgent);
- assert.equal(service.remoteExtensionManagementServer?.label, "wb-test-a");
- assert.deepEqual(await remoteChannel.call("getInstalled", []), []);
- assert.doesNotThrow(() => remoteChannel.listen("onDidInstallExtensions")(() => { }).dispose());
- await assert.rejects(remoteChannel.call("installFromGallery", []), /not managed from a Source window \(installFromGallery\)/);
- await withoutRemoteExtensionManagement(remoteAgent).getConnection()!.getChannel("extensionGalleryManifest").call("setExtensionGalleryManifest", [null]);
- withoutRemoteExtensionManagement(remoteAgent).getConnection()!.getChannel("remoteFilesystem");
- assert.deepEqual(asked, ["remoteFilesystem"]);
-});
diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts
deleted file mode 100644
index 6b72549a0..000000000
--- a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteWindowExtensionManagement.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) dev.fast. All rights reserved.
- * Licensed under the MIT License. See LICENSE in the repository root for license information.
- *--------------------------------------------------------------------------------------------*/
-
-import { Event } from "../../../base/common/event.js";
-import type { IChannel } from "../../../base/parts/ipc/common/ipc.js";
-import { WorkbenchExtensionGalleryManifestService } from "../../../workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.js";
-import { ExtensionManagementServerService } from "../../../workbench/services/extensionManagement/electron-browser/extensionManagementServerService.js";
-import type { IRemoteAgentService } from "../../../workbench/services/remote/common/remoteAgentService.js";
-import { isReviewRemoteAuthority, override } from "./reviewRemoteAuthority.js";
-
-const UNSERVED = new Set(["extensions", "extensionGalleryManifest"]);
-
-/** The trimmed server manages no extensions. */
-const NO_EXTENSIONS: IChannel = {
- call: async (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.None as Event,
-};
-
-export function withoutRemoteExtensionManagement(remoteAgentService: IRemoteAgentService): IRemoteAgentService {
- const connection = remoteAgentService.getConnection();
- if (!connection || !isReviewRemoteAuthority(connection.remoteAuthority)) return remoteAgentService;
- const quiet = override(connection, {
- getChannel: (name: string) => (UNSERVED.has(name) ? NO_EXTENSIONS : connection.getChannel(name)) as T,
- });
- return override(remoteAgentService, { getConnection: () => quiet });
-}
-
-export class ReviewExtensionManagementServerService extends ExtensionManagementServerService {
- constructor(...args: ConstructorParameters) {
- args[1] = withoutRemoteExtensionManagement(args[1]);
- super(...args);
- }
-}
-
-export class ReviewExtensionGalleryManifestService extends WorkbenchExtensionGalleryManifestService {
- constructor(...args: ConstructorParameters) {
- args[5] = withoutRemoteExtensionManagement(args[5]);
- super(...args);
- }
-}
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..adc33cf91 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; Source windows list no extensions.
+ 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 {
From c074a0786b20ceaf9ffe48becc82d89a892eb39a Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 15:50:12 -0400
Subject: [PATCH 17/39] Drop duplicate Source window tests and trim comments
---
.../reviewSourceWindowHostState.test.ts | 14 -----
.../reviewCanvasEditorTabsService.test.ts | 14 -----
.../services/reviewCanvasEditorTabsService.ts | 1 -
.../src/vs/server/node/serverServices.ts | 2 +-
packages/review-protocol/src/contracts.ts | 3 +-
.../review/src/server/review-gateway.test.ts | 60 -------------------
6 files changed, 2 insertions(+), 92 deletions(-)
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
index a1a380d75..d3888d248 100644
--- 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
@@ -90,20 +90,6 @@ test("shows the alias while online, the offline state within one poll, and resto
}
});
-test("a window on another authority shows nothing and never asks main", () => {
- for (const remoteAuthority of [undefined, "ssh-remote+x", "Whiteboard+ABC"]) {
- const contribution = new ReviewSourceWindowHostState(
- { remoteAuthority } as IWorkbenchEnvironmentService,
- { getChannel: () => assert.fail("asked main") } as unknown as IMainProcessService,
- { addEntry: () => assert.fail("added an entry") } as unknown as IStatusbarService,
- { notify: () => assert.fail("notified") } as unknown as INotificationService,
- { getConnection: () => null } as unknown as IRemoteAgentService,
- { get: () => assert.fail("read storage") } as unknown as IStorageService,
- );
- contribution.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;
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 4719884b9..a8243ae5b 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
@@ -112,17 +112,3 @@ test("a remote diff opens its empty side from a laptop empty file", async (t) =>
assert.equal(options.remoteAuthority, HOST);
assert.equal(options.diffMode, true);
});
-
-test("references and the source tree open a remote review's window on its host", async (t) => {
- const { tabs, opened } = sourceTabs(t, () => remoteAnswer);
- tabs.inputFor({ kind: "api", reviewId: "r1", title: "Fix the parser" });
- await tabs.openSourceReferences(apiSourceUri({ view: view("wt1"), side: "head", file: "src/a b.ts" }), { lineNumber: 4, column: 5 });
- await tabs.openApiSource({ reviewId: "r1", kind: "current" }, "Fix the parser");
- const [references, tree] = opened;
- assert.deepEqual(references.toOpen.map(item => item.workspaceUri!.toString()), [remote("/home/dev/navigator/repo.code-workspace")]);
- const { reviewReferencesToShow, remoteAuthority } = references.options as { reviewReferencesToShow: { resource: URI; lineNumber: number; column: number }; remoteAuthority: string };
- assert.equal(remoteAuthority, HOST);
- assert.deepEqual({ ...reviewReferencesToShow, resource: reviewReferencesToShow.resource.toString() }, { resource: remote("/home/dev/repo/src/a b.ts"), lineNumber: 4, column: 5 });
- assert.deepEqual(tree.toOpen.map(item => [item.workspaceUri!.toString(), item.label]), [[remote("/home/dev/navigator/repo.code-workspace"), "Fix the parser"]]);
- assert.equal(tree.options.remoteAuthority, HOST);
-});
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 16952fca5..fcf934483 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
@@ -32,7 +32,6 @@ import { IReviewDesktopConnectionService, reviewResponseError } from "./reviewDe
type NavigatorAnswer = { workspacePath: string; filePath?: string } | ReviewRemoteNavigatorAnswer;
-/** A remote answer's host paths open on its server's `whiteboard+` authority. */
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);
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 adc33cf91..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,7 +139,7 @@ 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; Source windows list no extensions.
+ // Whiteboard: extension management is cut.
socketServer.registerChannel('extensions', new NoExtensionsChannel());
socketServer.registerChannel('extensionGalleryManifest', new NoExtensionsChannel());
diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts
index 309c6f2de..52eb6b5f2 100644
--- a/packages/review-protocol/src/contracts.ts
+++ b/packages/review-protocol/src/contracts.ts
@@ -986,8 +986,7 @@ export interface ReviewGatewayHostState {
declined?: true;
}
-/** `POST /:id/navigator` for a remote caller: host paths, and the server they
- * are on. `emptySide`: the diff side has no file; open one locally. */
+/** `emptySide`: the diff side has no file; open one locally. */
export interface ReviewRemoteNavigatorAnswer {
workspacePath: string;
filePath?: string;
diff --git a/packages/review/src/server/review-gateway.test.ts b/packages/review/src/server/review-gateway.test.ts
index 35ec32dab..41b2f02af 100644
--- a/packages/review/src/server/review-gateway.test.ts
+++ b/packages/review/src/server/review-gateway.test.ts
@@ -352,66 +352,6 @@ it("streams a remote answer line by line and closes the remote connection when t
await closed.promise;
});
-/** A remote whose navigator answers `answers[file]` for `?file=`; `gone` with 409. */
-async function startNavigator(answers: Record) {
- const reviewId = randomUUID();
-
- const fake = await startFake({
- version,
- serverId: "c0ffee-42",
- reviewIds: [reviewId],
- handle(request, response) {
- if (!request.url?.startsWith(`/reviews-api/${reviewId}/navigator`))
- return false;
-
- const file = new URL(request.url, "http://remote").searchParams.get(
- "file",
- );
-
- response.statusCode = file === "gone" ? 409 : 200;
- response.setHeader("content-type", "application/json");
- response.end(JSON.stringify(answers[file ?? ""]));
-
- return true;
- },
- });
-
- const started = await startGateway([
- { alias: "wb-a", endpoint: fake.endpoint },
- ]);
-
- await expect.poll(() => started.gateway.hosts()[0]?.state).toBe("online");
-
- const open = (file: string) =>
- started.request(`/${reviewId}/navigator?file=${file}`, {
- method: "POST",
- body: "{}",
- });
-
- return { ...started, open };
-}
-
-it("forwards a remote navigator's answer and refusal untouched", async () => {
- const file = {
- workspacePath: "/home/dev/ws.code-workspace",
- filePath: "/home/dev/a.ts",
- serverId: "c0ffee-42",
- };
-
- const { open } = await startNavigator({
- file,
- gone: { ok: false, error: "The file is gone." },
- });
-
- const response = await open("file");
- expect(response.headers.get(REVIEW_HOST_HEADER)).toBe("wb-a");
- expect(await response.json()).toEqual(file);
- expect(await (await open("gone")).json()).toEqual({
- ok: false,
- error: "The file is gone.",
- });
-});
-
it("answers 503 naming the version to install for a review on another version, also from its memory file", async () => {
const a = await startRemote(path.join(root, "a"));
const onA = await seed(a, "On a");
From 3708ab5bc0f93e01ef2d4aae4f0599e626a12838 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Wed, 14 Oct 2026 15:55:25 -0400
Subject: [PATCH 18/39] Shorten the Source windows docs
---
docs/remote-hosts.md | 21 ++++++++-------------
1 file changed, 8 insertions(+), 13 deletions(-)
diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md
index eaaad0890..e6832ea4b 100644
--- a/docs/remote-hosts.md
+++ b/docs/remote-hosts.md
@@ -223,25 +223,20 @@ login shell (`~/.profile` or your shell's own start-up file is enough):
## Source windows
-"Open file", the source tree and Ask's file links open a Source window bound
-to the host. The window shows the host's checkout read-only, with the
-explorer, quick open, text search and the language features above. Its title
-is ` — Source — Whiteboard`.
+"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 reads ` — offline,
-reconnecting…` within about 15 seconds, and a warning says the window
-reconnects when the host is back. A window restored while its host is down
-shows the same. The window reconnects by itself, usually within a few seconds
-of the host returning, and the entry goes back to the alias.
+When the host stops answering, the status bar says so. The window reconnects
+when the host is back.
Not available in a Source window:
- Terminals and source control.
-- Watching on musl hosts such as Alpine. The explorer refreshes when the
- window gets focus. On glibc hosts it updates as files change.
+- 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, about 130 MB with
-TypeScript. Close windows you are done with.
+Each Source window runs its own extension host on the host. Close the ones you
+are done with.
## Not available for remote reviews yet
From 0c7843ef2ceca815f951d1b067dcc7dfa6e920f0 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Tue, 6 Oct 2026 13:05:28 -0400
Subject: [PATCH 19/39] Let a review client reopen its shared stream at once
The shared review stream waits up to 30 seconds between reconnect
attempts. reconnect() starts a new attempt now, with a fresh backoff.
Agent-Session: 13cd2035-0029-45cb-a2c9-65f0c01b6460
Agent-Session: e9b9e23e-de55-4291-9391-cd234f736397
Agent-Session: 5368a6f5-df0c-4411-8eec-4b9cccbfbef0
Agent-Session: f086ac47-f054-41fb-8279-be4df7aa2dee
Agent-Session: 3a3376c5-3f72-46df-930c-a66d5c3bcce5
Agent-Session: 7633c50b-998d-4b1e-8ca2-ffb350a26066
Agent-Session: 9a4d92ed-7a0d-42a1-9169-574bca60095b
Agent-Session: 1c093463-922b-435b-a925-664d5d5c3c0a
Agent-Session: 2b51b153-90b5-4a72-a052-fccf0beb9f1c
---
packages/review-protocol/src/review-api-client.ts | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/packages/review-protocol/src/review-api-client.ts b/packages/review-protocol/src/review-api-client.ts
index 3a3b00f11..a6c85591f 100644
--- a/packages/review-protocol/src/review-api-client.ts
+++ b/packages/review-protocol/src/review-api-client.ts
@@ -206,11 +206,7 @@ export class ReviewApiClient {
if (!connections)
liveConnections.set(this.request, (connections = new Map()));
- const key = JSON.stringify([
- this.connection.serverUrl,
- this.connection.token,
- ]);
-
+ const key = this.liveKey();
let live = connections.get(key);
if (!live) {
@@ -230,6 +226,13 @@ export class ReviewApiClient {
disconnected,
);
}
+ /** Reopen the shared stream now rather than after its backoff. */
+ reconnect() {
+ liveConnections.get(this.request)?.get(this.liveKey())?.reconnect();
+ }
+ private liveKey() {
+ return JSON.stringify([this.connection.serverUrl, this.connection.token]);
+ }
}
type Result =
@@ -342,7 +345,7 @@ class LiveConnection {
}, restartDelayMs);
}
- private reconnect() {
+ reconnect() {
this.abort.abort();
this.abort = new AbortController();
const listeners = [...this.listeners];
From c7e9fe6b730c6efbdf4433192f4dc7cd05e80afd Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Tue, 6 Oct 2026 14:32:29 -0400
Subject: [PATCH 20/39] Word each remote host state once, with its next step
reviewHostStatus gives every host state one short status, one label, a
sentence and the action that helps: Retry, Install or Settings. The
gateway uses its sentence when a down host has no detail, so the window
no longer reads "alex-box is auth-failed.".
The gateway also stops saying "did not answer: it did not answer", and
an unknown network error reads "it failed (CODE)", not the bare code.
Agent-Session: 5368a6f5-df0c-4411-8eec-4b9cccbfbef0
Agent-Session: f086ac47-f054-41fb-8279-be4df7aa2dee
Agent-Session: 3a3376c5-3f72-46df-930c-a66d5c3bcce5
Agent-Session: 7633c50b-998d-4b1e-8ca2-ffb350a26066
Agent-Session: 9a4d92ed-7a0d-42a1-9169-574bca60095b
Agent-Session: 1c093463-922b-435b-a925-664d5d5c3c0a
Agent-Session: 2b51b153-90b5-4a72-a052-fccf0beb9f1c
---
packages/review-protocol/src/index.ts | 2 +
.../review-protocol/src/review-host-status.ts | 81 +++++++++++++++++++
.../src/server/review-gateway-streams.ts | 3 +-
.../src/server/review-gateway-transport.ts | 3 +-
packages/review/src/server/review-gateway.ts | 2 +-
5 files changed, 88 insertions(+), 3 deletions(-)
create mode 100644 packages/review-protocol/src/review-host-status.ts
diff --git a/packages/review-protocol/src/index.ts b/packages/review-protocol/src/index.ts
index 0d171294f..d720cfe1b 100644
--- a/packages/review-protocol/src/index.ts
+++ b/packages/review-protocol/src/index.ts
@@ -56,6 +56,8 @@ export * from "./contracts.js";
export * from "./review-api-client.js";
+export * from "./review-host-status.js";
+
export {
type ByCommitEntry,
type ReviewAgentTraceEvent,
diff --git a/packages/review-protocol/src/review-host-status.ts b/packages/review-protocol/src/review-host-status.ts
new file mode 100644
index 000000000..0d4972331
--- /dev/null
+++ b/packages/review-protocol/src/review-host-status.ts
@@ -0,0 +1,81 @@
+import type { ReviewGatewayHostState } from "./contracts.js";
+
+export interface ReviewHostStatus {
+ /** Short words for a chip or a list row, without the host name. */
+ status: string;
+ /** The same with the host name, for a heading. */
+ label: string;
+ sentence: string;
+ /** What helps: none while the host is busy or online. */
+ action?: "retry" | "install" | "settings";
+ busy?: true;
+}
+
+const STATUS: Record<
+ ReviewGatewayHostState["state"],
+ Pick & {
+ status: string;
+ label(alias: string): string;
+ }
+> = {
+ online: { status: "online", label: (alias) => alias },
+ connecting: {
+ status: "connecting",
+ label: (alias) => `Connecting to ${alias}…`,
+ busy: true,
+ },
+ installing: {
+ status: "installing",
+ label: (alias) => `Installing on ${alias}…`,
+ busy: true,
+ },
+ offline: {
+ status: "offline",
+ label: (alias) => `${alias} offline`,
+ action: "retry",
+ },
+ unreachable: {
+ status: "offline",
+ label: (alias) => `${alias} offline`,
+ action: "retry",
+ },
+ incompatible: {
+ status: "needs an update",
+ label: (alias) => `${alias} needs an update`,
+ action: "install",
+ },
+ "not-installed": {
+ status: "not installed",
+ label: (alias) => `Whiteboard not installed on ${alias}`,
+ action: "install",
+ },
+ "auth-failed": {
+ status: "can't sign in",
+ label: (alias) => `Can't sign in to ${alias}`,
+ action: "settings",
+ },
+ unsupported: {
+ status: "can't be used",
+ label: (alias) => `${alias} can't be used`,
+ action: "settings",
+ },
+ duplicate: {
+ status: "can't be used",
+ label: (alias) => `${alias} can't be used`,
+ action: "settings",
+ },
+};
+
+/** One wording and one next step per host state, shared by every surface. */
+export function reviewHostStatus(
+ host: Pick,
+): ReviewHostStatus {
+ const { label, ...rest } = STATUS[host.state];
+ const words = label(host.alias);
+
+ return {
+ ...rest,
+ label: words,
+ sentence: host.detail ?? (words.endsWith("…") ? words : `${words}.`),
+ };
+}
diff --git a/packages/review/src/server/review-gateway-streams.ts b/packages/review/src/server/review-gateway-streams.ts
index 1edcb128b..2e0b1a7c3 100644
--- a/packages/review/src/server/review-gateway-streams.ts
+++ b/packages/review/src/server/review-gateway-streams.ts
@@ -5,6 +5,7 @@ import {
type ReviewApiSummary,
type ReviewGatewayHostState,
parseJsonText,
+ reviewHostStatus,
} from "@dev.fast/review-protocol";
import { coverageModeSchema } from "@review/review-api/review-progress.js";
import { z } from "zod";
@@ -47,7 +48,7 @@ export type Located =
| undefined;
export const downDetail = (down: ReviewGatewayHostState) =>
- down.detail ?? `${down.alias} is ${down.state}.`;
+ reviewHostStatus(down).sentence;
const watchPath = (subscriptions: object[]) =>
`/reviews-api/watch?subscriptions=${encodeURIComponent(JSON.stringify(subscriptions))}`;
diff --git a/packages/review/src/server/review-gateway-transport.ts b/packages/review/src/server/review-gateway-transport.ts
index ea47c81ab..6ec8da389 100644
--- a/packages/review/src/server/review-gateway-transport.ts
+++ b/packages/review/src/server/review-gateway-transport.ts
@@ -182,6 +182,7 @@ const ERROR_WORDS = new Map([
["ECONNREFUSED", "it refused the connection"],
["ECONNRESET", "it closed the connection"],
["UND_ERR_SOCKET", "it closed the connection"],
+ ["EPIPE", "it closed the connection"],
["ETIMEDOUT", "it did not answer"],
["UND_ERR_HEADERS_TIMEOUT", NO_ANSWER],
]);
@@ -199,5 +200,5 @@ export function errorCode(cause: unknown): string {
export function errorText(cause: unknown): string {
const code = errorCode(cause);
- return ERROR_WORDS.get(code) ?? code;
+ return ERROR_WORDS.get(code) ?? `it failed (${code})`;
}
diff --git a/packages/review/src/server/review-gateway.ts b/packages/review/src/server/review-gateway.ts
index 877e06560..55a5026ab 100644
--- a/packages/review/src/server/review-gateway.ts
+++ b/packages/review/src/server/review-gateway.ts
@@ -266,7 +266,7 @@ export function createReviewGateway(input: {
return answer(remote.alias, timedOut ? 504 : 502, {
ok: false,
- error: `${remote.alias} did not answer: ${reason}.`,
+ error: `Could not reach ${remote.alias}: ${reason}.`,
});
}
From 679c72df8c70ac499ad671859705f44655cb48da Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Tue, 6 Oct 2026 13:05:28 -0400
Subject: [PATCH 21/39] Show a remote host's state and next step on its reviews
A dropped review stream shows a yellow chip in the top bar, such as
"alex-box offline", instead of the "Connection lost" banner. A click
runs the step that helps: Retry, Install or Open Settings. Retry also
reopens the review stream at once.
A remote review that has not loaded shows the same, not a blank page.
On Home, an unavailable review says why, offers the step, and opens
once its host is back.
Agent-Session: 5368a6f5-df0c-4411-8eec-4b9cccbfbef0
Agent-Session: f086ac47-f054-41fb-8279-be4df7aa2dee
Agent-Session: 3a3376c5-3f72-46df-930c-a66d5c3bcce5
Agent-Session: 7633c50b-998d-4b1e-8ca2-ffb350a26066
Agent-Session: 9a4d92ed-7a0d-42a1-9169-574bca60095b
Agent-Session: 1c093463-922b-435b-a925-664d5d5c3c0a
Agent-Session: 2b51b153-90b5-4a72-a052-fccf0beb9f1c
---
.../browser/parts/canvas/reviewCanvasPart.ts | 15 +-
apps/review-desktop/scripts/e2e/TESTING.md | 2 +-
.../scripts/e2e/journeys/remote-host.mjs | 31 ++-
packages/review-protocol/src/contracts.ts | 11 +-
packages/review/app/src/api-canvas.test.tsx | 169 ++++++++++++++
packages/review/app/src/api-canvas.tsx | 97 +++++---
.../review/app/src/authoring-activity.tsx | 4 +
packages/review/app/src/connection-chip.tsx | 217 ++++++++++++++++++
packages/review/app/src/desktop-entry.tsx | 2 +-
packages/review/app/src/remote-host-state.ts | 108 +++++++++
packages/review/app/src/review-home-styles.ts | 6 +
.../app/src/review-home-view.browser.test.tsx | 52 +++--
packages/review/app/src/review-home-view.tsx | 97 +++++---
packages/review/app/src/shell-styles.ts | 4 +-
14 files changed, 709 insertions(+), 106 deletions(-)
create mode 100644 packages/review/app/src/connection-chip.tsx
create mode 100644 packages/review/app/src/remote-host-state.ts
diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts
index 86d019584..0017db9fe 100644
--- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts
@@ -71,6 +71,7 @@ import type {
ReviewDocumentWidthChoice,
ReviewKeymapChoice,
ReviewReadyNotificationChoice,
+ ReviewRemoteHostActions,
ReviewRuntimeConfig,
ReviewSurfaceEvent,
ReviewTheme,
@@ -459,6 +460,7 @@ export class ReviewCanvasEditorPane extends EditorPane {
kind: "api",
reviewId,
...remoteEntry(this.apiCatalog.reviews.find((review) => review.reviewId === reviewId)),
+ remoteHosts: this.remoteHostActions(),
structuralDiffEnabled: this.currentStructuralDiffEnabled(),
softwareMapEnabled: this.currentSoftwareMapEnabled(),
documentWidth: this.currentDocumentWidth(),
@@ -574,7 +576,7 @@ export class ReviewCanvasEditorPane extends EditorPane {
this.reviewTelemetryService.capture("review_restored", { via: "home" });
return this.apiCatalog.lifecycle(uuid, "active");
},
- hostStates: () => this.desktopConnection.readRemoteHosts(),
+ remoteHosts: this.remoteHostActions(),
openSourceTree: (uuid) => {
const api = this.apiCatalog.reviews.find((review) => review.reviewId === uuid);
if (api) {
@@ -642,6 +644,17 @@ export class ReviewCanvasEditorPane extends EditorPane {
}
}
+ private remoteHostActions(): ReviewRemoteHostActions {
+ return {
+ states: () => this.desktopConnection.readRemoteHosts(),
+ retry: (alias) => this.desktopConnection.retryRemoteHost(alias),
+ install: (alias) => this.desktopConnection.installRemoteHost(alias),
+ openSettings: async () => {
+ await this.tabsService.openSettings(true);
+ },
+ };
+ }
+
async refreshComparison(): Promise {
if (!(this.input instanceof ReviewCanvasEditorInput) || this.input.target.kind !== "api") return;
this.readyInput = undefined;
diff --git a/apps/review-desktop/scripts/e2e/TESTING.md b/apps/review-desktop/scripts/e2e/TESTING.md
index fbb75eeba..7a7be589b 100644
--- a/apps/review-desktop/scripts/e2e/TESTING.md
+++ b/apps/review-desktop/scripts/e2e/TESTING.md
@@ -123,7 +123,7 @@ key, and the journey adds `wb-test-a` in Settings as a user would. It checks,
on the DOM and on the page's requests: the host goes `online`; Home lists the
container's review as `wb-test-a: wbrepo`; the document, a code peek, the Diff
view and the structural diff load; an edit and a `session_create` on the
-remote reach the window; a killed `ssh` master shows "Connection lost" and
+remote reach the window; a killed `ssh` master shows a chip in the top bar and
recovers without a reload; a stopped server is `offline` within 15 s while a
laptop review still opens; another package version is `incompatible` with the
install command in Settings; and removing the host takes its reviews out of
diff --git a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
index c7530f55a..b0bbf8ff4 100644
--- a/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
@@ -454,17 +454,13 @@ async function journey(ctx, page, until) {
await tab(title).click();
await canvas.getByRole("heading", { name: title }).waitFor();
await page.evaluate(() => {
- const seen = (window.__remoteHostBanner = []);
+ const seen = (window.__remoteHostChip = []);
new MutationObserver(() => {
- for (const status of document.querySelectorAll(
- ".review-canvas-root [role=status]",
+ for (const chip of document.querySelectorAll(
+ ".review-canvas-root .connection-chip",
))
- if (
- status.textContent.startsWith("Connection lost") &&
- !seen.includes(status.textContent)
- )
- seen.push(status.textContent);
+ if (!seen.includes(chip.textContent)) seen.push(chip.textContent);
}).observe(document.body, {
subtree: true,
childList: true,
@@ -477,10 +473,14 @@ async function journey(ctx, page, until) {
assert.ok(master, "no ssh master for the host");
process.kill(master, "SIGKILL");
await until(
- () => page.evaluate(() => window.__remoteHostBanner.length > 0),
- "the Connection lost banner",
+ () => page.evaluate(() => window.__remoteHostChip.length > 0),
+ "the disconnected chip",
30000,
);
+ assert.equal(
+ await canvas.locator("[role=status]").filter({ hasText: "Connection lost" }).count(),
+ 0,
+ );
await until(
async () =>
(await hostState())?.state === "online" && (await masterPid()) !== master,
@@ -489,11 +489,8 @@ async function journey(ctx, page, until) {
);
await until(
async () =>
- (await canvas
- .locator("[role=status]")
- .filter({ hasText: "Connection lost" })
- .count()) === 0,
- "the banner to clear",
+ (await canvas.locator(".connection-chip").count()) === 0,
+ "the chip to clear",
);
await remoteApi("session_edit", {
sessionId: reviewId,
@@ -505,7 +502,7 @@ async function journey(ctx, page, until) {
await canvas.getByText("After the reconnect.").waitFor({ timeout: 30000 });
assert.equal(await page.evaluate(() => performance.timeOrigin), timeOrigin);
ctx.check(
- `6. a killed ssh master showed "${await page.evaluate(() => window.__remoteHostBanner[0])}" and the review recovered in the same page`,
+ `6. a killed ssh master showed "${await page.evaluate(() => window.__remoteHostChip[0])}" in the top bar and the review recovered in the same page`,
);
// 7. A hung server: offline within 15 s, and a laptop review still opens at once.
@@ -558,7 +555,7 @@ async function journey(ctx, page, until) {
for (const text of [title, second]) {
await row(text).waitFor();
assert.equal(await row(text).getAttribute("data-unavailable"), "");
- assert.match(await row(text).innerText(), /incompatible/);
+ assert.match(await row(text).innerText(), /needs an update/);
}
ctx.check(
diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts
index 52eb6b5f2..2effb2e96 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[];
@@ -756,6 +764,7 @@ export type ReviewCanvasContent =
version?: number;
host?: string;
available?: ReviewApiSummary["available"];
+ remoteHosts?: ReviewRemoteHostActions;
bridge: ReviewCanvasBridge;
setTitle?(title: string): void;
setSourceView?(
@@ -790,7 +799,7 @@ export type ReviewCanvasContent =
// Boards in Trash can be restored for 30 days or deleted.
deleteReview?(uuid: string): Promise;
restoreReview?(uuid: string): Promise;
- hostStates?(): Promise;
+ remoteHosts?: ReviewRemoteHostActions;
// Opens the review and pins its read-only source tree open. Absent when
// the host cannot show the tree.
openSourceTree?(uuid: string): void;
diff --git a/packages/review/app/src/api-canvas.test.tsx b/packages/review/app/src/api-canvas.test.tsx
index ffcddc1f8..a83080902 100644
--- a/packages/review/app/src/api-canvas.test.tsx
+++ b/packages/review/app/src/api-canvas.test.tsx
@@ -1292,6 +1292,175 @@ it("gives a review on another machine no tutorial controls, even a tutorial revi
expect(tutorial).not.toHaveBeenCalledWith(true);
});
+it("shows a lost remote host as a top-bar chip that retries the host and the stream", async () => {
+ const review = await command({
+ type: "create",
+ title: "Remote",
+ target: { kind: "commits", ...pins },
+ });
+
+ const app = new Hono().route("/reviews-api", createReviewApi(store));
+ app.get("/reviews-api/:id/commits", (context) => context.json([]));
+ let down = false;
+ let watches = 0;
+ let cut: (() => void) | undefined;
+
+ const bridge = testReviewBridge(
+ {},
+ {
+ request: async (url, init) => {
+ if (!new URL(String(url)).pathname.endsWith("/watch"))
+ return app.request(url, init);
+ watches++;
+
+ if (down)
+ return Response.json(
+ { ok: false, error: "wb-a is offline: it did not answer." },
+ { status: 503 },
+ );
+ const response = await app.request(url, init);
+
+ return new Response(
+ response.body!.pipeThrough(
+ new TransformStream({
+ start: (controller) => {
+ cut = () =>
+ controller.error(
+ new Error("wb-a is offline: it did not answer."),
+ );
+ },
+ }),
+ ),
+ response,
+ );
+ },
+ },
+ );
+
+ const retry = vi.fn<(alias: string) => Promise>(async () => {
+ down = false;
+ });
+
+ const remoteHosts = {
+ states: async () => [
+ down
+ ? {
+ alias: "wb-a",
+ state: "offline" as const,
+ detail: "wb-a is offline: it did not answer.",
+ }
+ : { alias: "wb-a", state: "online" as const },
+ ],
+ retry,
+ install: async () => {},
+ openSettings: async () => {},
+ };
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ await act(async () => {
+ canvas = mount(container, {
+ kind: "api",
+ reviewId: review.reviewId,
+ host: "wb-a",
+ remoteHosts,
+ bridge,
+ setSourceView: () => {},
+ });
+ });
+ await act(() =>
+ vi.waitFor(() =>
+ expect(container.querySelector("h1")?.textContent).toBe("Remote"),
+ ),
+ );
+
+ down = true;
+ await act(async () => cut?.());
+
+ const chip = await vi.waitFor(() => {
+ const found = container.querySelector(
+ "button.connection-chip",
+ );
+
+ expect(found?.textContent).toBe("wb-a offline");
+
+ return found!;
+ });
+
+ expect(container.textContent).not.toContain("Connection lost");
+ expect(chip.title).toBe(
+ "Click to retry\nwb-a is offline: it did not answer.",
+ );
+
+ const before = watches;
+ await act(async () => chip.click());
+ expect(retry).toHaveBeenCalledWith("wb-a");
+ await act(() =>
+ vi.waitFor(() => {
+ expect(container.querySelector(".connection-chip")).toBeNull();
+ }),
+ );
+ // The stream came back at once, not after its backoff.
+ expect(watches).toBe(before + 1);
+});
+
+it("shows why a remote review has not loaded, with its host's next step", async () => {
+ const install = vi.fn<(alias: string) => Promise>(async () => {});
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ await act(async () => {
+ canvas = mount(container, {
+ kind: "api",
+ reviewId: "11111111-1111-4111-8111-111111111111",
+ host: "wb-a",
+ remoteHosts: {
+ states: async () => [
+ {
+ alias: "wb-a",
+ state: "incompatible",
+ detail: "wb-a runs Whiteboard 0.1.0; this Desktop runs 0.2.0.",
+ },
+ ],
+ retry: async () => {},
+ install,
+ openSettings: async () => {},
+ },
+ bridge: testReviewBridge(
+ {},
+ {
+ request: async () =>
+ Response.json(
+ { ok: false, error: "wb-a runs Whiteboard 0.1.0." },
+ { status: 503 },
+ ),
+ },
+ ),
+ });
+ });
+
+ const waiting = await vi.waitFor(() => {
+ const found = container.querySelector(".host-waiting");
+
+ expect(found?.querySelector("h2")?.textContent).toBe(
+ "wb-a needs an update",
+ );
+
+ return found!;
+ });
+
+ expect(waiting.textContent).toContain(
+ "wb-a runs Whiteboard 0.1.0; this Desktop runs 0.2.0.",
+ );
+ expect(container.textContent).not.toContain("Connection lost");
+ await act(async () =>
+ [...waiting.querySelectorAll("button")]
+ .find((button) => button.textContent === "Install")!
+ .click(),
+ );
+ expect(install).toHaveBeenCalledWith("wb-a");
+});
+
async function mountPeekReview(content: {
host?: string;
available?: { sourceWindows: boolean };
diff --git a/packages/review/app/src/api-canvas.tsx b/packages/review/app/src/api-canvas.tsx
index 9c344f1ff..84e03918f 100644
--- a/packages/review/app/src/api-canvas.tsx
+++ b/packages/review/app/src/api-canvas.tsx
@@ -40,6 +40,7 @@ import {
} from "./authoring-cursor";
import { SaveMarkdown } from "./blocks";
import { CanvasQueryProvider } from "./canvas-query";
+import { ConnectionContext, HostWaiting } from "./connection-chip";
import { DisplayedReviewVersionContext } from "./displayed-review-version-context";
import { DrawQueueProvider } from "./draw-queue-provider";
import {
@@ -112,11 +113,13 @@ export function ApiCanvas({
const sourceRef = useRef<{ key: string; version: number }>(undefined);
const sourceVersion = sourceRef.current?.key;
const [error, setError] = useState();
+ const [lost, setLost] = useState();
useEffect(() => {
const abort = new AbortController();
const loader = createDocumentLoader(client);
setData(undefined);
setTrashedAt(null);
+ setLost(undefined);
setActivity(undefined);
setCursor(null);
setLensCursor(null);
@@ -175,6 +178,7 @@ export function ApiCanvas({
content.reviewId,
abort.signal,
async (snapshot) => {
+ setLost(undefined);
setActivity(snapshot.activity);
setCursor((current) => nextCursor(current, cursorMemory, snapshot));
setLensCursor((current) =>
@@ -237,9 +241,7 @@ export function ApiCanvas({
return;
}
- setError(
- `Connection lost. Reconnecting… ${cause instanceof Error ? cause.message : ""}`,
- );
+ setLost(cause instanceof Error ? cause.message : "");
},
);
})();
@@ -467,16 +469,39 @@ export function ApiCanvas({
);
+ const { host, remoteHosts } = content;
+
+ const connection = useMemo(
+ () =>
+ lost === undefined
+ ? undefined
+ : {
+ host,
+ hosts: remoteHosts,
+ detail: lost,
+ reconnect: () => client.reconnect(),
+ },
+ [lost, host, remoteHosts, client],
+ );
+
// Loads are near-instant, so stay blank until there is data or an error.
// Both branches root the same query provider, so its cache outlives a load.
+ // Without data there is no top bar for the connection chip.
if (!data)
return (
{trashBanner}
- {error !== undefined && (
+ {error === undefined && host && remoteHosts ? (
+ client.reconnect()}
+ lost={lost}
+ />
+ ) : (error ?? lost) !== undefined ? (
<>