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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
biome:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Picks up the Biome version pinned in pnpm-lock.yaml.
- uses: biomejs/setup-biome@v2
- run: biome ci --error-on-warnings .

check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Installs the pnpm pinned in packageManager, then runs pnpm install.
# Tests run .ts files directly, which needs Node 22.18 or newer.
- uses: pnpm/setup@v3
with:
runtime: node@22
cache: true
require-lockfile: true
# The web app's type-check imports routeTree.gen.ts, which the build generates.
- run: pnpm build
- run: pnpm -r check
- run: pnpm test
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@ pnpm --filter @browse-code-mode/mcp-server test:worker
pnpm test:e2e # existing browser on BROWSE_CDP_URL or port 9222
```

`pnpm check` runs [Biome](https://biomejs.dev) (lint, formatting, import order)
before type-checking every package. `pnpm lint:fix` applies Biome's safe fixes
and formatting; `pnpm format` only formats. Biome warnings fail the check.

`pnpm install` adds a pre-push hook that runs `pnpm lint`, and CI runs the same
check on every pull request. Set `SKIP_SIMPLE_GIT_HOOKS=1` to skip the hook.

The runtime suites start an isolated Chrome and exercise real MCP execution,
TypeScript, snapshot-ref clicks, loop interruption, screenshots, and lifecycle
cleanup against Node and local workerd. They shut down their test processes.
26 changes: 16 additions & 10 deletions apps/web/src/lib/browser-api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { mcpHeaders } from "./env.ts";
import { fetchBrowserApp } from "./browser-app.ts";
import { mcpHeaders } from "./env.ts";

/** Browser API calls are bounded; navigation/HMR cancellation is not a 500. */
export async function proxyBrowserRequest(request: Request, path: "/browser" | "/screen", dispatch = fetchBrowserApp): Promise<Response> {
export async function proxyBrowserRequest(
request: Request,
path: "/browser" | "/screen",
dispatch = fetchBrowserApp,
): Promise<Response> {
try {
const body = path === "/screen" || request.method === "DELETE"
? await request.text()
: undefined;
const body =
path === "/screen" || request.method === "DELETE" ? await request.text() : undefined;
const response = await dispatch(new URL(path, "http://browse.internal"), {
method: request.method,
headers: { ...mcpHeaders(), "content-type": "application/json" },
Expand All @@ -26,10 +29,13 @@ export async function proxyBrowserRequest(request: Request, path: "/browser" | "
} catch (error) {
if (request.signal.aborted) return new Response(null, { status: 499 });
const timedOut = error instanceof Error && error.name === "TimeoutError";
return Response.json({
error: timedOut
? "The browser service took too long to respond. Try again."
: "Cannot reach the browser service. Make sure pnpm dev is running, then try again.",
}, { status: timedOut ? 504 : 502 });
return Response.json(
{
error: timedOut
? "The browser service took too long to respond. Try again."
: "Cannot reach the browser service. Make sure pnpm dev is running, then try again.",
},
{ status: timedOut ? 504 : 502 },
);
}
}
10 changes: 7 additions & 3 deletions apps/web/src/lib/browser-app.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { createConfiguredApp } from "@browse-code-mode/mcp-server/runtime";

/** Request-local Hono app; no listener, loopback HTTP, or stored browser session. */
export async function fetchBrowserApp(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return createConfiguredApp({ ...process.env, MCP_ENDPOINT: "/mcp" })
.fetch(new Request(input, init));
export async function fetchBrowserApp(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
return createConfiguredApp({ ...process.env, MCP_ENDPOINT: "/mcp" }).fetch(
new Request(input, init),
);
}

/** Mount the complete Hono router beneath /api/browse. */
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/lib/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { dynamicTool, jsonSchema, type JSONSchema7, type ToolSet } from "ai";

import { mcpHeaders } from "./env.ts";
import { dynamicTool, type JSONSchema7, jsonSchema, type ToolSet } from "ai";
import { fetchBrowserApp } from "./browser-app.ts";
import { mcpHeaders } from "./env.ts";

export interface McpSession {
tools: ToolSet;
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";

import styles from "../styles.css?url";

Expand All @@ -12,7 +12,12 @@ export const Route = createRootRoute({
links: [{ rel: "stylesheet", href: styles }],
}),
component: RootDocument,
notFoundComponent: () => <main><h1>Page not found</h1><a href="/">Return to browser</a></main>,
notFoundComponent: () => (
<main>
<h1>Page not found</h1>
<a href="/">Return to browser</a>
</main>
),
});

function RootDocument() {
Expand Down
16 changes: 4 additions & 12 deletions apps/web/src/routes/api.chat.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { anthropic } from "@ai-sdk/anthropic";
import { createFileRoute } from "@tanstack/react-router";
import {
convertToModelMessages,
stepCountIs,
streamText,
type UIMessage,
} from "ai";
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from "ai";

import { env } from "../lib/env.ts";
import { openMcpSession } from "../lib/mcp.ts";
import { type McpSession, openMcpSession } from "../lib/mcp.ts";

const SYSTEM = `You drive a real web browser by writing TypeScript programs and running them with the browser_run tool.

