Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
406 changes: 406 additions & 0 deletions apps/review-desktop/scripts/e2e/journeys/remote-ask.mjs

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions apps/review-desktop/scripts/e2e/journeys/remote-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ const remoteScript = path.join(import.meta.dirname, "../remote/remote.mjs");

const runId = process.env.WB_TEST_RUN ?? `e2e${Date.now().toString(36)}`;

const runDir = `/tmp/wbt.${runId}`;
export const runDir = `/tmp/wbt.${runId}`;

const prepared = process.env.REVIEW_E2E_REMOTE_HOST;
export const prepared = process.env.REVIEW_E2E_REMOTE_HOST;

const alias = `wb-test-${prepared ?? "a"}`;
export const alias = `wb-test-${prepared ?? "a"}`;

const title = "Remote order";

Expand All @@ -32,7 +32,7 @@ export const options = {
env: { DEV_FAST_REVIEW_SSH_CONFIG: `${runDir}/ssh_config` },
};

const createRemoteReview = String.raw`
export const createRemoteReview = String.raw`
set -e
field() { node -pe "JSON.parse(require('fs').readFileSync(0, 'utf8')).$1"; }
rm -rf ~/wbrepo
Expand Down Expand Up @@ -61,7 +61,7 @@ export async function listedAs(ctx, title) {
return review && `${review.host}: ${review.repositoryGroup?.label}`;
}

