From f4eea54498e113251c2e6dfae494f48801023678 Mon Sep 17 00:00:00 2001 From: blaise1030 Date: Sat, 6 Jun 2026 13:06:51 +0800 Subject: [PATCH 01/16] docs: add preview bridge design spec --- .../specs/2026-06-06-preview-bridge-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/specs/2026-06-06-preview-bridge-design.md diff --git a/docs/specs/2026-06-06-preview-bridge-design.md b/docs/specs/2026-06-06-preview-bridge-design.md new file mode 100644 index 0000000..0371383 --- /dev/null +++ b/docs/specs/2026-06-06-preview-bridge-design.md @@ -0,0 +1,136 @@ +# 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 ``) { + t.Errorf("script not injected before : %s", out) + } +} + +func TestInjectClientScript_appendsWhenNoBodyTag(t *testing.T) { + body := `

No closing body

` + resp := &http.Response{ + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + if err := injectClientScript(resp); err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + if !strings.HasSuffix(strings.TrimSpace(string(out)), ``) { + t.Errorf("script not appended: %s", out) + } +} + +func TestInjectClientScript_rewritesAbsolutePaths(t *testing.T) { + body := `` + resp := &http.Response{ + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + if err := injectClientScript(resp); err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + s := string(out) + if !strings.Contains(s, `href="/sidecar/proxy/styles.css"`) { + t.Errorf("href not rewritten: %s", s) + } + if !strings.Contains(s, `src="/sidecar/proxy/src/main.ts"`) { + t.Errorf("src not rewritten: %s", s) + } + // client.js src should NOT be double-rewritten + if strings.Contains(s, `/sidecar/proxy/sidecar/`) { + t.Errorf("client.js script tag was incorrectly rewritten: %s", s) + } +} + +func TestInjectClientScript_skipsNonHTML(t *testing.T) { + body := `console.log("hello")` + resp := &http.Response{ + Header: http.Header{"Content-Type": []string{"application/javascript"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + if err := injectClientScript(resp); err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + if strings.Contains(string(out), "sidecar") { + t.Errorf("script injected into non-HTML response") + } +} + +func TestProxyHandler_requiresTarget(t *testing.T) { + handler := NewProxyHandler() + req := httptest.NewRequest("GET", "/sidecar/proxy", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestProxyHandler_rejectsNonLocalhost(t *testing.T) { + handler := NewProxyHandler() + req := httptest.NewRequest("GET", "/sidecar/proxy?target=http://evil.com", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for non-localhost target, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd server-go && go test ./internal/sidecar/... +``` +Expected: FAIL — `injectClientScript`, `NewProxyHandler` not defined. + +- [ ] **Step 3: Implement proxy.go** + +Create `server-go/internal/sidecar/proxy.go`: +```go +package sidecar + +import ( + "bytes" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +const ( + scriptTag = `` + cookieName = "__sidecar_target" + cookiePath = "/sidecar/proxy" +) + +// NewProxyHandler returns an http.Handler that: +// - On first load (target query param present): stores target in a cookie and proxies the root. +// - On asset requests (cookie present, path under /sidecar/proxy/*): proxies to the stored target. +// +// HTML responses have: +// 1. Absolute-path src="/" and href="/" attributes rewritten to /sidecar/proxy/ so that +// asset requests stay under the proxy path prefix (where the cookie applies). +// 2. client.js injected before . +// +// V1 limitation: only src/href attributes in HTML are rewritten. JS fetch('/...') calls +// from the app will hit the sidecar, not the target. This works for most Vite/Next.js +// initial page loads; dynamic API calls require a future full-rewrite pass or service worker. +// +// Only localhost targets are accepted. +func NewProxyHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := "" + + if t := r.URL.Query().Get("target"); t != "" { + if !isLocalhostURL(t) { + http.Error(w, "target must be a localhost URL", http.StatusBadRequest) + return + } + target = t + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: target, + Path: cookiePath, + SameSite: http.SameSiteStrictMode, + }) + } else if c, err := r.Cookie(cookieName); err == nil { + target = c.Value + } + + if target == "" { + http.Error(w, "target query param required", http.StatusBadRequest) + return + } + + targetURL, err := url.Parse(target) + if err != nil { + http.Error(w, "invalid target URL", http.StatusBadRequest) + return + } + + // Strip /sidecar/proxy prefix to get the subpath for the target + subpath := strings.TrimPrefix(r.URL.Path, "/sidecar/proxy") + if subpath == "" { + subpath = "/" + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + proxy.ModifyResponse = injectClientScript + + r2 := r.Clone(r.Context()) + r2.URL = &url.URL{ + Scheme: targetURL.Scheme, + Host: targetURL.Host, + Path: subpath, + RawQuery: r.URL.RawQuery, + } + r2.Host = targetURL.Host + + proxy.ServeHTTP(w, r2) + }) +} + +func isLocalhostURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + host := u.Hostname() + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +func injectClientScript(resp *http.Response) error { + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/html") { + return nil + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + + // Rewrite absolute-path asset references so requests stay under /sidecar/proxy/ + // and carry the target cookie. Handles ` + cookieName = "__sidecar_target" + cookiePath = "/sidecar/proxy" +) + +// NewProxyHandler returns an http.Handler that: +// - On first load (target query param present): stores target in a cookie and proxies the root. +// - On asset requests (cookie present, path under /sidecar/proxy/*): proxies to the stored target. +// +// HTML responses have: +// 1. Absolute-path src="/" and href="/" attributes rewritten to /sidecar/proxy/ so that +// asset requests stay under the proxy path prefix (where the cookie applies). +// 2. client.js injected before . +// +// Only localhost targets are accepted. +func NewProxyHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := "" + + if t := r.URL.Query().Get("target"); t != "" { + if !isLocalhostURL(t) { + http.Error(w, "target must be a localhost URL", http.StatusBadRequest) + return + } + target = t + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: target, + Path: cookiePath, + SameSite: http.SameSiteStrictMode, + }) + } else if c, err := r.Cookie(cookieName); err == nil { + target = c.Value + } + + if target == "" { + http.Error(w, "target query param required", http.StatusBadRequest) + return + } + + targetURL, err := url.Parse(target) + if err != nil { + http.Error(w, "invalid target URL", http.StatusBadRequest) + return + } + + // Strip /sidecar/proxy prefix to get the subpath for the target + subpath := strings.TrimPrefix(r.URL.Path, "/sidecar/proxy") + if subpath == "" { + subpath = "/" + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + proxy.ModifyResponse = injectClientScript + + r2 := r.Clone(r.Context()) + r2.URL = &url.URL{ + Scheme: targetURL.Scheme, + Host: targetURL.Host, + Path: subpath, + RawQuery: r.URL.RawQuery, + } + r2.Host = targetURL.Host + + proxy.ServeHTTP(w, r2) + }) +} + +func isLocalhostURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + host := u.Hostname() + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +func injectClientScript(resp *http.Response) error { + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/html") { + return nil + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + + // Rewrite absolute-path asset references so requests stay under /sidecar/proxy/ + // and carry the target cookie. Handles `) { + t.Errorf("script not injected before : %s", out) + } +} + +func TestInjectClientScript_appendsWhenNoBodyTag(t *testing.T) { + body := `