Expand Down Expand Up @@ -40,12 +35,9 @@ export const Route = createFileRoute("/api/chat")({
cdpUrl?: string;
};
if (!cdpUrl || typeof cdpUrl !== "string")
return Response.json(
{ error: "Start a browser first." },
{ status: 400 },
);
return Response.json({ error: "Start a browser first." }, { status: 400 });

let session;
let session: McpSession;
try {
session = await openMcpSession(cdpUrl);
} catch (error) {
Expand Down
115 changes: 67 additions & 48 deletions apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface BrowserHandle {
}

async function responseError(response: Response): Promise<string> {
const body = await response.json().catch(() => null) as { error?: string } | null;
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return body?.error ?? `Browser request failed (HTTP ${response.status}).`;
}

Expand Down Expand Up @@ -47,7 +47,9 @@ function Home() {
try {
if (next) sessionStorage.setItem("browse.browser", JSON.stringify(next));
else sessionStorage.removeItem("browse.browser");
} catch { /* A blocked storage API must not prevent using the browser. */ }
} catch {
/* A blocked storage API must not prevent using the browser. */
}
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {
Expand All @@ -56,28 +58,31 @@ function Home() {
};
return (
<div className="split">
<div
className="viewport"
style={{ display: "flex", flexDirection: "column" }}
>
<div className="viewport" style={{ display: "flex", flexDirection: "column" }}>
<div style={{ padding: 12 }}>
<button onClick={() => void lifecycle()} disabled={busy}>
<button type="button" onClick={() => void lifecycle()} disabled={busy}>
{busy ? "Working…" : browser ? "Stop browser" : "Start browser"}
</button>
{browser ? <span role="status" style={{ marginLeft: 12 }}>Browser connected</span> : null}
{error ? <div className="error" role="alert">{error}</div> : null}
{browser ? (
<span role="status" style={{ marginLeft: 12 }}>
Browser connected
</span>
) : null}
{error ? (
<div className="error" role="alert">
{error}
</div>
) : null}
</div>
{browser ? (
<LiveView browser={browser} />
) : (
<div className="viewport-empty" style={{ position: "static", flex: 1 }}>Start a browser to begin.</div>
<div className="viewport-empty" style={{ position: "static", flex: 1 }}>
Start a browser to begin.
</div>
)}
</div>
{browser ? (
<Chat key={browser.cdpUrl} cdpUrl={browser.cdpUrl} />
) : (
<div className="chat" />
)}
{browser ? <Chat key={browser.cdpUrl} cdpUrl={browser.cdpUrl} /> : <div className="chat" />}
</div>
);
}
Expand Down Expand Up @@ -154,9 +159,19 @@ function LiveView({ browser }: { browser: BrowserHandle }) {
{src ? (
<img className="frame" src={src} alt="" />
) : (
<div className="viewport-empty">{error ? "Browser preview unavailable" : "Waiting for the browser…"}</div>
<div className="viewport-empty">
{error ? "Browser preview unavailable" : "Waiting for the browser…"}
</div>
)}
{error ? <div className="error" role="alert" style={{ position: "absolute", bottom: 16, left: 16, right: 16 }}>{error} Retrying…</div> : null}
{error ? (
<div
className="error"
role="alert"
style={{ position: "absolute", bottom: 16, left: 16, right: 16 }}
>
{error} Retrying…
</div>
) : null}
</div>
);
}
Expand All @@ -172,6 +187,7 @@ function Chat({ cdpUrl }: { cdpUrl: string }) {

// Scroll the list itself. `scrollIntoView` walks up the ancestor chain and
// will scroll the document too, which drags the whole two-pane layout.
// biome-ignore lint/correctness/useExhaustiveDependencies: the dependencies are the triggers; the effect reads only the ref.
useEffect(() => {
const list = listRef.current;
if (list) list.scrollTop = list.scrollHeight;
Expand Down Expand Up @@ -207,11 +223,7 @@ function Chat({ cdpUrl }: { cdpUrl: string }) {
placeholder="Ask the browser to do something"
rows={1}
/>
<button
type="button"
onClick={submit}
disabled={busy || input.trim().length === 0}
>
<button type="button" onClick={submit} disabled={busy || input.trim().length === 0}>
Send
</button>
</div>
Expand All @@ -222,32 +234,41 @@ function Chat({ cdpUrl }: { cdpUrl: string }) {
function Message({ message }: { message: UIMessage }) {
return (
<div className={`msg msg-${message.role}`}>
{message.parts.map((part, index) => {
if (part.type === "text") {
if (!part.text.trim()) return null;
// Assistant replies use tables and lists, so they render as markdown;
// a user's own words are shown verbatim rather than reinterpreted as
// markup. Streamdown rather than react-markdown because it renders
// markdown that is still arriving, so a half-written table or fence
// does not flash as raw text mid-stream.
return message.role === "assistant" ? (
<div key={index} className="bubble markdown">
<Streamdown>{part.text}</Streamdown>
</div>
) : (
<div key={index} className="bubble">
{part.text}
</div>
);
}

const call = asToolCall(part);
return call ? <ToolCall key={index} call={call} /> : null;
})}
{message.parts.map((part, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: parts only append and text parts have no id, so the index is stable.
<MessagePart key={index} role={message.role} part={part} />
))}
</div>
);
}

function MessagePart({
role,
part,
}: {
role: UIMessage["role"];
part: UIMessage["parts"][number];
}) {
if (part.type === "text") {
if (!part.text.trim()) return null;
// Assistant replies use tables and lists, so they render as markdown;
// a user's own words are shown verbatim rather than reinterpreted as
// markup. Streamdown rather than react-markdown because it renders
// markdown that is still arriving, so a half-written table or fence
// does not flash as raw text mid-stream.
return role === "assistant" ? (
<div className="bubble markdown">
<Streamdown>{part.text}</Streamdown>
</div>
) : (
<div className="bubble">{part.text}</div>
);
}

const call = asToolCall(part);
return call ? <ToolCall call={call} /> : null;
}

interface ToolCallView {
name: string;
state: string;
Expand All @@ -261,8 +282,7 @@ interface ToolCallView {
* a tool-specific part type that only exists for statically declared tools.
*/
function asToolCall(part: UIMessage["parts"][number]): ToolCallView | null {
if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-"))
return null;
if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-")) return null;
const record = part as unknown as {
type: string;
toolName?: string;
Expand All @@ -284,8 +304,7 @@ function ToolCall({ call }: { call: ToolCallView }) {
const code = codeOf(call.input);
const output = call.errorText ?? textOf(call.output);
const failed = Boolean(call.errorText) || call.state === "output-error";
const running =
call.state === "input-streaming" || call.state === "input-available";
const running = call.state === "input-streaming" || call.state === "input-available";

return (
<details
Expand Down
Loading
Loading