async function remote(...args) {
export async function remote(...args) {
return (
await exec(process.execPath, [remoteScript, ...args], {
env: { ...process.env, WB_TEST_RUN: runId },
Expand All @@ -70,7 +70,7 @@ async function remote(...args) {
).stdout.trim();
}

function onRemote(command, input = "") {
export function onRemote(command, input = "") {
return new Promise((resolve, reject) => {
const child = spawn(
"ssh",
Expand All @@ -96,7 +96,7 @@ function onRemote(command, input = "") {
const remoteApi = (tool, input) =>
onRemote(`whiteboard api ${tool} -`, JSON.stringify(input));

async function remoteToken() {
export async function remoteToken() {
const out = (await onRemote("whiteboard remote attach --json")).split("\n");

let token;
Expand All @@ -115,7 +115,7 @@ async function remoteToken() {
return token;
}

async function recordRequests(page, streamed) {
export async function recordRequests(page, streamed) {
const cdp = await page.context().newCDPSession(page);
const requests = new Map();
const entry = (id) => requests.get(id) ?? requests.set(id, { id }).get(id);
Expand Down Expand Up @@ -191,7 +191,7 @@ export async function run(ctx) {
}
}

async function closeDesktop(ctx) {
export async function closeDesktop(ctx) {
const session = await ctx.browser.newBrowserCDPSession().catch(() => null);

await Promise.race([
Expand Down Expand Up @@ -672,7 +672,7 @@ async function desktopSshProcesses() {
const desktopSsh = async () =>
(await desktopSshProcesses()).map(([pid]) => pid);

async function masterPid() {
export async function masterPid() {
return (await desktopSshProcesses()).find(
([, args]) => args.includes(" -M -N ") && args.endsWith(`-- ${alias}`),
)?.[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ async function addHost(ctx, host) {
return section;
}

async function removeHost(ctx, host, { uninstall = false } = {}) {
export async function removeHost(ctx, host, { uninstall = false } = {}) {
const section = await hostsSection(ctx);

await section.getByRole("button", { name: `Remove ${host}` }).click();
Expand Down
5 changes: 4 additions & 1 deletion apps/review-desktop/scripts/e2e/remote/aws-hosts.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Hosts on AWS: `aws-up`, and removing a run's instances, key pair and security groups. */
import { addOnce, aws, run, sleep } from "./exec.mjs";
import { waitForSsh } from "./ssh.mjs";
import { installFakeAgent, waitForSsh } from "./ssh.mjs";

const maxInstances = 2;

Expand Down Expand Up @@ -221,6 +221,9 @@ export async function awsUp(runState, name, options) {
60,
"test -f /run/systemd/shutdown/scheduled",
);

if (options["fake-agent"]) await installFakeAgent(runState, host);

console.log(alias);
}

Expand Down
7 changes: 6 additions & 1 deletion apps/review-desktop/scripts/e2e/remote/docker-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { remoteDevInstall } from "../../remote-dev-install.mjs";

import { addOnce, docker, exec, run, sleep } from "./exec.mjs";
import { hostOf } from "./run-state.mjs";
import { waitForSsh } from "./ssh.mjs";
import { installFakeAgent, waitForSsh } from "./ssh.mjs";

const imageDir = path.join(import.meta.dirname, "image");

Expand Down Expand Up @@ -122,6 +122,9 @@ export async function up(runState, name, options) {

if (jump && port) throw new Error("--port and --jump cannot be combined");

if (options["fake-agent"] && auth !== "key")
throw new Error("--fake-agent needs --auth key");

const toolchain = options.toolchain ?? "none";

if (toolchain !== "none" && !TOOLCHAIN_IMAGES[toolchain])
Expand Down Expand Up @@ -286,6 +289,8 @@ export async function up(runState, name, options) {
: [],
);

if (options["fake-agent"]) await installFakeAgent(runState, host);

console.log(alias);
}

Expand Down
83 changes: 83 additions & 0 deletions apps/review-desktop/scripts/e2e/remote/fake-agent/acp-agent.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createInterface } from "node:readline";

const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);

const reply = (id, result) => send({ jsonrpc: "2.0", id, result });

const notify = (method, params) => send({ jsonrpc: "2.0", method, params });

const cwds = new Map();

let toolCalls = 0;

createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;

const { id, method, params } = JSON.parse(line);

switch (method) {
case "initialize":
return reply(id, {
protocolVersion: 1,
agentCapabilities: {
loadSession: false,
promptCapabilities: { image: false },
},
authMethods: [],
});
case "session/new": {
const sessionId = `wb-test-${cwds.size + 1}`;

cwds.set(sessionId, params.cwd);

return reply(id, {
sessionId,
modes: {
currentModeId: "build",
availableModes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
},
});
}

case "session/set_mode":
return reply(id, {});
case "session/prompt": {
const { sessionId, prompt } = params;
const question = prompt.findLast((block) => block.type === "text")?.text;

notify("session/update", {
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: `You asked: ${question}. See \`f.ts\`.` },
},
});
notify("session/update", {
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: `t${++toolCalls}`,
title: "Read f.ts",
kind: "read",
status: "completed",
locations: [{ path: `${cwds.get(sessionId)}/f.ts`, line: 1 }],
},
});

return reply(id, { stopReason: "end_turn" });
}

case "session/cancel":
return;
default:
if (id !== undefined)
send({
jsonrpc: "2.0",
id,
error: { code: -32601, message: `unknown method ${method}` },
});
}
});
10 changes: 10 additions & 0 deletions apps/review-desktop/scripts/e2e/remote/fake-agent/opencode
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/sh
# wb-test: stands in for OpenCode. `opencode acp` speaks ACP; anything else prints a version.
case "$1" in
acp)
node=$(command -v node)
[ -n "$node" ] || for candidate in /opt/whiteboard/whiteboard-remote/bin/node "$HOME"/.dev/whiteboard-remote/versions/*/bin/node; do [ -x "$candidate" ] && node=$candidate; done
[ -x "$node" ] || { echo "opencode (wb-test): no node on PATH or in a Whiteboard install" >&2; exit 127; }
exec "$node" "${0%/*}/acp-agent.mjs" ;;
*) echo "opencode 0.0.0-wb-test" ;;
esac
5 changes: 3 additions & 2 deletions apps/review-desktop/scripts/e2e/remote/remote.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ const usage = `usage: remote.mjs <command>
up <name> [--platform linux/amd64|linux/arm64] [--image <image>] [--node 24|20|none]
[--auth key|password] [--banner] [--shell bash|fish] [--jump <name>]
[--sealed] [--delay-ms <n>] [--no-forwarding] [--port <n>]
[--toolchain rust|swift|dotnet]
[--toolchain rust|swift|dotnet] [--fake-agent]
install <name> [--version <v>] [--commit <VS Code server commit>]
pack --out <file.tar.xz> [--version <v>] [--arch x64|arm64]
ssh <name> -- <command...>
forward <name> <remote port>
pause <name> | resume <name> | logs <name>
aws-up <name> [--arch x64|arm64] [--sealed]
aws-up <name> [--arch x64|arm64] [--sealed] [--fake-agent]
down <name> | down --all | down --every-run
verify-clean

Expand Down Expand Up @@ -49,6 +49,7 @@ async function main(argv) {
"no-forwarding": { type: "boolean" },
port: { type: "string" },
toolchain: { type: "string" },
"fake-agent": { type: "boolean" },
version: { type: "string" },
commit: { type: "string" },
out: { type: "string" },
Expand Down
18 changes: 18 additions & 0 deletions apps/review-desktop/scripts/e2e/remote/ssh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,21 @@ export async function stopTunnels(runState, name) {
state.forwards = state.forwards.filter((f) => name && f.host !== name);
await runState.save();
}

/** Puts the fake OpenCode in ~/.opencode/bin, where Ask finds it. */
export async function installFakeAgent(runState, host) {
const dir = path.join(import.meta.dirname, "fake-agent");

await run("ssh", [...sshArgs(runState, host), "mkdir -p ~/.opencode/bin"]);
await run("scp", [
"-F",
`${runState.dir}/ssh_config`,
path.join(dir, "opencode"),
path.join(dir, "acp-agent.mjs"),
`${host.alias}:.opencode/bin/`,
]);
await run("ssh", [
...sshArgs(runState, host),
"chmod 755 ~/.opencode/bin/opencode ~/.opencode/bin/acp-agent.mjs",
]);
}
8 changes: 8 additions & 0 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ Once a host is `online`, Settings lists its unconnected agents: Claude Code,
Codex, OpenCode and Pi. **Connect** runs the agent's own install commands on
the host.

**Ask.** Ask works on a review that lives on a host. The agent runs on the
host, in the review's checkout, with the agents and sign-ins already there
(`claude auth login`, `codex login`, `opencode auth login`,
`cursor-agent login`, Pi's `/login`). Threads are kept on the host, so every
laptop that connects to it sees the same history for its reviews. A file that
an answer names shows as text, because Source windows are not available for a
remote review.

## Remove Whiteboard from a host

In Settings, click **Remove** on the host, check **Also remove Whiteboard from
Expand Down
60 changes: 60 additions & 0 deletions packages/review/app/src/ask-files.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,63 @@ it("links the files an answer names to the Source window, once the checkout has

await act(async () => root.unmount());
});

it("shows the files an answer names as text where Source windows are unavailable", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const post = vi.fn<ReviewCanvasBridge["post"]>(async () => ({ ok: true }));
const session = testReviewSession({}, { post });
const fetch = vi.spyOn(session, "fetch");

session.review!.available = {
sourceWindows: false,
languageFeatures: false,
};

const entries: AgentEntry[] = [
{ kind: "agent", id: "answer", text: "See `src/f.ts:12`." },
];

const thread: AskThreadState = {
id: "thread",
agent: "codex",
agentName: "Codex",
status: "idle",
readOnly: true,
bypass: false,
head: "7fd03b8e2",
cwd: "/checkouts/whiteboard",
selection: { title: "remote review" },
entries,
};

const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);

await act(async () =>
root.render(
<ReviewSessionProvider session={session}>
<AskFilesProvider threadId="thread">
<AskAgentTurn
thread={thread}
entries={entries}
renderPermission={() => null}
/>
</AskFilesProvider>
</ReviewSessionProvider>,
),
);

const code = [...container.querySelectorAll("code")].find(
(element) => element.textContent === "src/f.ts:12",
)!;

expect(code).toBeDefined();
expect(container.querySelector('[role="link"], a, button')).toBeNull();

await act(async () => code.click());
expect(fetch).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();

await act(async () => root.unmount());
});
7 changes: 5 additions & 2 deletions packages/review/app/src/ask-files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,19 @@ function AskFileLink({
children: ReactNode;
}): ReactNode {
const files = useContext(AskFilesContext);
const canOpen = useReviewSession().review?.available?.sourceWindows !== false;
const { path, line } = fileRef;

useEffect(() => files?.request(path), [files, path]);
useEffect(() => {
if (canOpen) files?.request(path);
}, [canOpen, files, path]);
const file = files?.lookup(path);

const tooltip = useTooltip<HTMLAnchorElement>(
`Open ${file}${line ? `:${line}` : ""}`,
);

if (!files || !file) return fallback;
if (!canOpen || !files || !file) return fallback;
const open = () => files.open(file, line);

// A link, not a button: a button is one box, so a long path would jump to
Expand Down
Loading
Loading