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 <will@Williams-MacBook-Pro.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
wpbiggs
2026-04-30 00:03:38 +03:00
committed by GitHub
co-authored by William Biggers Bohdan Triapitsyn
parent 67d05a23fc
commit bd9a91335c
37 changed files with 4238 additions and 399 deletions
+85 -13
View File
@@ -1021,8 +1021,38 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
onEditMessage={handleQueuedMessageEdit}
/>
{hasDrafts && (
<div className="pb-2">
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl border"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>
{draftCount}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 pb-2">
{reviewCount > 0 ? (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{reviewCount}</span>
</div>
) : null}
{previewConsoleCount > 0 ? (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.devServerLogs')}</span>
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewConsoleCount}</span>
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
onClick={() => removePreviewDrafts('preview-console')}
aria-label={t('chat.chatInput.devServerLogsRemove')}
title={t('chat.chatInput.devServerLogsRemove')}
>
<RiCloseLine className="h-3 w-3" />
</button>
</div>
) : null}
{previewAnnotationCount > 0 ? (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.previewAnnotations')}</span>
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewAnnotationCount}</span>
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
onClick={() => removePreviewDrafts('preview-annotation')}
aria-label={t('chat.chatInput.previewContextRemove')}
title={t('chat.chatInput.previewContextRemove')}
>
<RiCloseLine className="h-3 w-3" />
</button>
</div>
) : null}
</div>
)}
@@ -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<string>(code);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | 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 (
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
<div className="opacity-0 transition-opacity group-hover:opacity-100">
<div className="flex items-center gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
{canPreview ? (
<button
type="button"
onClick={() => setViewMode((mode) => (mode === 'preview' ? 'code' : 'preview'))}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={viewMode === 'preview' ? 'Show code' : 'Preview'}
aria-pressed={viewMode === 'preview'}
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
>
{viewMode === 'preview' ? <RiCodeLine className="size-3.5" /> : <RiEyeLine className="size-3.5" />}
</button>
) : null}
{canPreview ? (
<button
type="button"
onClick={handleDownload}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download HTML"
aria-label="Download HTML"
>
<RiDownloadLine className="size-3.5" />
</button>
) : null}
<button
type="button"
onClick={() => { void handleCopy(); }}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={copied ? 'Copied' : 'Copy code'}
aria-label={copied ? 'Copied' : 'Copy code'}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
</div>
</div>
<div className="px-3 py-2.5">
{highlight ? (
<SyntaxHighlighter
language={language}
style={syntaxTheme}
customStyle={CODE_SHARED_STYLE}
codeTagProps={{ style: CODE_SHARED_STYLE }}
PreTag="pre"
>
{code}
</SyntaxHighlighter>
) : (
<pre style={CODE_SHARED_STYLE}>
<code style={CODE_SHARED_STYLE}>{code}</code>
</pre>
)}
</div>
{canPreview && viewMode === 'preview' ? (
<div className="h-[320px] md:h-[420px] bg-background">
<iframe
srcDoc={code}
title="HTML preview"
className="h-full w-full border-0"
sandbox="allow-scripts allow-forms"
/>
</div>
) : (
<div className="px-3 py-2.5">
{highlight ? (
<SyntaxHighlighter
language={language}
style={syntaxTheme}
customStyle={CODE_SHARED_STYLE}
codeTagProps={{ style: CODE_SHARED_STYLE }}
PreTag="pre"
>
{code}
</SyntaxHighlighter>
) : (
<pre style={CODE_SHARED_STYLE}>
<code style={CODE_SHARED_STYLE}>{code}</code>
</pre>
)}
</div>
)}
</div>
);
};
const buildMarkdownComponents = ({
syntaxTheme,
onPreviewLoopback,
previewLabel,
previewTitle,
}: {
syntaxTheme: { [key: string]: React.CSSProperties };
onPreviewLoopback?: (url: string) => void;
previewLabel?: string;
previewTitle?: string;
}): Components => ({
table({ children, ...props }) {
return <TableWrapper className={props.className}>{children}</TableWrapper>;
@@ -846,15 +925,36 @@ const buildMarkdownComponents = ({
);
},
a({ href, children, ...props }) {
const targetHref = href ?? '';
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
return (
<a
{...props}
href={href}
target={isExternalHttpUrl(href ?? '') ? '_blank' : undefined}
rel={isExternalHttpUrl(href ?? '') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
<>
<a
{...props}
href={href}
target={isExternalHttpUrl(targetHref) ? '_blank' : undefined}
rel={isExternalHttpUrl(targetHref) ? 'noopener noreferrer' : undefined}
>
{children}
</a>
{isLoopback && onPreviewLoopback ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onPreviewLoopback(targetHref);
}}
className="ml-1 inline-flex h-5 items-center gap-0.5 rounded border border-[var(--border)] bg-[var(--surface-background)] px-1.5 align-middle text-[11px] leading-none text-[var(--muted-foreground)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--foreground)]"
aria-label={previewTitle ?? previewLabel ?? 'Open preview pane'}
title={previewTitle ?? previewLabel ?? 'Open preview pane'}
data-loopback-preview-trigger="true"
>
<RiEyeLine className="size-3" aria-hidden="true" />
<span className="font-medium">{previewLabel ?? 'Preview'}</span>
</button>
) : null}
</>
);
},
});
@@ -1436,8 +1536,24 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode,
});
useExternalLinkInteractions({ containerRef });
const openContextPreview = useUIStore((state) => state.openContextPreview);
const { t } = useI18n();
const handlePreviewLoopback = React.useCallback((url: string) => {
if (!effectiveDirectory) return;
openContextPreview(effectiveDirectory, url);
}, [effectiveDirectory, openContextPreview]);
const previewLabel = t('terminalView.preview.open');
const previewTitle = t('terminalView.preview.openTitle');
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]);
const markdownComponents = React.useMemo(
() => buildMarkdownComponents({
syntaxTheme,
onPreviewLoopback: effectiveDirectory ? handlePreviewLoopback : undefined,
previewLabel,
previewTitle,
}),
[syntaxTheme, effectiveDirectory, handlePreviewLoopback, previewLabel, previewTitle],
);
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
const markdownBlocks = useStableMarkdownBlocks(content, isStreaming && !disableStreamAnimation, componentKey);
@@ -16,7 +16,7 @@ import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine } from '@remixicon/react';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine, RiGlobalLine } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -43,6 +43,7 @@ import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessions } from '@/sync/sync-context';
import { useI18n } from '@/lib/i18n';
import { extractLoopbackUrls } from '@/lib/url';
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' };
@@ -960,6 +961,36 @@ const AssistantMessageBody = React.memo(({
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const messagePreviewUrl = React.useMemo(() => {
for (const part of assistantTextParts) {
const text = (part as { text?: unknown }).text;
if (typeof text !== 'string' || text.length === 0) {
continue;
}
const url = extractLoopbackUrls(text)[0];
if (!url) {
continue;
}
return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
}
for (const part of toolParts) {
const state = (part as unknown as { state?: unknown }).state as Record<string, unknown> | undefined;
const output = state && typeof state.output === 'string' ? state.output : null;
if (!output) {
continue;
}
// eslint-disable-next-line no-control-regex
const url = extractLoopbackUrls(output.replace(/\x1b\[[0-9;]*m/g, ''))[0];
if (!url) {
continue;
}
return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
}
return null;
}, [assistantTextParts, toolParts]);
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
@@ -1658,9 +1689,35 @@ const AssistantMessageBody = React.memo(({
}, [messageCompletedAt, messageCreatedAt]);
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
const canOpenMessagePreview = !isMobile && !isVSCodeRuntime();
const finalTurnActionButtons = (
<>
{canOpenMessagePreview && messagePreviewUrl ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.openPreviewAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => {
const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : null);
if (!directory) {
return;
}
openContextPreview(directory, messagePreviewUrl);
}}
>
<RiGlobalLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
</Tooltip>
) : null}
{!isVSCodeRuntime() ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>