No closing body

` + resp := &http.Response{ + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + if err := injectClientScript(resp); err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + if !strings.HasSuffix(strings.TrimSpace(string(out)), ``) { + t.Errorf("script not appended: %s", out) + } +} + +func TestInjectClientScript_rewritesAbsolutePaths(t *testing.T) { + body := `` + resp := &http.Response{ + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + if err := injectClientScript(resp); err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + s := string(out) + if !strings.Contains(s, `href="/sidecar/proxy/styles.css"`) { + t.Errorf("href not rewritten: %s", s) + } + if !strings.Contains(s, `src="/sidecar/proxy/src/main.ts"`) { + t.Errorf("src not rewritten: %s", s) + } + // client.js src should NOT be double-rewritten + if strings.Contains(s, `/sidecar/proxy/sidecar/`) { + t.Errorf("client.js script tag was incorrectly rewritten: %s", s) + } +} + +func TestInjectClientScript_skipsNonHTML(t *testing.T) { + body := `console.log("hello")` + resp := &http.Response{ + Header: http.Header{"Content-Type": []string{"application/javascript"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + if err := injectClientScript(resp); err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + if strings.Contains(string(out), "sidecar") { + t.Errorf("script injected into non-HTML response") + } +} + +func TestProxyHandler_requiresTarget(t *testing.T) { + handler := NewProxyHandler() + req := httptest.NewRequest("GET", "/sidecar/proxy", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestProxyHandler_rejectsNonLocalhost(t *testing.T) { + handler := NewProxyHandler() + req := httptest.NewRequest("GET", "/sidecar/proxy?target=http://evil.com", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for non-localhost target, got %d", w.Code) + } +} From 53bb3aca2689d4d9e4bbb25792ab3ee3b5a07fe9 Mon Sep 17 00:00:00 2001 From: blaise1030 Date: Sat, 6 Jun 2026 14:46:55 +0800 Subject: [PATCH 12/16] feat(go): add sidecar routes, embed client.js, broadcast refresh on file write Co-Authored-By: Claude Sonnet 4.6 --- server-go/internal/api/router.go | 7 ++++++- server-go/internal/sidecar/embed.go | 6 ++++++ server-go/internal/sidecar/routes.go | 21 +++++++++++++++++++++ server-go/internal/sidecar/static/client.js | 1 + server-go/internal/workspace/router.go | 11 ++++++++++- 5 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 server-go/internal/sidecar/embed.go create mode 100644 server-go/internal/sidecar/routes.go create mode 100644 server-go/internal/sidecar/static/client.js diff --git a/server-go/internal/api/router.go b/server-go/internal/api/router.go index 572b690..ba4fe8d 100644 --- a/server-go/internal/api/router.go +++ b/server-go/internal/api/router.go @@ -11,10 +11,12 @@ import ( "github.com/blaisetiong/workbench-cli/server-go/internal/appstate" "github.com/blaisetiong/workbench-cli/server-go/internal/assets" "github.com/blaisetiong/workbench-cli/server-go/internal/auth" + "github.com/blaisetiong/workbench-cli/server-go/internal/config" "github.com/blaisetiong/workbench-cli/server-go/internal/events" "github.com/blaisetiong/workbench-cli/server-go/internal/keybindings" "github.com/blaisetiong/workbench-cli/server-go/internal/notifications" "github.com/blaisetiong/workbench-cli/server-go/internal/settings" + "github.com/blaisetiong/workbench-cli/server-go/internal/sidecar" "github.com/blaisetiong/workbench-cli/server-go/internal/terminal" "github.com/blaisetiong/workbench-cli/server-go/internal/workspace" ) @@ -36,6 +38,9 @@ func writeJSON(w http.ResponseWriter, v any, code int) { } func RegisterRoutes(r *chi.Mux, version string, state *appstate.AppState, cookieSecure bool, registry *terminal.Registry, allowedHosts []string) { + hub := sidecar.NewHub(config.DataDir()) + sidecar.RegisterRoutes(r, hub) + r.Route("/api", func(r chi.Router) { // Public loopback hook (e.g. Claude hooks) — must bypass the origin guard. notifications.RegisterHookRoute(r, state.DB, state.EventBus) @@ -178,7 +183,7 @@ func RegisterRoutes(r *chi.Mux, version string, state *appstate.AppState, cookie }) r.Group(func(r chi.Router) { - workspace.RegisterRoutes(r, state.DB, state.Session, state.EventBus) + workspace.RegisterRoutes(r, state.DB, state.Session, state.EventBus, hub) }) }) }) diff --git a/server-go/internal/sidecar/embed.go b/server-go/internal/sidecar/embed.go new file mode 100644 index 0000000..520f462 --- /dev/null +++ b/server-go/internal/sidecar/embed.go @@ -0,0 +1,6 @@ +package sidecar + +import _ "embed" + +//go:embed static/client.js +var ClientJS []byte diff --git a/server-go/internal/sidecar/routes.go b/server-go/internal/sidecar/routes.go new file mode 100644 index 0000000..7d1729e --- /dev/null +++ b/server-go/internal/sidecar/routes.go @@ -0,0 +1,21 @@ +package sidecar + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +// RegisterRoutes mounts all sidecar endpoints under /sidecar. +func RegisterRoutes(r chi.Router, hub *Hub) { + r.Get("/sidecar/client.js", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(ClientJS) + }) + + r.HandleFunc("/sidecar/ws", hub.ServeWS) + + r.Handle("/sidecar/proxy", NewProxyHandler()) + r.Handle("/sidecar/proxy/*", NewProxyHandler()) +} diff --git a/server-go/internal/sidecar/static/client.js b/server-go/internal/sidecar/static/client.js new file mode 100644 index 0000000..a1e8b97 --- /dev/null +++ b/server-go/internal/sidecar/static/client.js @@ -0,0 +1 @@ +// placeholder — overwritten by pnpm build diff --git a/server-go/internal/workspace/router.go b/server-go/internal/workspace/router.go index 2f5a68e..d3bf088 100644 --- a/server-go/internal/workspace/router.go +++ b/server-go/internal/workspace/router.go @@ -72,7 +72,13 @@ func domainStatus(err error) int { return http.StatusInternalServerError } -func RegisterRoutes(r chi.Router, db *sql.DB, session *auth.Session, bus *events.Bus) { +// Refresher is implemented by any type that can trigger a full-page reload +// in connected browser tabs (e.g. the sidecar Hub). +type Refresher interface { + BroadcastRefresh() +} + +func RegisterRoutes(r chi.Router, db *sql.DB, session *auth.Session, bus *events.Bus, refresher ...Refresher) { r.Use(auth.RequireSession(session)) // Projects @@ -304,6 +310,9 @@ func RegisterRoutes(r chi.Router, db *sql.DB, session *auth.Session, bus *events return } jsonResp(w, map[string]bool{"ok": true}, http.StatusOK) + for _, rf := range refresher { + rf.BroadcastRefresh() + } }) r.Post("/worktrees/{id}/files", func(w http.ResponseWriter, r *http.Request) { From 9d4a5d35d5cef7d928eba200c10f320b98c9ac5f Mon Sep 17 00:00:00 2001 From: blaise1030 Date: Sat, 6 Jun 2026 14:48:58 +0800 Subject: [PATCH 13/16] feat(frontend): add terminal URL link provider Implements extractURLs (pure, tested in isolation) and createURLLinkProvider (xterm ILinkProvider) for detecting and activating localhost/127.0.0.1 URLs in terminal output. Co-Authored-By: Claude Sonnet 4.6 --- .../terminal/lib/terminal-url-links.test.ts | 45 ++++++++++++++++ .../terminal/lib/terminal-url-links.ts | 52 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 frontend/src/modules/terminal/lib/terminal-url-links.test.ts create mode 100644 frontend/src/modules/terminal/lib/terminal-url-links.ts diff --git a/frontend/src/modules/terminal/lib/terminal-url-links.test.ts b/frontend/src/modules/terminal/lib/terminal-url-links.test.ts new file mode 100644 index 0000000..ad9a1fa --- /dev/null +++ b/frontend/src/modules/terminal/lib/terminal-url-links.test.ts @@ -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); + }); +}); diff --git a/frontend/src/modules/terminal/lib/terminal-url-links.ts b/frontend/src/modules/terminal/lib/terminal-url-links.ts new file mode 100644 index 0000000..9445629 --- /dev/null +++ b/frontend/src/modules/terminal/lib/terminal-url-links.ts @@ -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); + }, + })), + ); + }, + }; +} From c36e093bf589c0c33966f735c8636145fd59dad4 Mon Sep 17 00:00:00 2001 From: blaise1030 Date: Sat, 6 Jun 2026 14:50:55 +0800 Subject: [PATCH 14/16] feat(frontend): add sidecar WebSocket composable Co-Authored-By: Claude Sonnet 4.6 --- .../src/modules/terminal/lib/sidecar-ws.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/src/modules/terminal/lib/sidecar-ws.ts diff --git a/frontend/src/modules/terminal/lib/sidecar-ws.ts b/frontend/src/modules/terminal/lib/sidecar-ws.ts new file mode 100644 index 0000000..d0fe5cb --- /dev/null +++ b/frontend/src/modules/terminal/lib/sidecar-ws.ts @@ -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(); +let reconnectTimer: ReturnType | 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); +} From df0357fe54cc932c784b95aa267c46a78c0d4e1d Mon Sep 17 00:00:00 2001 From: blaise1030 Date: Sat, 6 Jun 2026 14:50:58 +0800 Subject: [PATCH 15/16] feat(frontend): wire URL links and element-selected into terminal Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/modules/terminal/pages/Terminal.vue | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/modules/terminal/pages/Terminal.vue b/frontend/src/modules/terminal/pages/Terminal.vue index e0eecfb..64ffaea 100644 --- a/frontend/src/modules/terminal/pages/Terminal.vue +++ b/frontend/src/modules/terminal/pages/Terminal.vue @@ -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"; @@ -48,6 +50,7 @@ let terminal: Terminal | null = null; let fitAddon: FitAddon | null = null; let resizeObserver: ResizeObserver | null = null; let fitInterval: ReturnType | null = null; +let unsubscribeSidecar: (() => void) | undefined; const worktreeId = computed(() => route.params.worktreeId as string); const { data: worktree } = useQuery(worktreeQueryOptions(worktreeId)); @@ -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)); @@ -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 = @@ -165,6 +180,7 @@ onUnmounted(() => { terminal?.dispose(); terminal = null; fitAddon = null; + unsubscribeSidecar?.(); }); watch( From 8b307d20a9e6b0a06a267179776a8045e987ef1b Mon Sep 17 00:00:00 2001 From: blaise1030 Date: Sat, 6 Jun 2026 14:52:34 +0800 Subject: [PATCH 16/16] chore: wire sidecar-client build into Go embed pipeline Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- scripts/build-go.mjs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 791b6a9..95d4cfd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-go.mjs b/scripts/build-go.mjs index a251dcb..5e212c2 100644 --- a/scripts/build-go.mjs +++ b/scripts/build-go.mjs @@ -47,6 +47,17 @@ if (!existsSync(join(embedPublic, "index.html"))) { process.exit(1); } +// Copy sidecar-client bundle for go:embed +const sidecarDist = join(root, "sidecar-client/dist/client.js"); +const sidecarEmbed = join(serverGo, "internal/sidecar/static/client.js"); +if (!existsSync(sidecarDist)) { + console.error("sidecar-client/dist/client.js missing. Run `pnpm run build` first."); + process.exit(1); +} +console.log("Staging sidecar client for go:embed …"); +mkdirSync(join(serverGo, "internal/sidecar/static"), { recursive: true }); +cpSync(sidecarDist, sidecarEmbed); + mkdirSync(join(root, "bin"), { recursive: true }); console.log("Building Go server (embed, stripped) …");