Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f4eea54
docs: add preview bridge design spec
Blaise1030 Jun 6, 2026
ab53947
docs: update preview bridge spec - screenshot saved to .workbench/files
Blaise1030 Jun 6, 2026
de4c9fe
docs: add preview bridge implementation plan
Blaise1030 Jun 6, 2026
e7170d8
Merge branch 'main' into browser-sidecar
Blaise1030 Jun 6, 2026
591401e
chore: scaffold sidecar-client package
Blaise1030 Jun 6, 2026
42d9193
Merge branch 'main' into browser-sidecar
Blaise1030 Jun 6, 2026
620dc5e
feat(sidecar-client): add CSS selector capture
Blaise1030 Jun 6, 2026
5acae73
fix(sidecar-client): address selector code quality issues
Blaise1030 Jun 6, 2026
65803e2
feat(sidecar-client): add VanJS selection pill with shadow DOM
Blaise1030 Jun 6, 2026
8fcfa5c
feat(sidecar-client): add element screenshot capture
Blaise1030 Jun 6, 2026
4e5327f
feat(sidecar-client): add WS client entry with selection mode
Blaise1030 Jun 6, 2026
6764e26
feat(go): add sidecar WebSocket hub with screenshot persistence
Blaise1030 Jun 6, 2026
26106a8
feat(go): add sidecar reverse proxy with HTML injection
Blaise1030 Jun 6, 2026
53bb3ac
feat(go): add sidecar routes, embed client.js, broadcast refresh on f…
Blaise1030 Jun 6, 2026
9d4a5d3
feat(frontend): add terminal URL link provider
Blaise1030 Jun 6, 2026
c36e093
feat(frontend): add sidecar WebSocket composable
Blaise1030 Jun 6, 2026
df0357f
feat(frontend): wire URL links and element-selected into terminal
Blaise1030 Jun 6, 2026
8b307d2
chore: wire sidecar-client build into Go embed pipeline
Blaise1030 Jun 6, 2026
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
1,494 changes: 1,494 additions & 0 deletions docs/plans/2026-06-06-preview-bridge.md

Large diffs are not rendered by default.

148 changes: 148 additions & 0 deletions docs/specs/2026-06-06-preview-bridge-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Preview Bridge

A live preview feature for browser-sidecar that mirrors Cursor's browser panel concept — but between two browser tabs instead of embedded in the IDE. The IDE detects running dev servers from terminal output, and `Cmd+click` opens a proxy-wrapped preview tab that stays in sync with the IDE.

## Scope

- Terminal URL detection with click / Cmd+click behaviour
- Go reverse proxy that injects a control script into HTML responses
- WebSocket hub on the Go backend relaying messages between IDE and preview tabs
- Auto-refresh preview on file save
- Element selection mode in the preview tab — captures CSS selector + screenshot and pastes into the active IDE terminal

Out of scope for v1: bidirectional navigation, click-to-source-file, cross-origin support.

---

## 1. Terminal URL detection

The terminal panel parses each output line with a regex for `https?://localhost:\d+(/\S*)?`. Matched URLs are rendered as clickable links inline in the terminal output.

- **Normal click** → `window.open(url)` — opens raw dev server in new tab
- **Cmd+click** → `window.open('/sidecar/proxy?target=' + encodeURIComponent(url))` — opens via sidecar proxy

No new toolbar, button, or sidebar chrome needed. Works with any framework that prints a local URL on boot (Vite, Next.js, SvelteKit, etc.).

---

## 2. New pnpm package: `sidecar-client`

A standalone package in the monorepo responsible for the injected browser script.

```
sidecar-client/
package.json ← name: @browser-sidecar/sidecar-client
vite.config.ts ← lib mode, iife format, single entry
src/
index.ts ← pill UI + WebSocket client + selection logic
dist/
client.js ← output embedded by Go
```

Added to `pnpm-workspace.yaml`. Built as part of `pnpm -r build`. VanJS bundled directly into the output — no host app dependencies required.

---

## 3. Go backend — new endpoints

| Endpoint | Method | Purpose |
|---|---|---|
| `/sidecar/proxy` | GET | Reverse proxy to `?target=` URL. Rewrites HTML responses to inject `<script src="/sidecar/client.js">` before `</body>`. Proxies all other assets (JS, CSS, images) transparently. |
| `/sidecar/client.js` | GET | Serves the bundled client script. Embedded via `//go:embed sidecar-client/dist/client.js`. |
| `/sidecar/ws` | WS | WebSocket hub. Accepts connections from both IDE tab and proxy tab. Relays all messages to all other connected clients. |

---

## 4. WebSocket message protocol

All messages are JSON.

