From bd9a91335cd4179c7cfb68ed014af43c2e7d88f2 Mon Sep 17 00:00:00 2001 From: wpbiggs Date: Wed, 29 Apr 2026 17:03:38 -0400 Subject: [PATCH] feat(preview): embedded dev-server preview pane + dev shutdown controls (#1062) * feat: embedded preview proxy for local dev servers Add a same-origin server proxy under /api/preview/proxy/:id and matching UI surfaces so local dev servers (Vite, Next, etc.) can be embedded inside OpenChamber. Server (packages/web/server): - New lib/preview/proxy-runtime.js: cookie-gated HTTP+WebSocket proxy to loopback hosts only, with TTL'd targets and SSRF allowlist. - index.js wires the runtime alongside terminal/event-stream. UI (packages/ui): - ContextPanel preview tab with iframe, reload, and open-in-browser. - Inline html code-block preview in MarkdownRenderer. - Terminal auto-detects loopback URLs and offers to open them. - i18n keys across en, es, pt-BR, uk, zh-CN. * perf(preview): cache proxy targets across PreviewPane remounts Module-scoped Map keyed by upstream URL so tab switches and component remounts within the same page session reuse the existing proxy registration instead of POSTing a fresh target each time. In-memory only by design: the server holds the target map in memory and the auth cookie is HttpOnly + scoped to the proxy id, so a stale persisted entry would 404 after a server restart. Entries are evicted on registration error and on a 30s safety margin before TTL expiry. * feat(preview): surface dev-server-down state with retry overlay Iframes don't expose HTTP status to the parent, so when the proxy returns a 502 (upstream dev server is offline) the iframe just renders the raw JSON error body. Probe the proxy URL out-of-band with HEAD (falling back to GET on 404/405) and replace the iframe with a friendly 'Dev server is not responding' overlay + retry button when the upstream is unreachable. Re-probes on reload, on URL change, and on proxy re-registration. * feat(preview): strip frame-busting response headers Many dev servers (Next.js, others) send X-Frame-Options: SAMEORIGIN and/or a CSP with frame-ancestors that block embedding inside the OpenChamber iframe. The proxy is same-origin and already authenticated per-target, so embedding is otherwise safe. - Drop X-Frame-Options outright on proxied responses. - Surgically remove only the frame-ancestors directive from Content-Security-Policy and Content-Security-Policy-Report-Only, preserving every other directive. Drops the header entirely if no directives remain. - Verified end-to-end: upstream sending both headers comes through with X-Frame-Options removed, CSP retaining default-src/script-src but no frame-ancestors, and unrelated headers untouched. * docs(preview): design for remote-host relay agent Design-only doc for the next phase of the embedded preview feature: when OpenChamber runs remotely (cloud/shared/tunnel) and the user's dev server runs on their local machine. Covers architecture (local agent + outbound control WebSocket + server dispatch), pairing flow, wire protocol, security model, failure modes, open questions, and implementation milestones. No code changes. * feat(preview): auto-open preview pane for loopback URLs in chat Detect http(s) loopback URLs in incoming assistant messages and open the preview pane automatically, deduped per (session, url) pair so re-renders or repeated mentions do not steal focus. Add an inline Preview button next to loopback links in chat markdown as a manual fallback when the auto-open was dismissed or the URL appeared in an older message. - url.ts: isLoopbackHttpUrl / extractLoopbackUrls helpers - ChatContainer: module-level dedupe Set + effect on active session tail - MarkdownRendererImpl: optional onPreviewLoopback in main renderer only (SimpleMarkdownRenderer for tool diffs is intentionally untouched) - Reuses existing terminalView.preview.open i18n keys * feat: preview enhancements, dev shutdown, and reliability fixes Add preview start/stop UI in ContextPanel/Header, improve URL detection (Python HTTP server logs, trailing punctuation, IPv6 loopback), fix proxy path filtering to avoid disrupting non-preview WebSockets. Add dev-only /api/system/dev-shutdown endpoint and Header button to terminate local dev processes and orphaned preview servers. Improve terminal cleanup with process group killing, event pipeline reconnect backoff. Update file read APIs with optional flag and cache control. Add /api/system/free-port endpoint, detectDevServer.ts utility, and preview/shutdown i18n strings for 5 languages. * fix: harden preview support * fix: keep terminal toolbar interactive * fix: keep expanded terminal below header * fix: keep preview iframe under proxy path * fix: respect project action preview urls * fix: rewrite preview asset urls * feat: capture preview console logs * feat: annotate preview elements * feat: attach preview annotation screenshots * fix: improve proxied preview hmr * feat: refine preview action UX * fix: address preview review feedback * fix: show auto-discover preview wait state --------- Co-authored-by: William Biggers Co-authored-by: Bohdan Triapitsyn --- docs/PREVIEW_REMOTE_RELAY.md | 326 ++++++ package.json | 2 +- packages/electron/main.mjs | 34 + packages/ui/src/components/chat/ChatInput.tsx | 98 +- .../components/chat/MarkdownRendererImpl.tsx | 174 +++- .../components/chat/message/MessageBody.tsx | 59 +- .../components/layout/BottomTerminalDock.tsx | 46 +- .../ui/src/components/layout/ContextPanel.tsx | 950 +++++++++++++++++- packages/ui/src/components/layout/Header.tsx | 89 +- .../layout/ProjectActionsButton.tsx | 528 ++++++---- .../src/components/ui/sortable-tabs-strip.tsx | 8 +- .../ui/src/components/views/FilesView.tsx | 10 +- packages/ui/src/components/views/PlanView.tsx | 12 +- .../ui/src/components/views/TerminalView.tsx | 162 +-- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/contextFileOpenGuard.ts | 9 +- packages/ui/src/lib/detectDevServer.ts | 209 ++++ packages/ui/src/lib/i18n/messages/en.ts | 59 ++ packages/ui/src/lib/i18n/messages/es.ts | 59 ++ packages/ui/src/lib/i18n/messages/ko.ts | 58 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 59 ++ packages/ui/src/lib/i18n/messages/uk.ts | 59 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 59 ++ .../ui/src/lib/messages/inlineComments.ts | 14 +- packages/ui/src/lib/openchamberConfig.ts | 12 +- packages/ui/src/lib/url.ts | 51 + .../src/stores/useInlineCommentDraftStore.ts | 4 +- packages/ui/src/stores/useTerminalStore.ts | 254 ++++- packages/ui/src/stores/useUIStore.ts | 34 +- packages/ui/src/sync/event-pipeline.ts | 24 +- packages/web/server/index.js | 16 + packages/web/server/lib/fs/routes.js | 4 + .../server/lib/opencode/bootstrap-runtime.js | 1 + .../web/server/lib/opencode/core-routes.js | 208 ++++ .../web/server/lib/preview/proxy-runtime.js | 902 +++++++++++++++++ packages/web/server/lib/terminal/runtime.js | 36 +- packages/web/src/api/files.ts | 7 +- 37 files changed, 4238 insertions(+), 399 deletions(-) create mode 100644 docs/PREVIEW_REMOTE_RELAY.md create mode 100644 packages/ui/src/lib/detectDevServer.ts create mode 100644 packages/web/server/lib/preview/proxy-runtime.js diff --git a/docs/PREVIEW_REMOTE_RELAY.md b/docs/PREVIEW_REMOTE_RELAY.md new file mode 100644 index 00000000..21671210 --- /dev/null +++ b/docs/PREVIEW_REMOTE_RELAY.md @@ -0,0 +1,326 @@ +# Preview — Remote-host relay (design) + +Status: design only, no implementation. +Owner: TBD. +Audience: contributors planning the next phase of the embedded preview feature. + +## Problem + +The current preview implementation (`packages/web/server/lib/preview/proxy-runtime.js`, +`packages/ui/src/components/layout/ContextPanel.tsx`) terminates inside the +OpenChamber server process and forwards requests to a **loopback** target +(`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`). It works for these topologies: + +| Topology | Works today? | +| ------------------------------------------------------------------------ | ------------ | +| Web UI in browser, OpenChamber server on same host as dev server | yes | +| Electron desktop, dev server on same host | yes | +| VS Code extension, dev server on same host | yes | +| Mobile/tablet hitting OpenChamber over LAN, dev server on host | yes | +| **Remote OpenChamber** (cloud / shared / tunneled), dev server on user's local machine | **no** | + +The blocked case is real: a user runs `openchamber serve` on a remote box (or a +hosted OpenChamber instance) but their dev server (`vite`, `next dev`, etc.) +runs on their laptop. The proxy correctly refuses to talk to non-loopback +targets — that is a deliberate SSRF gate, not a bug. We need a separate path +that tunnels traffic from the remote OpenChamber back to the user's laptop +without weakening that gate. + +## Non-goals + +- Replacing the existing loopback proxy. The local-loopback path is the common + case and stays unchanged. +- Acting as a generic public ingress for arbitrary local services. We only + expose dev servers selected through the preview UI, scoped to the active + user's session. +- Providing a hosted relay service. The relay is something the user runs; + OpenChamber provides the agent + the server endpoints. + +## Constraints (carried forward from the loopback proxy) + +- Same-origin in the browser. The iframe must load from the OpenChamber + origin so HTTPS, cookies, and CSP behave predictably. +- Per-target cookie auth. A target id must not be guessable, and the cookie + must be HttpOnly + scoped to that target's path. +- WebSocket upgrade support (HMR is a hard requirement; without it the + feature is uninteresting). +- Strip frame-busting headers on the response. +- Strip OpenChamber credentials before forwarding to the dev server. +- Survive partial failure cleanly: if the agent disconnects, the iframe + should land on the existing "dev server is not responding" overlay, not a + zombie hang. + +## Architecture + +Three components, in order of where they run. + +### 1. Local agent (runs on the user's laptop) + +A small process the user starts on the same machine as the dev server. Two +shipping options: + +- A subcommand of the existing CLI: `openchamber preview-agent`. +- A standalone single-binary build for users who do not have the full UI + installed locally. + +Responsibilities: + +- Open exactly one outbound, authenticated WebSocket to the remote + OpenChamber server (`wss:///api/preview/agent`). Outbound-only — no + inbound port on the user's machine, so it works behind NAT, VPN, + corporate firewall, etc. +- Authenticate with a short-lived enrollment token issued by the remote + OpenChamber server (see "Pairing flow"). +- Advertise the set of dev servers the user has authorised. Scope is + loopback-only on the agent side (same allowlist as the existing proxy: + `localhost`, `127.0.0.1`, `::1`, `0.0.0.0`). The agent never proxies to + arbitrary hosts on the user's network. +- Multiplex per-request streams over the single control WebSocket + (frame protocol below). Each browser request becomes one logical stream. +- Forward HTTP and upgraded WebSocket connections to the local dev server. +- Send authoritative `agent-disconnected` notifications so the server can + evict targets immediately rather than waiting for TTL. + +Deliberately out of scope for the agent: + +- TLS termination. The agent only talks to loopback over plain HTTP; the + outbound link to OpenChamber is TLS via the server's existing cert. +- Anything that mutates the user's filesystem. +- Acting as a general SOCKS/HTTP proxy. It is dev-server-scoped. + +### 2. Remote OpenChamber server (extends `proxy-runtime.js`) + +Adds two new surfaces alongside the existing loopback proxy: + +- `GET /api/preview/agent` (WebSocket): the single control channel an agent + connects to after enrollment. Authenticated by the enrollment token + the + user's UI session. +- `POST /api/preview/targets/remote`: same shape as the existing + `POST /api/preview/targets`, but the URL is interpreted **relative to a + connected agent**. The body becomes + `{ agentId, url, ttlMs? }` (or the existing endpoint accepts an optional + `agentId` and dispatches to the right path). The response keeps the same + contract: `{ id, proxyBasePath, expiresAt }`. The browser does not learn + it is talking to a remote agent — that is a server-side detail. + +The existing `/api/preview/proxy/:id/*` route is reused unchanged from the +browser's perspective. Internally it now dispatches based on the registered +target type: + +- `kind: 'loopback'` (existing) → `http-proxy-middleware` to a local origin. +- `kind: 'agent'` (new) → encode the request into a frame, push it onto the + matching agent's WebSocket, await the response frames, stream them back + to the browser. + +This dispatch boundary is the only invasive change to the existing runtime. +The factory stays `createPreviewProxyRuntime`; the agent registry, frame +codec, and response streaming live in a sibling module +(`packages/web/server/lib/preview/agent-runtime.js`) so the loopback path +remains readable and individually testable. + +### 3. Browser (UI layer) + +Almost no change. `PreviewPane` already POSTs to `/api/preview/targets` and +loads the iframe at the returned `proxyBasePath`. The remote case adds: + +- A small "no agent connected" empty state when the user's profile has no + active agent but tries to preview a non-public URL. Gives them the exact + command to run and a one-click copy of the enrollment token. +- The existing 502 / dev-server-down overlay handles agent disconnects too + — the proxy returns 502 if the agent vanishes mid-request. + +## Pairing / enrollment flow + +The agent must prove it is acting on behalf of a specific UI user, and the +server must be able to revoke that proof. + +1. User opens Settings → Preview → "Connect a local dev-server agent". +2. Server mints a short-lived (5 min) enrollment token bound to the user's + UI session id, with a single allowed scope: `preview-agent.connect`. UI + shows the command: + ``` + openchamber preview-agent --server https:// --token + ``` +3. Agent posts the enrollment token to `POST /api/preview/agent/enroll` and + receives a long-lived `agentId` + `agentSecret`. Stored in the agent's + config dir (`$XDG_CONFIG_HOME/openchamber/agent.json` or platform + equivalent). +4. Agent opens the control WebSocket, authenticating with `agentId` + + `agentSecret`. The server verifies and registers the agent against the + owning user. +5. Agent sends an initial `hello` frame with: agent version, OS, hostname + hint (display only — never used for routing), and a list of dev-server + URLs the user has explicitly approved on the agent side. + +Revocation: + +- User can revoke an agent from Settings; the server invalidates the + `agentSecret` and closes any open WebSocket. +- The agent honours `disconnect` frames from the server with a clean + shutdown. +- Enrollment tokens are single-use and expire after 5 min. + +## Wire protocol (control WebSocket) + +Binary frames, little-endian, one frame = one logical operation. JSON metadata +header followed by an opaque body. Designed to be implementable in Node and +Bun without exotic deps. + +``` ++--------+--------+--------+----------------------+----------------------+ +| u8 ver | u8 op | u32 len| metadata (JSON, len) | body (remaining) | ++--------+--------+--------+----------------------+----------------------+ +``` + +Operations: + +| op | name | direction | metadata | body | +| ---- | ----------------- | -------------- | ------------------------------------------------------------------- | ----------------------------------- | +| 0x01 | hello | agent → server | `{ agentVersion, hostnameHint, allowedTargets: [{origin}] }` | empty | +| 0x02 | hello-ack | server → agent | `{ ok, serverVersion }` or `{ ok: false, reason }` | empty | +| 0x10 | http-request | server → agent | `{ streamId, method, path, headers, originHint }` | request body bytes | +| 0x11 | http-response-head| agent → server | `{ streamId, status, headers }` | empty | +| 0x12 | http-response-data| agent → server | `{ streamId, fin: bool }` | response body chunk | +| 0x13 | http-error | agent → server | `{ streamId, code, message }` | empty | +| 0x20 | ws-open | server → agent | `{ streamId, path, headers, subprotocols }` | empty | +| 0x21 | ws-open-ack | agent → server | `{ streamId, ok, status?, subprotocol? }` | empty | +| 0x22 | ws-frame | both | `{ streamId, opcode: 'text'|'binary', fin: bool }` | frame payload | +| 0x23 | ws-close | both | `{ streamId, code?, reason? }` | empty | +| 0x30 | cancel | server → agent | `{ streamId }` | empty | +| 0xFE | ping | both | `{ ts }` | empty | +| 0xFF | disconnect | server → agent | `{ reason }` | empty | + +Notes: + +- `streamId` is server-assigned for `http-request` and `ws-open`. It scopes + ordering and back-pressure per logical request. +- Body chunks for HTTP responses are streamed (`fin: false` until the last + chunk). The server proxies them to the browser without buffering, so + large downloads do not balloon memory on either side. +- The `originHint` lets the agent log which approved target a request was + routed to; routing itself is determined by the registered target's + `agentId` + origin, not by anything the browser sends. +- Back-pressure: if the server's downstream socket is paused, it stops + reading from the agent's WebSocket. WebSocket flow control then applies + end-to-end. We do not implement an additional credit scheme until + measurement shows we need one. + +## Security model + +Every guarantee the loopback proxy gives must hold here too. Checked +against the same threat model: + +- **Server-side SSRF**: target URLs are still validated against the loopback + allowlist — but on the agent, not the server. The server never makes a + network call on behalf of a target. +- **Cross-user target access**: a target id is owned by the user that + registered it. Cookie + path scope unchanged. +- **Cross-agent leakage**: a target id is also bound to the specific + `agentId` it was registered against. Even if two users somehow share a + target id (they cannot — ids are 128-bit random), dispatch only reaches + the agent the target was bound to. +- **Agent impersonation**: `agentSecret` is per-agent, stored only on the + user's machine, transported only over TLS during enrollment + connect. + Revocable from Settings. +- **Frame-busting headers**: stripped server-side after the agent returns + the response, identical to the loopback path. Same code path + (`stripFrameBustingHeaders`) — keep it as a single point of truth. +- **Dev-server credentials**: the agent strips `cookie`, `authorization`, + and `x-openchamber-ui-session` before forwarding to the local dev + server, mirroring the existing `proxyReq` handler. +- **Public-internet exposure**: no inbound port opens on the user's + machine; no egress to non-loopback addresses; the agent process refuses + to start with `0.0.0.0` upstream targets that resolve off-loopback. +- **Connection pinning**: when the agent's WebSocket disconnects, all of + its targets are evicted immediately and any in-flight streams are + aborted with 502. The cached entry on the browser side (see + `previewProxyTargetCache` in `ContextPanel.tsx`) will then re-register + on the next attempt and surface the "no agent connected" empty state. + +Out-of-scope hardening to revisit later: + +- mTLS for the agent ↔ server link (current proposal: TLS + agentSecret; + mTLS is a future option for self-hosters who want it). +- Audit logging of every proxied request (today the loopback path doesn't + do this; the remote path should not become an exception without a UX + for inspecting the log). + +## Failure modes + +| Failure | Behaviour | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Agent never connected | `POST /api/preview/targets/remote` returns 409 with `{ error: 'No agent connected' }`. UI shows empty state. | +| Agent disconnected mid-request | Server cancels the stream, returns 502 to the browser, evicts the target. Existing overlay handles it. | +| Dev server down on user's laptop | Agent forwards the connection refusal as `http-error`; server emits 502. Existing overlay handles it. | +| Slow agent / dev server | Streamed response keeps flowing; no buffering on the server. WebSocket flow control gates the data rate. | +| Server restarted | Agent reconnects with stored `agentSecret`. Browser-side cache 404s on next request and re-registers. | +| Enrollment token expired | `POST /api/preview/agent/enroll` returns 401 with a clear error; UI prompts to mint a new one. | +| Two agents registered for same user | Allowed. The browser-side flow always picks the most recently active agent for a given upstream URL. | + +## Open questions + +These need a decision before implementation, not before the doc lands. + +1. **CLI surface.** Is `openchamber preview-agent` the right verb, or should + it live under `openchamber agent preview`? Bias: the former; only one + agent today, and we can rename without breaking anything if we ever ship + a second. +2. **Multi-agent UX.** When a user has two agents online (laptop + desktop) + and registers a `localhost:3000` preview, which one wins? Most-recent + activity is a sensible default but we should also let the user pin a + target to an agent. +3. **Browser-side detection of remote vs loopback.** Today the UI has no + reason to know. If the empty state needs the user's enrolled agents, + that becomes a new `GET /api/preview/agents` endpoint. Acceptable. +4. **Storage of `agentSecret`.** Plain file under the agent config dir is + simplest. OS keychain integration is nicer but a much larger surface. + Bias: file first, keychain later. +5. **Frame protocol vs. full HTTP/2 / gRPC.** The custom frame protocol is + maybe 200 lines in each runtime. gRPC would handle streaming and back + pressure for us but adds a heavy dep. Bias: custom frames; revisit only + if we hit a back-pressure or multiplexing bug we cannot solve cleanly. +6. **Compression.** The current loopback path forces `accept-encoding: + identity` to keep the proxy simple. The remote path probably wants + gzip/br between the agent and the server to save bandwidth on slow + links — but the dev server may not be configured for it. Decide once we + measure. + +## Implementation milestones + +Each milestone is independently shippable and reviewable. Numbers are +sequence, not effort. + +1. Agent registry + enrollment endpoints on the server. No proxying yet. + Settings UI to mint and revoke enrollment tokens. +2. Standalone agent that connects, says hello, and stays connected with + ping/pong. No proxying yet. Validates the auth + reconnect story. +3. HTTP-only proxying through the agent (`http-request` / + `http-response-*`). Browser can register a remote target and load + static pages. No HMR yet. +4. WebSocket proxying through the agent (`ws-open` / `ws-frame` / + `ws-close`). HMR works. +5. Failure-mode polish: 502 on disconnect, target eviction, browser-side + empty state, "agent connected" indicator in Settings. +6. Documentation + tutorial for the remote-host scenario; update + `docs/REVERSE_PROXY.md` cross-link. + +## Why not …? + +- **A reverse SSH tunnel from the agent.** Works but requires SSH server + on the OpenChamber host, exposes a port, and breaks the same-origin + guarantee unless we also reverse-proxy that port through the + OpenChamber HTTP server. The control-WebSocket design avoids all of + that and keeps a single TLS endpoint. +- **Cloudflare/ngrok-style hosted relay.** Would work but turns + OpenChamber into a service that depends on a third party (or on us + hosting a relay). The agent design lets users run entirely + self-hosted. +- **WebRTC data channels.** Lower latency in theory, much harder to debug + and to reason about behind corporate NATs. Not worth the complexity + for HTTP + WS forwarding. + +## Cross-references + +- Loopback runtime: `packages/web/server/lib/preview/proxy-runtime.js` +- Browser PreviewPane + cache: `packages/ui/src/components/layout/ContextPanel.tsx` +- Reverse-proxy deployment notes: `docs/REVERSE_PROXY.md` diff --git a/package.json b/package.json index a410ac8f..5dd2be99 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "Bohdan Triapitsyn", "license": "MIT", "scripts": { - "dev": "concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"", + "dev": "OPENCHAMBER_DEV_SHUTDOWN=true concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"", "build": "bun run --filter '*' build", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index a308b5ce..03d5c950 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -104,6 +104,7 @@ const MIN_WINDOW_WIDTH = 800; const MIN_WINDOW_HEIGHT = 520; const MIN_RESTORE_WINDOW_WIDTH = 900; const MIN_RESTORE_WINDOW_HEIGHT = 560; +const MAX_CAPTURE_PAGE_RECT_AREA = 4_000_000; const LOCAL_HOST_ID = 'local'; const ENV_OVERRIDE_HOST_ID = '__env'; const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; @@ -1648,6 +1649,38 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_get_app_version': return APP_VERSION; + case 'desktop_capture_page_rect': { + if (!browserWindow || browserWindow.isDestroyed()) { + throw new Error('Window is not available'); + } + + const bounds = browserWindow.getContentBounds(); + const x = Number.isFinite(args.x) ? Math.max(0, Math.floor(args.x)) : 0; + const y = Number.isFinite(args.y) ? Math.max(0, Math.floor(args.y)) : 0; + const width = Number.isFinite(args.width) ? Math.max(1, Math.floor(args.width)) : 1; + const height = Number.isFinite(args.height) ? Math.max(1, Math.floor(args.height)) : 1; + const clampedX = Math.min(x, Math.max(0, bounds.width - 1)); + const clampedY = Math.min(y, Math.max(0, bounds.height - 1)); + const rect = { + x: clampedX, + y: clampedY, + width: Math.min(width, Math.max(1, bounds.width - clampedX)), + height: Math.min(height, Math.max(1, bounds.height - clampedY)), + }; + if (rect.width * rect.height > MAX_CAPTURE_PAGE_RECT_AREA) { + throw new Error('Capture area is too large'); + } + + const image = await browserWindow.webContents.capturePage(rect); + const buffer = image.toJPEG(82); + return { + mime: 'image/jpeg', + base64: buffer.toString('base64'), + width: image.getSize().width, + height: image.getSize().height, + }; + } + case 'desktop_save_markdown_file': { const defaultPath = typeof args.defaultFileName === 'string' ? args.defaultFileName.trim() : ''; if (!defaultPath) { @@ -2247,6 +2280,7 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([ 'desktop_start_window_drag', 'desktop_get_app_version', 'desktop_get_lan_address', + 'desktop_capture_page_rect', ]); ipcMain.handle('openchamber:invoke', async (event, command, args) => { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 24b724d5..5e516297 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1021,8 +1021,38 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [currentSessionId, newSessionDraftOpen] ) ); + const draftSourceKey = useInlineCommentDraftStore( + React.useCallback( + (state) => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : []; + let previewConsole = 0; + let previewAnnotation = 0; + let review = 0; + for (const draft of drafts) { + if (draft.source === 'preview-console') previewConsole += 1; + else if (draft.source === 'preview-annotation') previewAnnotation += 1; + else review += 1; + } + return `${previewConsole}:${previewAnnotation}:${review}`; + }, + [currentSessionId, newSessionDraftOpen] + ) + ); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); + const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; + const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + if (!sessionKey) return; + const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? []; + for (const draft of drafts) { + if (draft.source === source) { + removeInlineCommentDraft(sessionKey, draft.id); + } + } + }, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]); // User message history for up/down arrow navigation. // Keep this on a narrow hook instead of full session message records. @@ -3270,19 +3300,61 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo onEditMessage={handleQueuedMessageEdit} /> {hasDrafts && ( -
-
- {t('chat.chatInput.reviewComments')} - - {draftCount} - -
+
+ {reviewCount > 0 ? ( +
+ {t('chat.chatInput.reviewComments')} + {reviewCount} +
+ ) : null} + {previewConsoleCount > 0 ? ( +
+ {t('chat.chatInput.devServerLogs')} + {previewConsoleCount} + +
+ ) : null} + {previewAnnotationCount > 0 ? ( +
+ {t('chat.chatInput.previewAnnotations')} + {previewAnnotationCount} + +
+ ) : null}
)} diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index cc7472d7..8ec7d896 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -11,13 +11,13 @@ import remend from 'remend'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; -import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; +import { RiFileCopyLine, RiCheckLine, RiDownloadLine, RiEyeLine, RiCodeLine } from '@remixicon/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; -import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; +import { isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; @@ -694,6 +694,26 @@ const CODE_SHARED_STYLE: React.CSSProperties = { lineHeight: 'var(--markdown-code-block-line-height)', }; +const downloadTextFile = (content: string, filename: string, mimeType: string) => { + if (typeof window === 'undefined') { + return; + } + + try { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + // Best-effort; callers can optionally toast. + } +}; + const MarkdownCodeBlock: React.FC<{ code: string; language: string; @@ -701,9 +721,18 @@ const MarkdownCodeBlock: React.FC<{ }> = ({ code, language, syntaxTheme }) => { const [copied, setCopied] = React.useState(false); const [highlight, setHighlight] = React.useState(true); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); const prevCodeRef = React.useRef(code); const timerRef = React.useRef | null>(null); + const canPreview = language === 'html' || language === 'htm'; + + React.useEffect(() => { + if (!canPreview && viewMode !== 'code') { + setViewMode('code'); + } + }, [canPreview, viewMode]); + // Defer Prism highlighting while code is actively streaming. // Initial mount renders highlighted immediately (plays nice with finalized blocks). React.useEffect(() => { @@ -732,46 +761,96 @@ const MarkdownCodeBlock: React.FC<{ window.setTimeout(() => setCopied(false), 2000); }, [code]); + const handleDownload = React.useCallback(() => { + if (!canPreview) { + return; + } + + const safeSuffix = Date.now().toString(36); + downloadTextFile(code, `preview-${safeSuffix}.html`, 'text/html;charset=utf-8'); + }, [canPreview, code]); + return (
{language} -
+
+ {canPreview ? ( + + ) : null} + {canPreview ? ( + + ) : null}
-
- {highlight ? ( - - {code} - - ) : ( -
-            {code}
-          
- )} -
+ {canPreview && viewMode === 'preview' ? ( +
+