```
IDE tab → proxy tab:
{ "type": "refresh" }

Proxy tab → Go hub (raw):
{ "type": "element-selected", "selector": ".card > h2", "screenshot": "<base64 png>" }

Go hub → IDE tab (enriched, base64 stripped):
{ "type": "element-selected", "selector": ".card > h2", "screenshotPath": ".workbench/files/<uuid>.png" }
```

The hub does not interpret messages — it relays them to all other connected clients.

---

## 5. File save → refresh

When the IDE frontend detects a file save (existing file-save hook), it sends `{ type: "refresh" }` over the `/sidecar/ws` WebSocket. The proxy tab's `client.js` receives this and calls `location.reload()`.

---

## 6. Injected client.js behaviour

The script is injected into every HTML response from the proxy. It:

1. Opens a WebSocket connection to `/sidecar/ws`
2. Listens for `{ type: "refresh" }` → calls `location.reload()`
3. Mounts the selection mode pill into a **Shadow DOM** host at the bottom-left of the page (`position: fixed`) to prevent style bleed in both directions
4. In selection mode: attaches `mouseover` / `mouseout` listeners to apply/remove a temporary `outline: 2px solid #89b4fa` inline style on hovered elements
5. On click in selection mode: captures the element's CSS selector and a screenshot of its bounding box, then sends `element-selected` to the WS hub, then deactivates selection mode

### Selection pill (VanJS + Shadow DOM)

```
┌─────────────────────┐ ← Shadow root (isolated styles)
│ ● Select │ ← dot: grey=off, green=on
└─────────────────────┘
bottom: 16px; left: 16px; position: fixed
```

Built with VanJS reactive state — dot colour and cursor update automatically when `active` state toggles.

### CSS selector capture

Walk the element's ancestor chain building a selector from `tagName` + `id` (if present, stop there) or significant class names. Stops at `<body>`. Avoids dynamic/generated class names (e.g. CSS modules hashes) by skipping classes matching `/^[a-z]+-[a-z0-9]{4,}$/i`.

### Screenshot capture

Use the Canvas API + `element.getBoundingClientRect()` with `html2canvas` (bundled into `sidecar-client`) to capture the element bounding box. Encode as base64 PNG and include in the `element-selected` message.

---

## 7. IDE: receiving element-selected

The Go WS hub handler intercepts `element-selected` messages before relaying to the IDE tab:

1. Decodes the base64 screenshot and saves it to `.workbench/files/<uuid>.png` on disk
2. Strips the raw base64 from the message and replaces it with `screenshotPath: ".workbench/files/<uuid>.png"`
3. Forwards the enriched message to the IDE tab

The IDE tab then pastes two lines into the active terminal input:
```
.card > h2
Screenshot: .workbench/files/abc123.png
```

The file path is immediately readable by Claude Code (`Read` tool) or any terminal command. No OSC / iTerm2 inline image protocol needed.

---

## 8. Build pipeline

```
pnpm -r build
└─ sidecar-client: vite build → dist/client.js
└─ frontend: vite build → server-go/internal/embed/
└─ server-go: go build (embeds sidecar-client/dist/client.js)
```

`server-go` uses `//go:embed` pointed at `../../sidecar-client/dist/client.js` relative to the embed directive file.

---

## 9. What is not built

- No browser extension
- No click-to-source-file (bidirectional navigation)
- No CSS tweaking / live edit from preview
- No multi-target proxy (one dev server at a time per WS session)
41 changes: 41 additions & 0 deletions frontend/src/modules/terminal/lib/sidecar-ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
type ElementSelectedPayload = {
type: "element-selected";
selector: string;
screenshotPath: string;
};

type ElementSelectedHandler = (payload: ElementSelectedPayload) => void;

let ws: WebSocket | null = null;
const handlers = new Set<ElementSelectedHandler>();
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;

function connect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/sidecar/ws`);

ws.addEventListener("message", (e) => {
try {
const msg = JSON.parse(e.data as string) as { type: string };
if (msg.type === "element-selected") {
for (const h of handlers) h(msg as ElementSelectedPayload);
}
} catch {
// ignore
}
});

ws.addEventListener("close", () => {
reconnectTimer = setTimeout(connect, 2000);
});
}

export function onElementSelected(handler: ElementSelectedHandler): () => void {
if (handlers.size === 0) connect();
handlers.add(handler);
return () => handlers.delete(handler);
}
45 changes: 45 additions & 0 deletions frontend/src/modules/terminal/lib/terminal-url-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { extractURLs } from "./terminal-url-links";

describe("extractURLs", () => {
it("returns empty for lines with no URLs", () => {
expect(extractURLs("no urls here")).toEqual([]);
});

it("detects a simple localhost URL", () => {
const results = extractURLs(" ➜ Local: http://localhost:5173/");
expect(results).toHaveLength(1);
expect(results[0]!.url).toBe("http://localhost:5173/");
});

it("detects https URLs", () => {
const results = extractURLs(" ➜ Local: https://localhost:3000");
expect(results).toHaveLength(1);
expect(results[0]!.url).toBe("https://localhost:3000");
});

it("detects 127.0.0.1 URLs", () => {
const results = extractURLs("Server running at http://127.0.0.1:4321/");
expect(results).toHaveLength(1);
expect(results[0]!.url).toBe("http://127.0.0.1:4321/");
});

it("reports correct startX and endX", () => {
const prefix = " ➜ Local: ";
const url = "http://localhost:5173/";
const results = extractURLs(prefix + url);
// startX is byte index, not char index; prefix uses multi-byte chars
expect(results[0]!.url).toBe(url);
expect(results[0]!.startX).toBeGreaterThanOrEqual(0);
expect(results[0]!.endX).toBe(results[0]!.startX + url.length);
});

it("does not match non-localhost URLs", () => {
expect(extractURLs("see https://example.com for docs")).toEqual([]);
});

it("detects multiple URLs on one line", () => {
const line = "http://localhost:3000 and http://localhost:4000";
expect(extractURLs(line)).toHaveLength(2);
});
});
52 changes: 52 additions & 0 deletions frontend/src/modules/terminal/lib/terminal-url-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";

const URL_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+(?:\/\S*)?/g;

export interface URLLinkMatch {
url: string;
startX: number;
endX: number;
}

export function extractURLs(lineText: string): URLLinkMatch[] {
const results: URLLinkMatch[] = [];
for (const match of lineText.matchAll(new RegExp(URL_RE.source, "g"))) {
const url = match[0];
const startX = match.index!;
results.push({ url, startX, endX: startX + url.length });
}
return results;
}

export function createURLLinkProvider(
terminal: Terminal,
onClick: (url: string, metaKey: boolean) => void,
): ILinkProvider {
return {
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
const line = terminal.buffer.active.getLine(y - 1);
if (!line) {
callback(undefined);
return;
}
const text = line.translateToString(true);
const matches = extractURLs(text);
if (matches.length === 0) {
callback(undefined);
return;
}
callback(
matches.map((m): ILink => ({
range: {
start: { x: m.startX + 1, y },
end: { x: m.endX, y },
},
text: m.url,
activate(event: MouseEvent): void {
onClick(m.url, event.metaKey || event.ctrlKey);
},
})),
);
},
};
}
16 changes: 16 additions & 0 deletions frontend/src/modules/terminal/pages/Terminal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
useWorktreePanels,
} from "@/modules/workspace/lib/worktree-panels-storage";
import { createFileLinkProvider } from "@/modules/terminal/lib/terminal-file-links";
import { createURLLinkProvider } from "@/modules/terminal/lib/terminal-url-links";
import { onElementSelected } from "@/modules/terminal/lib/sidecar-ws";
import { terminalSelectionColors } from "@/modules/terminal/lib/terminal-theme";
import { cn } from "@/lib/utils";

Expand All @@ -48,6 +50,7 @@ let terminal: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let fitInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeSidecar: (() => void) | undefined;

const worktreeId = computed(() => route.params.worktreeId as string);
const { data: worktree } = useQuery(worktreeQueryOptions(worktreeId));
Expand Down Expand Up @@ -138,6 +141,15 @@ onMounted(async () => {
() => fileTreePaths.value,
),
);
terminal.registerLinkProvider(
createURLLinkProvider(terminal, (url, metaKey) => {
if (metaKey) {
window.open(`/sidecar/proxy?target=${encodeURIComponent(url)}`, "_blank");
} else {
window.open(url, "_blank");
}
}),
);
fitAddon.fit();

terminal.onData((data) => sessions.get(props.sessionId)?.sendInput(data));
Expand All @@ -150,6 +162,9 @@ onMounted(async () => {
fitInterval = setInterval(() => fitAddon?.fit(), 2_000);

sessions.attach(props.sessionId, terminal);
unsubscribeSidecar = onElementSelected(({ selector, screenshotPath }) => {
insertAtPrompt(`${selector}\nScreenshot: ${screenshotPath}\n`);
});
initError.value = null;
} catch (err) {
initError.value =
Expand All @@ -165,6 +180,7 @@ onUnmounted(() => {
terminal?.dispose();
terminal = null;
fitAddon = null;
unsubscribeSidecar?.();
});

watch(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"scripts": {
"worktree-setup": "pnpm install --frozen-lockfile && node scripts/worktree-ports.mjs",
"dev": "node scripts/dev-go.mjs",
"build": "vite build --config frontend/vite.config.ts && node scripts/assert-shiki-bundle.mjs",
"build": "pnpm --filter @browser-sidecar/sidecar-client build && vite build --config frontend/vite.config.ts && node scripts/assert-shiki-bundle.mjs",
"perf:budget": "node scripts/perf-budget.mjs",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"start": "./bin/workbench-cli",
Expand Down
Loading