Files
openchamber/packages/ui/src/components/layout/ContextPanel.tsx
T
Bohdan Triapitsyn e2fa7dbad2 feat(ui): context panel 2.0 - surface rail, changes-first git view, live PR surface (#2418)
* feat(ui): add context surface registry and rail switcher

* feat(ui): move git and project notes into context surfaces, embed editor file tree

* feat(ui): replace right sidebar with context surfaces, per-surface panel widths

* refactor(ui): retire legacy main-tab overlays and right-sidebar state

* feat(ui): rail polish, right-docked file tree, terminal surface

* feat(ui): move terminal into context surface, per-surface tab closing, editor empty state

* feat(ui): tune default rail order and activity dot

* fix(ui): keep context panel controls anchored during width animations

* feat(ui): lazy-follow context panel resize with window-level drag tracking

* feat(ui): panel dividers, right-dock tree icon, muted outline folder icons

* feat(ui): restructure git view into changes-first surface with standalone PR surface

- Remove commit/update/pr tabs; git view is always changes + commit
- Promote pull request to its own rail surface with shared repo context
- Move update-branch and re-integrate flows into separate dialogs
- Add PR status chip and repo actions menu to the git header row
- Seed new PR-status entries from resolved sibling remotes to avoid
  a false "checking status" state when the PR is already known
- History/graph dialog refresh button, fingerprint global identity icon,
  muted outline folder icons follow-ups

* feat(ui): progressive-disclosure PR surface with live checks and pinned chat context

- Segment the PR surface into Overview / Checks / Comments pill tabs with
  live badges; merge controls move to the status row
- Live checks segment: progress bar, per-run rows with workflow names,
  elapsed timers, expandable failures, auto-refresh while pending
- PR comments and failed checks pin as chat-context drafts (like terminal
  selections) instead of sending an immediate message; works on new-session
  drafts too
- Shared prContext cache client+server, ETag conditional requests in the
  octokit wrapper (304s bypass rate limits), extended checks aggregate
  (inProgress/queued/startedAt)
- Resolve gh-CLI auth login for merge-permission checks
- Full-width description editor with matched control heights

* fix(ui): single source of truth for PR checks and status readers

- Derive the checks aggregate from the visible run list and sync it into
  the PR-status store so bar, badges, header, and git-view chip agree
- Route PR body hydration through the shared context cache
- Git-view PR chip reads the freshest entry across remote keys

* fix(github): freshness stamps prevent stale cache responses from regressing PR state

- pr/status and pulls/context responses carry a server-side fetchedAt that
  survives cache serves
- The status store rejects responses older than the held snapshot (only
  clearing the loading flag), and the checks sync adopts the context's
  stamp so stale status polls cannot flip fresher derived checks
- Regression test for the stale-response guard

* perf(github): repo-level pull-list cache collapses per-branch PR resolution

- One pulls.list per repo per state per 45s answers every branch (10
  worktrees = 1 call, not 10 query fans); in-flight fetches coalesce
- A complete repo list makes a no-PR miss authoritative, skipping the
  per-owner head queries AND the Search API fallback (the 30/min killer)
- force refresh bypasses the repo list cache; PR create/merge/ready
  invalidate it

* perf(github): back off Search API misses per repo+branch

A branch without a PR re-searched on every poll; with >100 closed PRs the
list miss is never authoritative, so the search fallback still ran and
burned the 30/min search quota. Remember misses for 10 minutes; PR
creation clears remembered misses for the repo.

* fix(github): dedupe re-run check runs to the latest per (app, name)

listForRef returns the superseded completed run alongside its re-run;
GitHub's UI shows only the latest per name. Mirror that in both pr/status
and pulls/context so counts and run lists match github.com.

* fix(ui): address review findings on registry test, surface docs, and PR-context keys

- Rail-order test asserts against the registry itself (was stale after the
  'pr' surface landed and failed)
- surfaces DOCUMENTATION.md describes actual behavior: has-content surfaces
  hide until content exists; only multi-instance/terminal panes are
  keep-alive, singleton surfaces remount and restore from stores
- PR-context cache keys are runtime-scoped JSON tuples; invalidation
  compares the directory exactly instead of by string prefix (+ test)

* fix(ui): wrap long unbreakable tokens in check-run details

Annotation messages with long SHAs/URLs overflowed the panel; break-words
on annotation title/message/rawDetails and output summary/text, and the
expanded run body clips instead of widening the panel.

* fix(ui): busy state for context-attach buttons and honest attach labels

- 'Attach failed checks' / 'Attach all to chat' show a spinner and disable
  while the context request runs (previously nothing happened for seconds)
- Action labels/tooltips reworded from send-to-agent to attach-to-chat
  semantics across all locales

* fix(i18n): Ukrainian attach wording uses 'прикріпити' with proper cases

* fix(ui): runtime-scope PR-view remote caches, correct surfaces doc on preview

- Remote/remote-url caches in PullRequestView are keyed by runtime +
  directory so a backend switch never serves another runtime's remotes
- surfaces DOCUMENTATION.md: preview is not keep-alive; preview tabs
  remount on switch like singleton surfaces

* fix(ui): rail active color, clearer collapse icon, remove dead bottom-terminal dock

Design-review feedback on the context panel:

- Context rail: icons enlarged 16px -> 18px; the active surface is now
  highlighted with the primary color only (no background, no scale
  animation), replacing the previous scale-up effect that read as a
  resize rather than a selected state.
- Files tree: the icon-only 'collapse all folders' toolbar button now
  uses collapse-vertical instead of contract-up-down, which was easily
  mistaken for a close button. The labelled 'Collapse all' dropdown item
  in the session sidebar keeps its icon since text removes the ambiguity.
- Terminal: removed the leftover bottom-dock expand/close buttons that
  rendered in the context-panel terminal but controlled a dock that no
  longer exists (nothing toggles it anymore), so the expand button
  appeared to do nothing and duplicated the panel-header fullscreen
  control. Cleaned up the entire inert layer with it: four useUIStore
  fields (isBottomTerminalOpen/Expanded, bottomTerminalHeight,
  hasManuallyResizedBottomTerminal), five actions, their persistence,
  the MainLayout resize listener that only served the dock height, the
  dock-driven refit effect in TerminalView, and the
  terminalView.bottomDock.* keys across all 10 locale dictionaries.

Validated: ui type-check and lint clean; messages parity test (2 pass)
and useUIStore contextPanel test (13 pass) green; icon sprite
regenerated via icons:generate.

* refactor: use PR visual state for git header icon

Derives the pull request icon color from a single visual state
Covers merged, closed, draft, blocked, and open PR states
Removes conditional class handling from the git header icon
2026-07-27 23:07:42 +03:00

2999 lines
112 KiB
TypeScript

import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Button } from '@/components/ui/button';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { DiffView } from '@/components/views/DiffView';
import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PullRequestView } from '@/components/views/PullRequestView';
import { TerminalView } from '@/components/views/TerminalView';
import { PlanView } from '@/components/views/PlanView';
import { ProjectContextPanel } from './RightSidebarTabs';
import { SidebarFilesTree } from './SidebarFilesTree';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { openExternalUrl } from '@/lib/url';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { markSessionViewed } from '@/sync/notification-store';
import { setExternallyViewedSession, useDirectoryStore } from '@/sync/sync-context';
import { ContextPanelContent } from './ContextSidebarTab';
import { toast } from '@/components/ui';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getPreviewTargetRecoveryAction } from '@/lib/preview/proxy-response';
import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat';
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
import {
type PreviewElementMetadata,
isPreviewElementMetadata,
formatPreviewAnnotationMarkdown,
renderPreviewScreenshot,
desktopAnnotationToFile,
getCachedProxyTarget,
getBrowserProxyTargetKey,
previewProxyTargetCache,
} from '@/lib/preview/screenshot-capture';
const CONTEXT_PANEL_MIN_WIDTH = 380;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const RESIZE_FOLLOW_INTERVAL_MS = 100;
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
type TranslateFn = ReturnType<typeof useI18n>['t'];
const EMPTY_SESSION_TITLE_MAP = new Map<string, string>();
type PreviewConsoleEvent = {
id: number;
level: 'log' | 'info' | 'warn' | 'error' | 'debug' | 'resource' | 'runtime';
message: string;
details?: string;
ts: number;
};
type PreviewConsoleFilter = 'all' | 'errors' | 'warnings' | 'logs';
type PreviewBridgeMessage = {
source?: string;
version?: number;
type?: string;
level?: PreviewConsoleEvent['level'];
args?: unknown[];
message?: unknown;
stack?: unknown;
filename?: unknown;
line?: unknown;
column?: unknown;
tag?: unknown;
url?: unknown;
outerHTML?: unknown;
title?: unknown;
ts?: unknown;
target?: unknown;
navigation?: unknown;
};
const PREVIEW_CONSOLE_EVENT_LIMIT = 200;
const getPreviewConsoleFilterMatch = (event: PreviewConsoleEvent, filter: PreviewConsoleFilter): boolean => {
if (filter === 'all') return true;
if (filter === 'errors') return event.level === 'error' || event.level === 'runtime' || event.level === 'resource';
if (filter === 'warnings') return event.level === 'warn';
return event.level === 'log' || event.level === 'info' || event.level === 'debug';
};
const normalizeDirectoryKey = (value: string): string => {
if (!value) return '';
const raw = value.replace(/\\/g, '/');
const hadUncPrefix = raw.startsWith('//');
let normalized = raw.replace(/\/+$/g, '');
normalized = normalized.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
if (normalized === '') {
return raw.startsWith('/') ? '/' : '';
}
return normalized;
};
const clampWidth = (width: number): number => {
if (!Number.isFinite(width)) {
return CONTEXT_PANEL_DEFAULT_WIDTH;
}
return Math.min(CONTEXT_PANEL_MAX_WIDTH, Math.max(CONTEXT_PANEL_MIN_WIDTH, Math.round(width)));
};
const getAvailablePanelWidth = (panel: HTMLElement | null): number | null => {
const parentWidth = panel?.parentElement?.clientWidth;
if (!parentWidth || parentWidth <= 0) {
return null;
}
return parentWidth;
};
const getRelativePathLabel = (filePath: string | null, directory: string): string => {
if (!filePath) {
return '';
}
const normalizedFile = filePath.replace(/\\/g, '/');
const normalizedDir = directory.replace(/\\/g, '/').replace(/\/+$/, '');
if (normalizedDir && normalizedFile.startsWith(normalizedDir + '/')) {
return normalizedFile.slice(normalizedDir.length + 1);
}
return normalizedFile;
};
const getModeLabel = (
mode: ContextPanelMode,
t: TranslateFn
): string => {
if (mode === 'chat') return t('contextPanel.mode.chat');
if (mode === 'file') return t('contextPanel.mode.files');
if (mode === 'diff') return t('contextPanel.mode.diff');
if (mode === 'plan') return t('contextPanel.mode.plan');
if (mode === 'preview') return t('contextPanel.mode.preview');
if (mode === 'browser') return t('contextPanel.mode.browser');
if (mode === 'git') return t('layout.rightSidebar.git');
if (mode === 'pr') return t('contextPanel.mode.pr');
if (mode === 'notes') return t('contextRail.surface.notes');
if (mode === 'terminal') return t('layout.mainTab.terminal');
return t('contextPanel.mode.context');
};
const getFileNameFromPath = (path: string | null): string | null => {
if (!path) {
return null;
}
const normalized = path.replace(/\\/g, '/').trim();
if (!normalized) {
return null;
}
const segments = normalized.split('/').filter(Boolean);
if (segments.length === 0) {
return normalized;
}
return segments[segments.length - 1] || null;
};
const getTabLabel = (
tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; dedupeKey?: string; sessionTitleFallback?: string | null; stagedDiff?: boolean },
sessionTitleById: ReadonlyMap<string, string>,
t: TranslateFn
): string => {
if (tab.mode === 'chat') {
const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey);
if (sessionID) {
const sessionTitle = sessionTitleById.get(sessionID)?.trim();
if (sessionTitle) {
return sessionTitle;
}
}
const sessionTitleFallback = tab.sessionTitleFallback?.trim();
if (sessionTitleFallback) {
return sessionTitleFallback;
}
return t('contextPanel.mode.chat');
}
if (tab.label) {
return tab.label;
}
if (tab.mode === 'file') {
return getFileNameFromPath(tab.targetPath) || t('contextPanel.mode.files');
}
if (tab.mode === 'preview') {
const url = tab.targetPath;
if (url) {
try {
const parsed = new URL(url);
return parsed.host || parsed.hostname || t('contextPanel.mode.preview');
} catch {
// ignore invalid URL
}
}
return t('contextPanel.mode.preview');
}
if (tab.mode === 'diff') {
return t('contextPanel.mode.diff');
}
return getModeLabel(tab.mode, t);
};
const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }): React.ReactNode | undefined => {
if (tab.mode === 'file') {
return tab.targetPath
? <FileTypeIcon filePath={tab.targetPath} className="h-3.5 w-3.5" />
: undefined;
}
if (tab.mode === 'diff') {
return <Icon name="arrow-left-right" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'git') {
return <Icon name="git-branch" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'pr') {
return <Icon name="git-pull-request" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'notes') {
return <Icon name="sticky-note" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'terminal') {
return <Icon name="terminal-box" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'plan') {
return <Icon name="file-text" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'context') {
return <Icon name="donut-chart-fill" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'chat') {
return <Icon name="chat-4" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'preview') {
return <Icon name="global" className="h-3.5 w-3.5 text-[var(--status-info)]" />;
}
if (tab.mode === 'browser') {
return <Icon name="global" className="h-3.5 w-3.5" />;
}
return undefined;
};
const EDITOR_TREE_MIN_WIDTH = 200;
const EDITOR_TREE_MAX_WIDTH = 480;
// The editor surface's file-tree column: docked on the right, resizable from
// its left edge, and animated open/closed like the app sidebars.
const EditorTreeColumn: React.FC<{ visible: boolean }> = ({ visible }) => {
const { t } = useI18n();
const width = useUIStore((state) => state.contextEditorTreeWidth);
const setWidth = useUIStore((state) => state.setContextEditorTreeWidth);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(width);
const liveWidthRef = React.useRef<number | null>(null);
const pointerIDRef = React.useRef<number | null>(null);
const columnRef = React.useRef<HTMLDivElement | null>(null);
const clampTreeWidth = React.useCallback((value: number) => {
return Math.min(EDITOR_TREE_MAX_WIDTH, Math.max(EDITOR_TREE_MIN_WIDTH, Math.round(value)));
}, []);
const applyLiveTreeWidth = React.useCallback((nextWidth: number) => {
const column = columnRef.current;
if (!column) {
return;
}
column.style.width = `${nextWidth}px`;
column.style.setProperty('--oc-editor-tree-width', `${nextWidth}px`);
}, []);
const handlePointerDown = (event: React.PointerEvent) => {
if (!visible) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
pointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
liveWidthRef.current = width;
event.preventDefault();
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!isResizing || pointerIDRef.current !== event.pointerId) {
return;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampTreeWidth(startWidthRef.current + delta);
if (liveWidthRef.current === nextWidth) {
return;
}
liveWidthRef.current = nextWidth;
applyLiveTreeWidth(nextWidth);
};
const handlePointerEnd = (event: React.PointerEvent) => {
if (pointerIDRef.current !== event.pointerId) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampTreeWidth(liveWidthRef.current ?? width);
pointerIDRef.current = null;
liveWidthRef.current = null;
setIsResizing(false);
setWidth(finalWidth);
};
const appliedWidth = visible ? width : 0;
return (
<div
ref={columnRef}
className={cn(
'relative h-full flex-shrink-0 overflow-hidden border-l border-border/40 bg-background will-change-[width] motion-reduce:transition-none',
!visible && 'border-l-0',
)}
style={{
width: `${isResizing ? (liveWidthRef.current ?? appliedWidth) : appliedWidth}px`,
['--oc-editor-tree-width' as string]: `${isResizing ? (liveWidthRef.current ?? width) : width}px`,
overflowX: 'clip',
transitionProperty: isResizing ? 'none' : 'width',
transitionDuration: '200ms',
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
}}
aria-hidden={!visible}
>
{visible && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label={t('contextPanel.actions.resizePanelAria')}
/>
)}
<div
className={cn(
'relative z-10 h-full shrink-0 transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
isResizing && 'pointer-events-none',
!visible && 'pointer-events-none select-none opacity-0'
)}
style={{ width: 'var(--oc-editor-tree-width)' }}
aria-hidden={!visible}
>
<SidebarFilesTree />
</div>
</div>
);
};
const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => {
if (!dedupeKey || !dedupeKey.startsWith('session:')) {
return null;
}
const sessionID = dedupeKey.slice('session:'.length).trim();
return sessionID || null;
};
const areTitleMapsEqual = (a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean => {
if (a.size !== b.size) return false;
for (const [key, value] of a) {
if (b.get(key) !== value) return false;
}
return true;
};
const buildSessionTitleMap = (sessions: Array<{ id: string; title?: string | null }>, sessionIDs: readonly string[]): Map<string, string> => {
if (sessionIDs.length === 0) return EMPTY_SESSION_TITLE_MAP;
const wanted = new Set(sessionIDs);
const next = new Map<string, string>();
for (const session of sessions) {
if (!wanted.has(session.id)) continue;
const title = session.title?.trim();
if (title) next.set(session.id, title);
}
return next.size === 0 ? EMPTY_SESSION_TITLE_MAP : next;
};
const useSessionTitleMap = (directory: string | undefined, sessionIDs: readonly string[]): ReadonlyMap<string, string> => {
const store = useDirectoryStore(directory);
const snapshotRef = React.useRef<ReadonlyMap<string, string>>(EMPTY_SESSION_TITLE_MAP);
const sessionIDsRef = React.useRef<readonly string[]>(sessionIDs);
sessionIDsRef.current = sessionIDs;
return React.useSyncExternalStore(
store.subscribe,
React.useCallback(() => {
const next = buildSessionTitleMap(store.getState().session, sessionIDsRef.current);
if (areTitleMapsEqual(snapshotRef.current, next)) {
return snapshotRef.current;
}
snapshotRef.current = next;
return next;
}, [store]),
() => EMPTY_SESSION_TITLE_MAP,
);
};
const DESKTOP_BROWSER_INSPECT_SCRIPT = `new Promise((resolve) => {
const existing = document.getElementById('__openchamber_desktop_browser_overlay');
if (existing) existing.remove();
if (typeof window.__openchamberDesktopBrowserCancelInspect === 'function') {
try { window.__openchamberDesktopBrowserCancelInspect(); } catch { /* webview not ready */ }
}
const overlay = document.createElement('div');
overlay.id = '__openchamber_desktop_browser_overlay';
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #60a5fa;background:rgba(96,165,250,.24);border-radius:3px;display:none;box-sizing:border-box;';
document.documentElement.appendChild(overlay);
const cssEscape = (value) => {
try { return CSS.escape(value); } catch { return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); }
};
const selectorPart = (element) => {
const tag = element.tagName.toLowerCase();
if (element.id) return tag + '#' + cssEscape(element.id);
const className = String(element.className || '').trim().split(/\\s+/).filter(Boolean).slice(0, 3).map((part) => '.' + cssEscape(part)).join('');
return tag + className;
};
const metadata = (element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const ancestry = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE && ancestry.length < 8) {
ancestry.unshift({ tag: current.tagName.toLowerCase(), id: current.id || undefined, className: typeof current.className === 'string' ? current.className : undefined, selectorPart: selectorPart(current) });
current = current.parentElement;
}
const attrs = {};
for (const attr of Array.from(element.attributes || []).slice(0, 16)) attrs[attr.name] = attr.value.slice(0, 300);
const path = ancestry.map((entry) => entry.selectorPart).join(' > ');
return {
frame: 'top',
tag: element.tagName.toLowerCase(),
text: String(element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 500),
selector: element.id ? '#' + cssEscape(element.id) : path,
path,
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
center: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
attributes: attrs,
computedStyle: { display: style.display, position: style.position, fontWeight: style.fontWeight, fontSize: style.fontSize, lineHeight: style.lineHeight, fontFamily: style.fontFamily, color: style.color, backgroundColor: style.backgroundColor, zIndex: style.zIndex },
ancestry,
};
};
const move = (event) => {
const element = document.elementFromPoint(event.clientX, event.clientY);
if (!element || element === overlay || element === document.documentElement || element === document.body) return;
const rect = element.getBoundingClientRect();
overlay.style.display = 'block';
overlay.style.left = rect.left + 'px';
overlay.style.top = rect.top + 'px';
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
};
const cleanup = () => {
window.removeEventListener('mousemove', move, true);
window.removeEventListener('click', click, true);
window.removeEventListener('keydown', keydown, true);
if (window.__openchamberDesktopBrowserCancelInspect === cancel) {
delete window.__openchamberDesktopBrowserCancelInspect;
}
};
const cancel = () => {
cleanup();
overlay.remove();
resolve(null);
};
const click = (event) => {
event.preventDefault();
event.stopPropagation();
const element = document.elementFromPoint(event.clientX, event.clientY);
const result = element ? metadata(element) : null;
cleanup();
overlay.remove();
resolve(result);
};
const keydown = (event) => {
if (event.key !== 'Escape') return;
cancel();
};
window.__openchamberDesktopBrowserCancelInspect = cancel;
window.addEventListener('mousemove', move, true);
window.addEventListener('click', click, true);
window.addEventListener('keydown', keydown, true);
});`;
const DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT = `(() => {
if (typeof window.__openchamberDesktopBrowserCancelInspect === 'function') {
window.__openchamberDesktopBrowserCancelInspect();
return;
}
const overlay = document.getElementById('__openchamber_desktop_browser_overlay');
if (overlay) overlay.remove();
})()`;
const DESKTOP_BROWSER_SAME_WEBVIEW_NAVIGATION_SCRIPT = `(() => {
if (window.__openchamberSameWebviewNavigationInstalled) return;
window.__openchamberSameWebviewNavigationInstalled = true;
const navigate = (rawUrl) => {
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return false;
try {
const url = new URL(rawUrl, window.location.href);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
window.location.assign(url.href);
return true;
} catch (_error) {
return false;
}
};
const originalOpen = window.open.bind(window);
window.open = (url, target, features) => {
if (navigate(url)) return null;
return originalOpen(url, target, features);
};
document.addEventListener('click', (event) => {
if (event.defaultPrevented) return;
const target = event.target;
if (!(target instanceof Element)) return;
const anchor = target.closest('a[target="_blank"][href]');
if (!(anchor instanceof HTMLAnchorElement)) return;
if (!navigate(anchor.href)) return;
event.preventDefault();
event.stopPropagation();
}, true);
})()`;
const normalizeBrowserUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return 'about:blank';
try {
const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return 'about:blank';
return parsed.toString();
} catch {
return 'about:blank';
}
};
const runIframeScript = async <T,>(iframe: HTMLIFrameElement, script: string): Promise<T> => {
const frameWindow = iframe.contentWindow;
if (!frameWindow) {
throw new Error('Iframe window is not available');
}
const evaluate = (frameWindow as Window & { eval: (code: string) => unknown }).eval;
const result = evaluate.call(frameWindow, script) as unknown;
return await Promise.resolve(result) as T;
};
const truncateTabLabel = (value: string, maxChars: number): string => {
if (value.length <= maxChars) {
return value;
}
return `${value.slice(0, maxChars - 3)}...`;
};
type PreviewPaneProps = {
rawUrl: string;
onNavigate: (url: string) => void;
};
type PreviewProxyState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; proxyBasePath: string; previewToken?: string; expiresAt: number }
| { status: 'error'; message: string };
const getPreviewProxyOrigin = (proxySrc: string): string => {
if (typeof window === 'undefined') return '';
try {
return new URL(proxySrc || window.location.href, window.location.href).origin;
} catch {
return window.location.origin;
}
};
const postPreviewBridgeMessage = (frameWindow: Window, proxySrc: string, payload: Record<string, unknown>): void => {
const targetOrigin = getPreviewProxyOrigin(proxySrc);
frameWindow.postMessage(payload, targetOrigin);
};
const stripPreviewTokenFromUrl = (value: string): string => {
if (!value) return value;
try {
const parsed = new URL(value);
parsed.searchParams.delete('oc_preview_token');
parsed.searchParams.delete('oc_client_token');
parsed.searchParams.delete('oc_url_token');
return parsed.toString();
} catch {
return value;
}
};
const stripPreviewQueryParams = (value: string): string => {
if (!value) return value;
try {
const parsed = new URL(value);
parsed.searchParams.delete('ocPreview');
parsed.searchParams.delete('oc_preview_token');
parsed.searchParams.delete('oc_client_token');
parsed.searchParams.delete('oc_url_token');
return parsed.toString();
} catch {
return value;
}
};
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const [reloadNonce, bumpReload] = React.useReducer((x: number) => x + 1, 0);
const [proxyRegistrationNonce, bumpProxyRegistration] = React.useReducer((x: number) => x + 1, 0);
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState('');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const nextConsoleEventIdRef = React.useRef(1);
const [bridgeReady, setBridgeReady] = React.useState(false);
const [consoleOpen, setConsoleOpen] = React.useState(false);
const [consoleFilter, setConsoleFilter] = React.useState<PreviewConsoleFilter>('all');
const [consoleEvents, setConsoleEvents] = React.useState<PreviewConsoleEvent[]>([]);
const [inspectMode, setInspectMode] = React.useState(false);
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const effectiveDirectory = useEffectiveDirectory();
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
let parsedUrl: URL | null = null;
try {
parsedUrl = rawUrl ? new URL(rawUrl) : null;
} catch {
parsedUrl = null;
}
const isLoopback = parsedUrl
? (parsedUrl.hostname === 'localhost'
|| parsedUrl.hostname === '127.0.0.1'
|| parsedUrl.hostname === '::1'
|| parsedUrl.hostname === '[::1]'
|| parsedUrl.hostname === '0.0.0.0')
: false;
const normalizedUrl = parsedUrl
? (parsedUrl.hostname === '0.0.0.0'
? new URL(parsedUrl.toString().replace('0.0.0.0', '127.0.0.1'))
: parsedUrl)
: null;
const targetKey = normalizedUrl ? normalizedUrl.toString() : '';
const proxyCacheKey = targetKey ? `${getRuntimeApiBaseUrl() || 'same-origin'}|${targetKey}` : '';
const previewColorScheme = currentTheme.metadata.variant;
React.useEffect(() => {
if (!targetKey || !isLoopback) {
setProxyState({ status: 'idle' });
return;
}
const cached = getCachedProxyTarget(proxyCacheKey);
if (cached?.previewToken) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
return;
}
if (cached) {
previewProxyTargetCache.delete(proxyCacheKey);
}
let cancelled = false;
setProxyState({ status: 'loading' });
void (async () => {
try {
const response = await runtimeFetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url: targetKey }),
});
if (!response.ok) {
previewProxyTargetCache.delete(proxyCacheKey);
const errorBody = await response.json().catch(() => ({}));
const message = typeof errorBody?.error === 'string'
? errorBody.error
: `HTTP ${response.status}`;
if (!cancelled) {
setProxyState({ status: 'error', message });
}
return;
}
const body = await response.json() as { proxyBasePath?: unknown; previewToken?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
const previewToken = typeof body.previewToken === 'string' ? body.previewToken : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
if (!proxyBasePath || !previewToken) {
previewProxyTargetCache.delete(proxyCacheKey);
if (!cancelled) {
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
}
return;
}
previewProxyTargetCache.set(proxyCacheKey, { proxyBasePath, previewToken, expiresAt });
if (!cancelled) {
setProxyState({ status: 'ready', proxyBasePath, previewToken, expiresAt });
}
} catch (error) {
previewProxyTargetCache.delete(proxyCacheKey);
if (!cancelled) {
const message = error instanceof Error ? error.message : String(error);
setProxyState({ status: 'error', message });
}
}
})();
return () => {
cancelled = true;
};
}, [isLoopback, proxyCacheKey, proxyRegistrationNonce, t, targetKey]);
const directSrc = normalizedUrl
&& (normalizedUrl.protocol === 'http:' || normalizedUrl.protocol === 'https:')
? normalizedUrl.toString()
: '';
const proxyUrlAuthKey = isLoopback && proxyState.status === 'ready'
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
: '';
React.useEffect(() => {
if (!proxyUrlAuthKey) {
setUrlAuthReadyKey('');
return;
}
let cancelled = false;
setUrlAuthReadyKey('');
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
.then((token) => {
if (!cancelled && token) setUrlAuthReadyKey(proxyUrlAuthKey);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [proxyUrlAuthKey]);
const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl && urlAuthReadyKey === proxyUrlAuthKey
? (() => {
const path = normalizedUrl.pathname || '/';
const searchParams = new URLSearchParams(normalizedUrl.search);
searchParams.delete('oc_url_token');
searchParams.delete('oc_client_token');
searchParams.set('ocPreview', String(reloadNonce));
searchParams.set('oc_preview_token', proxyState.previewToken || '');
const search = searchParams.toString();
const hash = normalizedUrl.hash || '';
return getRuntimeUrlResolver().authenticatedAsset(`${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`);
})()
: '';
const effectiveSrc = isLoopback ? proxySrc : directSrc;
const headerSrc = isLoopback ? stripPreviewTokenFromUrl(proxySrc) : directSrc;
const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle' || urlAuthReadyKey !== proxyUrlAuthKey);
const showError = isLoopback && proxyState.status === 'error';
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey || !effectiveDirectory) {
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
return;
}
const pageUrl = rawUrl || effectiveSrc || '';
const viewport = typeof window !== 'undefined'
? { width: window.innerWidth, height: window.innerHeight }
: { width: 0, height: 0 };
const devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
void (async () => {
let attachedScreenshot = false;
try {
const iframe = iframeRef.current;
const screenshot = iframe ? await renderPreviewScreenshot(iframe, target) : null;
if (screenshot) {
await addAttachedFile(screenshot);
attachedScreenshot = true;
}
} catch {
attachedScreenshot = false;
}
addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
source: 'preview-annotation',
fileLabel: pageUrl || 'preview',
startLine: 1,
endLine: 1,
code: formatPreviewAnnotationMarkdown({
pageUrl,
viewport,
devicePixelRatio,
target,
screenshotAttached: attachedScreenshot,
intro: t('contextPanel.preview.inspect.attachAnnotation'),
}),
language: 'markdown',
text: '',
});
toast.success(t('contextPanel.preview.inspect.attached'));
})();
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveDirectory, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
React.useEffect(() => {
setBridgeReady(false);
setConsoleEvents([]);
setConsoleOpen(false);
setConsoleFilter('all');
setInspectMode(false);
setHoverTarget(null);
nextConsoleEventIdRef.current = 1;
}, [effectiveSrc]);
React.useEffect(() => {
const frameWindow = iframeRef.current?.contentWindow;
if (!bridgeReady || !frameWindow) {
return;
}
postPreviewBridgeMessage(frameWindow, proxySrc, {
source: 'openchamber-preview-parent',
version: 1,
type: 'set-inspect-mode',
enabled: inspectMode,
});
}, [bridgeReady, inspectMode, proxySrc]);
React.useEffect(() => {
const frameWindow = iframeRef.current?.contentWindow;
if (!bridgeReady || !frameWindow) {
return;
}
postPreviewBridgeMessage(frameWindow, proxySrc, {
source: 'openchamber-preview-parent',
version: 1,
type: 'set-color-scheme',
scheme: previewColorScheme,
});
}, [bridgeReady, previewColorScheme, proxySrc]);
React.useEffect(() => {
if (!inspectMode || typeof window === 'undefined') return;
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
setInspectMode(false);
}
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [inspectMode]);
React.useEffect(() => {
if (!isLoopback || typeof window === 'undefined') {
return;
}
const stringify = (value: unknown): string => {
if (typeof value === 'string') return value;
if (value === null || value === undefined) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const pushConsoleEvent = (event: Omit<PreviewConsoleEvent, 'id'>) => {
const id = nextConsoleEventIdRef.current;
nextConsoleEventIdRef.current += 1;
setConsoleEvents((current) => {
const next = [...current, { ...event, id }];
return next.length > PREVIEW_CONSOLE_EVENT_LIMIT
? next.slice(next.length - PREVIEW_CONSOLE_EVENT_LIMIT)
: next;
});
};
const handler = (event: MessageEvent<PreviewBridgeMessage>) => {
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
const data = event.data;
if (!data || data.source !== 'openchamber-preview-bridge' || data.version !== 1) {
return;
}
if (data.type === 'ready') {
setBridgeReady(true);
return;
}
if (data.type === 'console') {
const level = data.level === 'error' || data.level === 'warn' || data.level === 'info' || data.level === 'debug'
? data.level
: 'log';
const args = Array.isArray(data.args) ? data.args.map(stringify).filter(Boolean) : [];
pushConsoleEvent({
level,
message: args.join(' '),
ts: typeof data.ts === 'number' ? data.ts : Date.now(),
});
return;
}
if (data.type === 'runtime-error') {
const filename = stringify(data.filename);
const line = typeof data.line === 'number' ? data.line : null;
const column = typeof data.column === 'number' ? data.column : null;
const location = filename
? `${filename}${line !== null ? `:${line}${column !== null ? `:${column}` : ''}` : ''}`
: '';
const stack = stringify(data.stack);
pushConsoleEvent({
level: 'runtime',
message: stringify(data.message) || t('contextPanel.preview.console.runtimeError'),
details: [location, stack].filter(Boolean).join('\n'),
ts: typeof data.ts === 'number' ? data.ts : Date.now(),
});
return;
}
if (data.type === 'resource-error') {
const tag = stringify(data.tag) || 'resource';
const url = stringify(data.url);
pushConsoleEvent({
level: 'resource',
message: url ? `${tag}: ${url}` : tag,
details: stringify(data.outerHTML),
ts: typeof data.ts === 'number' ? data.ts : Date.now(),
});
return;
}
if (data.type === 'hover') {
setHoverTarget(isPreviewElementMetadata(data.target) ? data.target : null);
return;
}
if (data.type === 'select' && isPreviewElementMetadata(data.target)) {
setHoverTarget(data.target);
setInspectMode(false);
attachPreviewAnnotation(data.target);
return;
}
if (data.type === 'navigate-preview') {
const nextUrl = typeof data.url === 'string' ? data.url : '';
const navigation = data.navigation === 'external' ? 'external' : 'proxy';
if (nextUrl && navigation === 'external') {
void openExternalUrl(nextUrl);
return;
}
if (nextUrl) {
onNavigate(nextUrl);
}
}
};
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}, [attachPreviewAnnotation, isLoopback, onNavigate, t]);
const consoleErrorCount = consoleEvents.filter((event) => event.level === 'error' || event.level === 'runtime' || event.level === 'resource').length;
const filteredConsoleEvents = consoleEvents.filter((event) => getPreviewConsoleFilterMatch(event, consoleFilter));
const copyConsoleEvents = React.useCallback(() => {
const header = [
`Preview URL: ${rawUrl || effectiveSrc || ''}`,
`Events: ${consoleEvents.length}`,
'',
].join('\n');
const text = consoleEvents.map((event) => {
const timestamp = new Date(event.ts).toISOString();
const details = event.details ? `\n${event.details}` : '';
return `[${timestamp}] [${event.level}] ${event.message}${details}`;
}).join('\n');
void copyTextToClipboard(`${header}${text}`).then((result) => {
if (result.ok) {
toast.success(t('contextPanel.preview.console.copied'));
} else {
toast.error(t('contextPanel.preview.console.copyFailed'));
}
});
}, [consoleEvents, effectiveSrc, rawUrl, t]);
const attachConsoleEvents = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey || !effectiveDirectory) {
toast.error(t('contextPanel.preview.console.attachNoSession'));
return;
}
const header = [
`Preview URL: ${rawUrl || effectiveSrc || ''}`,
`Events: ${consoleEvents.length}`,
'',
].join('\n');
const text = consoleEvents.map((event) => {
const timestamp = new Date(event.ts).toISOString();
const details = event.details ? `\n${event.details}` : '';
return `[${timestamp}] [${event.level}] ${event.message}${details}`;
}).join('\n');
addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
source: 'preview-console',
fileLabel: rawUrl || effectiveSrc || 'preview',
startLine: 1,
endLine: Math.max(1, consoleEvents.length),
code: `${header}${text}`,
language: 'text',
text: t('contextPanel.preview.console.attachAnnotation'),
});
toast.success(t('contextPanel.preview.console.attached'));
}, [addInlineCommentDraft, consoleEvents, currentSessionId, effectiveDirectory, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
// Out-of-band upstream probe: iframes don't expose HTTP status to the parent,
// so when the proxy returns a 502 (upstream dev server is offline) the iframe
// would just render the raw JSON error body. Probe the proxy URL with a GET
// request and surface a friendly overlay when the upstream is unreachable.
type UpstreamState = 'unknown' | 'starting' | 'reachable' | 'unreachable';
const [upstreamState, setUpstreamState] = React.useState<UpstreamState>('unknown');
const upstreamProbeStartedAtRef = React.useRef<number>(0);
const upstreamProbeAttemptRef = React.useRef<number>(0);
const upstreamProbeKeyRef = React.useRef<string>('');
const proxyRecoveryAttemptedKeyRef = React.useRef<string>('');
const PREVIEW_STARTUP_GRACE_MS = 15_000;
React.useEffect(() => {
if (!proxySrc) {
setUpstreamState('unknown');
upstreamProbeKeyRef.current = '';
upstreamProbeStartedAtRef.current = 0;
upstreamProbeAttemptRef.current = 0;
return;
}
let cancelled = false;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
if (upstreamProbeKeyRef.current !== proxyCacheKey) {
upstreamProbeKeyRef.current = proxyCacheKey;
upstreamProbeStartedAtRef.current = Date.now();
upstreamProbeAttemptRef.current = 0;
}
const scheduleRetry = (delay: number) => {
retryTimeout = setTimeout(() => {
if (!cancelled) bumpReload();
}, delay);
};
setUpstreamState('unknown');
void (async () => {
const probe = async (): Promise<Response | null> => {
try {
return await runtimeFetch(proxySrc, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
redirect: 'manual',
});
} catch {
return null;
}
};
const response = await probe();
if (cancelled) return;
if (!response) {
setUpstreamState('unreachable');
scheduleRetry(5000);
return;
}
const recoveryAction = getPreviewTargetRecoveryAction(
response.headers,
proxyRecoveryAttemptedKeyRef.current === proxyCacheKey,
);
if (recoveryAction !== 'none') {
previewProxyTargetCache.delete(proxyCacheKey);
if (recoveryAction === 'retry-registration') {
proxyRecoveryAttemptedKeyRef.current = proxyCacheKey;
setProxyState({ status: 'loading' });
bumpProxyRegistration();
} else {
const errorBody = await response.json().catch(() => ({}));
if (cancelled) return;
const message = typeof errorBody?.error === 'string'
? errorBody.error
: `HTTP ${response.status}`;
setProxyState({ status: 'error', message });
}
return;
}
// The proxy emits 502 when the upstream is unreachable. Anything else
// (including 4xx from the upstream) means the upstream answered.
if (response.status !== 502) {
proxyRecoveryAttemptedKeyRef.current = '';
setUpstreamState('reachable');
return;
}
const startedAt = upstreamProbeStartedAtRef.current || Date.now();
const elapsed = Date.now() - startedAt;
if (elapsed < PREVIEW_STARTUP_GRACE_MS) {
// Dev servers can take a moment to bind. During the grace window,
// keep retrying and show a softer "starting" state.
setUpstreamState('starting');
upstreamProbeAttemptRef.current += 1;
const attempt = upstreamProbeAttemptRef.current;
const delay = Math.min(2000, 250 * Math.pow(2, Math.min(4, attempt)));
scheduleRetry(delay);
return;
}
setUpstreamState('unreachable');
scheduleRetry(5000);
})();
return () => {
cancelled = true;
if (retryTimeout) clearTimeout(retryTimeout);
};
}, [proxyCacheKey, proxySrc, reloadNonce]);
const showUpstreamStarting = isLoopback
&& proxyState.status === 'ready'
&& (upstreamState === 'unknown' || upstreamState === 'starting');
const showUpstreamUnreachable = isLoopback
&& proxyState.status === 'ready'
&& upstreamState === 'unreachable';
const handlePreviewFrameLoad = React.useCallback((event: React.SyntheticEvent<HTMLIFrameElement>) => {
if (!isLoopback || proxyState.status !== 'ready') {
return;
}
if (typeof window === 'undefined') {
return;
}
const frameWindow = event.currentTarget.contentWindow;
if (!frameWindow) {
return;
}
try {
const location = frameWindow.location;
const proxyOrigin = getPreviewProxyOrigin(proxySrc);
if (location.origin !== proxyOrigin) {
return;
}
if (location.pathname.startsWith(proxyState.proxyBasePath)) {
return;
}
const nextPath = `${proxyState.proxyBasePath}${location.pathname}${location.search}${location.hash}`;
frameWindow.location.replace(nextPath);
} catch {
// Cross-origin frames are expected for non-loopback/direct previews.
}
}, [isLoopback, proxySrc, proxyState]);
return (
<div className="absolute inset-0 flex flex-col">
<div className="flex items-center gap-1 border-b border-border/40 bg-[var(--surface-background)] px-2 py-1">
<div className="min-w-0 flex-1 truncate typography-micro text-muted-foreground" title={headerSrc || rawUrl}>
{headerSrc || rawUrl || t('contextPanel.preview.empty')}
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => bumpReload()}
title={t('contextPanel.preview.actions.reload')}
aria-label={t('contextPanel.preview.actions.reload')}
disabled={!effectiveSrc}
>
<Icon name="refresh" className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => {
if (!directSrc) return;
void openExternalUrl(directSrc);
}}
title={t('contextPanel.preview.actions.openExternal')}
aria-label={t('contextPanel.preview.actions.openExternal')}
disabled={!directSrc}
>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
{isLoopback ? (
<Button
type="button"
size="sm"
variant={inspectMode ? 'secondary' : 'ghost'}
className="h-7 gap-1 px-2"
onClick={() => setInspectMode((value) => !value)}
title={t('contextPanel.preview.inspect.toggle')}
aria-label={t('contextPanel.preview.inspect.toggle')}
disabled={!bridgeReady}
>
<Icon name="cursor" className="h-3.5 w-3.5" />
</Button>
) : null}
{isLoopback ? (
<Button
type="button"
size="sm"
variant={consoleOpen ? 'secondary' : 'ghost'}
className="h-7 gap-1 px-2"
onClick={() => setConsoleOpen((value) => !value)}
title={bridgeReady ? t('contextPanel.preview.console.open') : t('contextPanel.preview.console.waiting')}
aria-label={bridgeReady ? t('contextPanel.preview.console.open') : t('contextPanel.preview.console.waiting')}
disabled={!bridgeReady && consoleEvents.length === 0}
>
<Icon name="terminal-box" className="h-3.5 w-3.5" />
{consoleErrorCount > 0 ? (
<span className="typography-micro text-status-error">{consoleErrorCount}</span>
) : null}
</Button>
) : null}
</div>
<div className="relative min-h-0 flex-1 bg-background">
{showUpstreamStarting ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<div>{t('contextPanel.preview.startingServer')}</div>
<div className="text-xs opacity-70">{t('contextPanel.preview.startingServerHint')}</div>
</div>
) : showUpstreamUnreachable ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<div>{t('contextPanel.preview.upstreamUnreachable')}</div>
<div className="text-xs opacity-70">{t('contextPanel.preview.upstreamUnreachableHint')}</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => bumpReload()}
>
{t('contextPanel.preview.actions.retry')}
</Button>
</div>
) : effectiveSrc && (!isLoopback || upstreamState === 'reachable') ? (
<div className="relative h-full w-full">
<iframe
ref={iframeRef}
key={`${effectiveSrc}:${reloadNonce}`}
src={effectiveSrc}
title={t('contextPanel.preview.iframeTitle')}
className="h-full w-full border-0"
style={{ colorScheme: previewColorScheme }}
onLoad={handlePreviewFrameLoad}
sandbox={isLoopback
? 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads'
: 'allow-scripts allow-forms'}
/>
{inspectMode && hoverTarget ? (
<div
className="pointer-events-none absolute rounded-sm border-2 border-[var(--interactive-focus-ring)] bg-[var(--interactive-focus-ring)]/35"
style={{
left: hoverTarget.bounds.x,
top: hoverTarget.bounds.y,
width: hoverTarget.bounds.width,
height: hoverTarget.bounds.height,
}}
>
<div className="absolute -top-6 left-0 max-w-64 truncate rounded bg-[var(--surface-elevated)] px-2 py-0.5 typography-micro text-foreground shadow">
{hoverTarget.tag}{hoverTarget.text ? ` · ${hoverTarget.text}` : ''}
</div>
</div>
) : null}
</div>
) : showLoading ? (
<div className="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">
{t('contextPanel.preview.loading')}
</div>
) : showError ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-sm text-muted-foreground">
<div>{t('contextPanel.preview.proxyError')}</div>
{proxyState.status === 'error' ? (
<div className="text-center text-xs opacity-70">{proxyState.message}</div>
) : null}
</div>
) : (
<div className="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">
{t('contextPanel.preview.invalidUrl')}
</div>
)}
{consoleOpen ? (
<div className="absolute inset-x-3 bottom-3 z-10 max-h-[45%] overflow-hidden rounded-xl border border-border/70 bg-[var(--surface-elevated)] shadow-lg">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div className="typography-ui-label text-foreground">{t('contextPanel.preview.console.title')}</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="xs"
variant="ghost"
onClick={attachConsoleEvents}
disabled={consoleEvents.length === 0}
>
{t('contextPanel.preview.console.attach')}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={copyConsoleEvents}
disabled={consoleEvents.length === 0}
>
{t('contextPanel.preview.console.copy')}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setConsoleEvents([])}
disabled={consoleEvents.length === 0}
>
{t('contextPanel.preview.console.clear')}
</Button>
</div>
</div>
<div className="flex items-center gap-1 border-b border-border/30 px-3 py-1.5">
{(['all', 'errors', 'warnings', 'logs'] as const).map((filter) => (
<Button
key={filter}
type="button"
size="xs"
variant={consoleFilter === filter ? 'secondary' : 'ghost'}
onClick={() => setConsoleFilter(filter)}
>
{filter === 'all'
? t('contextPanel.preview.console.filter.all')
: filter === 'errors'
? t('contextPanel.preview.console.filter.errors')
: filter === 'warnings'
? t('contextPanel.preview.console.filter.warnings')
: t('contextPanel.preview.console.filter.logs')}
</Button>
))}
</div>
<div className="max-h-64 overflow-auto p-2 typography-code text-xs">
{consoleEvents.length === 0 ? (
<div className="px-2 py-3 text-muted-foreground">{t('contextPanel.preview.console.empty')}</div>
) : filteredConsoleEvents.length === 0 ? (
<div className="px-2 py-3 text-muted-foreground">{t('contextPanel.preview.console.noFilteredEvents')}</div>
) : filteredConsoleEvents.map((event) => (
<div key={event.id} className="border-b border-border/30 px-2 py-1 last:border-b-0">
<div className="flex gap-2">
<span className={cn(
'shrink-0 uppercase',
event.level === 'error' || event.level === 'runtime' || event.level === 'resource'
? 'text-status-error'
: event.level === 'warn'
? 'text-status-warning'
: 'text-muted-foreground'
)}>
{event.level}
</span>
<span className="min-w-0 break-words text-foreground">{event.message}</span>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
);
};
type DesktopBrowserPaneProps = {
initialUrl: string;
directory: string;
tabID: string;
};
const isElectronBrowserRuntime = (): boolean => {
return typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__);
};
const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
const { t } = useI18n();
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
const normalized = normalizeBrowserUrl(initialUrl);
const startUrl = normalized !== 'about:blank' ? normalized : '';
const [urlInput, setUrlInput] = React.useState(startUrl);
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
const [loadedUrl, setLoadedUrl] = React.useState(startUrl);
const [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []);
const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1);
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
const [isLoading, setIsLoading] = React.useState(Boolean(startUrl));
const [isInspecting, setIsInspecting] = React.useState(false);
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState('');
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
const persistUrl = React.useCallback((url: string) => {
if (!url || url === 'about:blank' || !directory || !tabID) return;
setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]);
const applyUrl = React.useCallback((url: string, options?: { replaceHistory?: boolean; inFrame?: boolean }) => {
const normalizedUrl = normalizeBrowserUrl(url);
const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : '';
setCurrentUrl(nextUrl);
setUrlInput(nextUrl);
if (!options?.inFrame) {
setLoadedUrl(nextUrl);
setIsLoading(Boolean(nextUrl));
} else {
setIsLoading(false);
}
persistUrl(nextUrl);
setHistory((current) => {
if (!nextUrl) {
setHistoryIndex(-1);
return [];
}
if (options?.replaceHistory) {
return current;
}
const kept = historyIndex >= 0 ? current.slice(0, historyIndex + 1) : [];
const previous = kept[kept.length - 1];
if (previous === nextUrl) {
setHistoryIndex(kept.length - 1);
return kept;
}
const nextHistory = [...kept, nextUrl];
setHistoryIndex(nextHistory.length - 1);
return nextHistory;
});
}, [historyIndex, persistUrl]);
const goToHistory = React.useCallback((nextIndex: number) => {
const nextUrl = history[nextIndex];
if (!nextUrl) return;
setHistoryIndex(nextIndex);
setCurrentUrl(nextUrl);
setLoadedUrl(nextUrl);
setUrlInput(nextUrl);
setIsLoading(true);
persistUrl(nextUrl);
}, [history, persistUrl]);
const handleReload = React.useCallback(() => {
if (!currentUrl) return;
setIsLoading(true);
try {
iframeRef.current?.contentWindow?.location.reload();
} catch {
bumpReload();
}
}, [currentUrl]);
React.useEffect(() => {
if (!loadedUrl) {
setProxyState({ status: 'idle' });
return;
}
const proxyTargetKey = getBrowserProxyTargetKey(loadedUrl);
const cached = getCachedProxyTarget(proxyTargetKey);
if (cached?.previewToken) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
return;
}
if (cached) {
previewProxyTargetCache.delete(proxyTargetKey);
}
let cancelled = false;
setProxyState({ status: 'loading' });
setIsLoading(true);
void (async () => {
try {
const response = await runtimeFetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url: loadedUrl, allowExternal: true }),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
const message = typeof errorBody?.error === 'string'
? errorBody.error
: `HTTP ${response.status}`;
if (!cancelled) {
setProxyState({ status: 'error', message });
}
return;
}
const body = await response.json() as { proxyBasePath?: unknown; previewToken?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
const previewToken = typeof body.previewToken === 'string' ? body.previewToken : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
if (!proxyBasePath || !previewToken) {
if (!cancelled) {
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
}
return;
}
previewProxyTargetCache.set(proxyTargetKey, { proxyBasePath, previewToken, expiresAt });
if (!cancelled) {
setProxyState({ status: 'ready', proxyBasePath, previewToken, expiresAt });
}
} catch (error) {
if (!cancelled) {
const message = error instanceof Error ? error.message : String(error);
setProxyState({ status: 'error', message });
}
}
})();
return () => {
cancelled = true;
};
}, [loadedUrl, t]);
const proxyUrlAuthKey = loadedUrl && proxyState.status === 'ready'
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
: '';
React.useEffect(() => {
if (!proxyUrlAuthKey) {
setUrlAuthReadyKey('');
return;
}
let cancelled = false;
setUrlAuthReadyKey('');
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
.then((token) => {
if (!cancelled && token) setUrlAuthReadyKey(proxyUrlAuthKey);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [proxyUrlAuthKey]);
const proxySrc = React.useMemo(() => {
if (urlAuthReadyKey !== proxyUrlAuthKey) return '';
if (!loadedUrl || proxyState.status !== 'ready') return '';
try {
const parsed = new URL(loadedUrl);
const path = parsed.pathname || '/';
const searchParams = new URLSearchParams(parsed.search);
searchParams.delete('oc_url_token');
searchParams.delete('oc_client_token');
searchParams.set('ocPreview', String(reloadNonce));
searchParams.set('oc_preview_token', proxyState.previewToken || '');
const search = searchParams.toString();
return getRuntimeUrlResolver().authenticatedAsset(`${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${parsed.hash}`);
} catch {
return '';
}
}, [loadedUrl, proxyState, proxyUrlAuthKey, reloadNonce, urlAuthReadyKey]);
const iframeSrc = proxySrc || (proxyState.status === 'error' ? loadedUrl : '');
const getCurrentUrlFromFrameUrl = React.useCallback((frameUrl: string): string => {
if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
try {
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
? proxyState.proxyBasePath.slice(0, -1)
: proxyState.proxyBasePath;
if (parsedFrameUrl.origin !== window.location.origin || !parsedFrameUrl.pathname.startsWith(proxyBasePath)) {
return '';
}
const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/';
const upstreamOrigin = new URL(loadedUrl).origin;
return stripPreviewQueryParams(new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString());
} catch {
return '';
}
}, [loadedUrl, proxyState]);
const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => {
if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
try {
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
const upstreamOrigin = new URL(loadedUrl).origin;
if (parsedFrameUrl.origin !== window.location.origin || upstreamOrigin === window.location.origin) {
return '';
}
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
? proxyState.proxyBasePath.slice(0, -1)
: proxyState.proxyBasePath;
if (parsedFrameUrl.pathname.startsWith(proxyBasePath)) {
return '';
}
return stripPreviewQueryParams(new URL(`${parsedFrameUrl.pathname}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString());
} catch {
return '';
}
}, [loadedUrl, proxyState]);
const postInspectMode = React.useCallback((enabled: boolean) => {
const frameWindow = iframeRef.current?.contentWindow;
if (!frameWindow) return;
frameWindow.postMessage({
source: 'openchamber-preview-parent',
version: 1,
type: 'set-inspect-mode',
enabled,
}, window.location.origin);
}, []);
const attachBrowserAnnotation = React.useCallback(async (target: PreviewElementMetadata) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) {
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
return;
}
const iframe = iframeRef.current;
const frameWindow = iframe?.contentWindow;
const rect = iframe?.getBoundingClientRect();
const viewport = {
width: Number.isFinite(frameWindow?.innerWidth) ? frameWindow?.innerWidth ?? rect?.width ?? 0 : rect?.width ?? 0,
height: Number.isFinite(frameWindow?.innerHeight) ? frameWindow?.innerHeight ?? rect?.height ?? 0 : rect?.height ?? 0,
};
const file = iframe ? await renderPreviewScreenshot(iframe, target) : null;
const screenshotAttached = Boolean(file);
if (file) {
await addAttachedFile(file);
}
addInlineCommentDraft({ directory, sessionKey }, {
source: 'preview-annotation',
fileLabel: currentUrl || 'browser',
startLine: 1,
endLine: 1,
code: formatPreviewAnnotationMarkdown({
pageUrl: currentUrl,
viewport,
devicePixelRatio: window.devicePixelRatio || 1,
target,
screenshotAttached,
intro: t(screenshotAttached
? 'contextPanel.preview.inspect.attachAnnotationWithScreenshot'
: 'contextPanel.preview.inspect.attachAnnotation'),
}),
language: 'markdown',
text: '',
});
toast.success(t('contextPanel.preview.inspect.attached'));
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, directory, newSessionDraftOpen, t]);
const cancelInspect = React.useCallback(() => {
const iframe = iframeRef.current;
setHoverTarget(null);
postInspectMode(false);
if (!iframe) return;
void runIframeScript<unknown>(iframe, DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {});
}, [postInspectMode]);
React.useEffect(() => {
if (!isInspecting) return;
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
setIsInspecting(false);
cancelInspect();
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [cancelInspect, isInspecting]);
React.useEffect(() => () => cancelInspect(), [cancelInspect]);
React.useEffect(() => {
const handler = (event: MessageEvent<PreviewBridgeMessage>) => {
if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data;
if (!data || data.source !== 'openchamber-preview-bridge' || data.version !== 1) return;
if (data.type === 'ready') {
const frameUrl = typeof data.url === 'string' ? data.url : '';
const nextUrl = getCurrentUrlFromFrameUrl(frameUrl);
if (nextUrl && nextUrl !== currentUrl) {
applyUrl(nextUrl, { inFrame: true });
}
return;
}
if (data.type === 'hover') {
setHoverTarget(isPreviewElementMetadata(data.target) ? data.target : null);
return;
}
if (data.type === 'select' && isPreviewElementMetadata(data.target)) {
setHoverTarget(null);
setIsInspecting(false);
postInspectMode(false);
void attachBrowserAnnotation(data.target);
return;
}
if (data.type === 'navigate-preview') {
const nextUrl = typeof data.url === 'string' ? data.url : '';
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(nextUrl);
if (upstreamUrl) {
applyUrl(upstreamUrl);
return;
}
if (nextUrl) {
applyUrl(nextUrl);
}
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [applyUrl, attachBrowserAnnotation, currentUrl, getCurrentUrlFromFrameUrl, getUpstreamUrlFromLocalFrameUrl, postInspectMode]);
const handleInspect = React.useCallback(() => {
const iframe = iframeRef.current;
if (!iframe || !currentUrl) return;
if (isInspecting) {
setIsInspecting(false);
cancelInspect();
return;
}
if (proxySrc) {
setHoverTarget(null);
setIsInspecting(true);
postInspectMode(true);
return;
}
setIsInspecting(true);
void (async () => {
try {
const target = await runIframeScript<unknown>(iframe, DESKTOP_BROWSER_INSPECT_SCRIPT);
setIsInspecting(false);
if (!target || !isPreviewElementMetadata(target)) return;
await attachBrowserAnnotation(target);
} catch {
setIsInspecting(false);
toast.error(t('contextPanel.browser.inspectUnavailable'));
}
})();
}, [attachBrowserAnnotation, cancelInspect, currentUrl, isInspecting, postInspectMode, proxySrc, t]);
const handleIframeLoad = React.useCallback(() => {
try {
const frameUrl = iframeRef.current?.contentWindow?.location.href || '';
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl);
if (upstreamUrl) {
applyUrl(upstreamUrl, { inFrame: true });
return;
}
} catch {
// Cross-origin direct iframe fallback; regular load handling still applies.
}
setIsLoading(false);
if (isInspecting && proxySrc) {
postInspectMode(true);
}
}, [applyUrl, getUpstreamUrlFromLocalFrameUrl, isInspecting, postInspectMode, proxySrc]);
return (
<div className="absolute inset-0 flex flex-col bg-background">
<div className="flex items-center gap-1 border-b border-border/40 bg-[var(--surface-background)] px-2 py-1">
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={historyIndex <= 0} onClick={() => goToHistory(historyIndex - 1)}>
<Icon name="arrow-left" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={historyIndex < 0 || historyIndex >= history.length - 1} onClick={() => goToHistory(historyIndex + 1)}>
<Icon name="arrow-right" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={!currentUrl} onClick={handleReload}>
<Icon name="refresh" className="h-3.5 w-3.5" />
</Button>
<form className="min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); applyUrl(urlInput); }}>
<input
value={urlInput}
onChange={(event) => setUrlInput(event.target.value)}
className="h-7 w-full rounded-md border border-border/50 bg-[var(--surface-elevated)] px-2 typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]"
aria-label={t('contextPanel.browser.addressAria')}
/>
</form>
<Button
type="button"
variant={isInspecting ? 'secondary' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
disabled={!currentUrl}
onClick={handleInspect}
title={t('contextPanel.preview.inspect.toggle')}
aria-label={t('contextPanel.preview.inspect.toggle')}
>
<Icon name="cursor" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={!currentUrl} onClick={() => void openExternalUrl(currentUrl)}>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
</div>
<div className="relative min-h-0 flex-1 bg-background">
{iframeSrc ? (
<div className="absolute inset-0">
<iframe
key={`${tabID}:${reloadNonce}`}
ref={iframeRef}
src={iframeSrc}
title={t('contextPanel.browser.empty')}
className="absolute inset-0 h-full w-full border-0 bg-background"
allow="clipboard-read; clipboard-write; fullscreen"
allowFullScreen
onLoad={handleIframeLoad}
/>
{isInspecting && hoverTarget ? (
<div
className="pointer-events-none absolute rounded-sm border-2 border-[var(--interactive-focus-ring)] bg-[var(--interactive-focus-ring)]/35"
style={{
left: hoverTarget.bounds.x,
top: hoverTarget.bounds.y,
width: hoverTarget.bounds.width,
height: hoverTarget.bounds.height,
}}
>
<div className="absolute -top-6 left-0 max-w-64 truncate rounded bg-[var(--surface-elevated)] px-2 py-0.5 typography-micro text-foreground shadow">
{hoverTarget.tag}{hoverTarget.text ? ` · ${hoverTarget.text}` : ''}
</div>
</div>
) : null}
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-6 bg-background p-6 text-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" />
<span className="typography-ui-header text-muted-foreground">{t('contextPanel.browser.empty')}</span>
<span className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.browser.emptyHint')}</span>
<span className="max-w-md typography-micro leading-relaxed text-status-warning/70">{t('contextPanel.browser.trustNotice')}</span>
</div>
)}
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/70 typography-micro text-muted-foreground">
{t('common.loading')}
</div>
) : null}
</div>
</div>
);
};
const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
const { t } = useI18n();
const webviewRef = React.useRef<WebviewElement | null>(null);
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
const normalized = normalizeBrowserUrl(initialUrl);
const startUrl = normalized !== 'about:blank' ? normalized : '';
const initialWebviewSrcRef = React.useRef(normalizeBrowserUrl(initialUrl));
const [urlInput, setUrlInput] = React.useState(startUrl);
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
const [isInspecting, setIsInspecting] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const loadingTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const showLoading = isLoading;
const persistUrl = React.useCallback((url: string) => {
if (!url || url === 'about:blank' || !directory || !tabID) return;
setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
// Listen to webview navigation events
React.useEffect(() => {
const webview = webviewRef.current;
if (!webview) return;
const syncUrl = () => {
try {
const url = webview.getURL();
if (url && url !== 'about:blank') {
setCurrentUrl(url);
setUrlInput(url);
persistUrl(url);
}
} catch { /* webview not ready */ }
};
const onNavigate = (event: Event) => {
const detail = (event as CustomEvent<{ url: string }>).detail;
if (typeof detail?.url === 'string' && detail.url) {
setCurrentUrl(detail.url);
setUrlInput(detail.url);
persistUrl(detail.url);
}
};
const onStartLoading = () => {
if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current);
loadingTimerRef.current = setTimeout(() => setIsLoading(true), 200);
};
const onStopLoading = () => {
if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current);
setIsLoading(false);
syncUrl();
};
const onNewWindow = (event: Event) => {
const detail = (event as CustomEvent<{ url: string; disposition: string }>).detail;
if (detail?.disposition === 'new-window' || detail?.disposition === 'foreground-tab' || detail?.disposition === 'background-tab') {
event.preventDefault();
const w = webviewRef.current;
if (typeof w?.loadURL === 'function' && detail.url) {
w.loadURL(detail.url);
}
}
};
const installSameWebviewNavigation = () => {
try {
webview.executeJavaScript?.(DESKTOP_BROWSER_SAME_WEBVIEW_NAVIGATION_SCRIPT, true).catch(() => {});
} catch { /* webview not ready */ }
};
webview.addEventListener('did-navigate', onNavigate);
webview.addEventListener('did-navigate-in-page', onNavigate);
webview.addEventListener('did-start-loading', onStartLoading);
webview.addEventListener('did-stop-loading', onStopLoading);
webview.addEventListener('new-window', onNewWindow);
webview.addEventListener('dom-ready', installSameWebviewNavigation);
// Check current loading state imperatively — we may have missed the event
try {
if (!webview.isLoading()) {
setIsLoading(false);
syncUrl();
}
} catch { /* webview not ready */ }
installSameWebviewNavigation();
return () => {
if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current);
webview.removeEventListener('did-navigate', onNavigate);
webview.removeEventListener('did-navigate-in-page', onNavigate);
webview.removeEventListener('did-start-loading', onStartLoading);
webview.removeEventListener('did-stop-loading', onStopLoading);
webview.removeEventListener('new-window', onNewWindow);
webview.removeEventListener('dom-ready', installSameWebviewNavigation);
};
}, [persistUrl]);
// Safety timeout: hide loading overlay after 30s even if events fire late
React.useEffect(() => {
const safety = setTimeout(() => setIsLoading(false), 30_000);
return () => clearTimeout(safety);
}, []);
// Escape key cancels inspect mode
React.useEffect(() => {
if (!isInspecting) return;
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
setIsInspecting(false);
const webview = webviewRef.current;
try { webview?.executeJavaScript?.(DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {}); } catch { /* webview not ready */ }
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [isInspecting]);
// Cancel inspect on unmount
React.useEffect(() => {
const webview = webviewRef.current;
return () => {
try {
const url = webview?.getURL?.();
if (url && url !== 'about:blank') {
setContextPanelTabTargetPath(directory, tabID, url);
}
} catch { /* webview not ready */ }
try { webview?.executeJavaScript?.(DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {}); } catch { /* webview not ready */ }
};
}, [directory, tabID, setContextPanelTabTargetPath]);
const loadUrl = React.useCallback((value: string) => {
const webview = webviewRef.current;
if (typeof webview?.loadURL !== 'function') return;
const nextUrl = normalizeBrowserUrl(value);
try { webview.loadURL(nextUrl); } catch { /* webview may not be ready */ }
}, []);
const handleInspect = React.useCallback(() => {
const webview = webviewRef.current;
if (!webview) return;
if (isInspecting) {
setIsInspecting(false);
try { webview.executeJavaScript?.(DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {}); } catch { /* webview not ready */ }
return;
}
setIsInspecting(true);
webview.executeJavaScript?.(DESKTOP_BROWSER_INSPECT_SCRIPT, true)
.then(async (target: unknown) => {
setIsInspecting(false);
if (!target || !isPreviewElementMetadata(target)) return;
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) {
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
return;
}
const wcId = typeof webview.getWebContentsId === 'function' ? webview.getWebContentsId() : null;
if (wcId === null || wcId === undefined) return;
const capture = await invokeDesktopCommand<{ mime: string; base64: string; width: number; height: number }>(
'desktop_browser_capture_page', { webContentsId: wcId }
);
const cssViewport = await webview.executeJavaScript?.(
'({ width: window.innerWidth, height: window.innerHeight })', true
).catch(() => null) as { width: number; height: number } | null | undefined;
const cssWidth = Number.isFinite(cssViewport?.width) ? (cssViewport as { width: number }).width : capture.width;
const cssHeight = Number.isFinite(cssViewport?.height) ? (cssViewport as { height: number }).height : capture.height;
const file = await desktopAnnotationToFile(capture.base64, capture.width, capture.height, cssWidth, cssHeight, target);
const screenshotAttached = Boolean(file);
if (file) {
await addAttachedFile(file);
}
addInlineCommentDraft({ directory, sessionKey }, {
source: 'preview-annotation',
fileLabel: currentUrl || 'browser',
startLine: 1,
endLine: 1,
code: formatPreviewAnnotationMarkdown({
pageUrl: currentUrl,
viewport: { width: cssWidth, height: cssHeight },
devicePixelRatio: window.devicePixelRatio || 1,
target,
screenshotAttached,
intro: t('contextPanel.preview.inspect.attachAnnotationWithScreenshot'),
}),
language: 'markdown',
text: '',
});
toast.success(t('contextPanel.preview.inspect.attached'));
})
.catch(() => setIsInspecting(false));
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, directory, isInspecting, newSessionDraftOpen, t]);
return (
<div className="absolute inset-0 flex flex-col bg-background">
<div className="flex items-center gap-1 border-b border-border/40 bg-[var(--surface-background)] px-2 py-1">
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => { try { webviewRef.current?.goBack?.(); } catch { /* webview not ready */ } }}>
<Icon name="arrow-left" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => { try { webviewRef.current?.goForward?.(); } catch { /* webview not ready */ } }}>
<Icon name="arrow-right" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => { try { webviewRef.current?.reload?.(); } catch { /* webview not ready */ } }}>
<Icon name="refresh" className="h-3.5 w-3.5" />
</Button>
<form className="min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); loadUrl(urlInput); }}>
<input
value={urlInput}
onChange={(event) => setUrlInput(event.target.value)}
className="h-7 w-full rounded-md border border-border/50 bg-[var(--surface-elevated)] px-2 typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]"
aria-label={t('contextPanel.browser.addressAria')}
/>
</form>
<Button
type="button"
variant={isInspecting ? 'secondary' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={handleInspect}
title={t('contextPanel.preview.inspect.toggle')}
aria-label={t('contextPanel.preview.inspect.toggle')}
>
<Icon name="cursor" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => void openExternalUrl(currentUrl)}>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
</div>
<div className="relative min-h-0 flex-1 bg-background">
<webview
ref={webviewRef}
src={initialWebviewSrcRef.current}
partition="persist:openchamber-browser"
allowpopups
style={{ width: '100%', height: '100%', border: 'none' }}
/>
{(!currentUrl || currentUrl === 'about:blank') && !isLoading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-6 bg-background p-6 text-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" />
<span className="typography-ui-header text-muted-foreground">{t('contextPanel.browser.empty')}</span>
</div>
) : null}
{showLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/70 typography-micro text-muted-foreground">
{t('common.loading')}
</div>
) : null}
</div>
</div>
);
};
export const ContextPanel: React.FC = () => {
const { t } = useI18n();
const effectiveDirectory = useEffectiveDirectory() ?? '';
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const closeContextPanelTab = useUIStore((state) => state.closeContextPanelTab);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const contextEditorTreeVisible = useUIStore((state) => state.contextEditorTreeVisible);
const toggleContextEditorTree = useUIStore((state) => state.toggleContextEditorTree);
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
const { themeMode, setThemeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
const tabs = React.useMemo(() => panelState?.tabs ?? [], [panelState?.tabs]);
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null;
const isOpen = Boolean(panelState?.isOpen && activeTab);
const isExpanded = Boolean(isOpen && panelState?.expanded);
const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null);
const activeModeForWidth = activeTab?.mode ?? null;
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
const widthFallbackBase = availablePanelAreaWidth
?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2);
const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase));
const chatSessionIDs = React.useMemo(() => {
const ids: string[] = [];
for (const tab of tabs) {
if (tab.mode !== 'chat') continue;
const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey);
if (sessionID && !ids.includes(sessionID)) ids.push(sessionID);
}
return ids;
}, [tabs]);
const sessionTitleById = useSessionTitleMap(directoryKey || undefined, chatSessionIDs);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(width);
const resizingWidthRef = React.useRef<number | null>(null);
const activeResizePointerIDRef = React.useRef<number | null>(null);
const panelRef = React.useRef<HTMLElement | null>(null);
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
const wasOpenRef = React.useRef(false);
// Tracks the panel area width so fraction-based surface defaults stay
// proportional as the window resizes; manual widths remain fixed px.
React.useLayoutEffect(() => {
const parent = panelRef.current?.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => {
setAvailablePanelAreaWidth(parent.clientWidth || null);
});
observer.observe(parent);
setAvailablePanelAreaWidth(parent.clientWidth || null);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!isOpen || wasOpenRef.current) {
wasOpenRef.current = isOpen;
return;
}
const frame = window.requestAnimationFrame(() => {
panelRef.current?.focus({ preventScroll: true });
});
wasOpenRef.current = true;
return () => window.cancelAnimationFrame(frame);
}, [isOpen]);
// Deferred resize: reflowing the chat column and the active surface (xterm,
// editor, embedded chat iframes) on every drag frame is unavoidably janky,
// so during the drag only a ghost guide line follows the pointer and the
// real width is applied once on release (riding the width transition).
const resizeAvailableWidthRef = React.useRef<number | null>(null);
// The panel content follows the guide line lazily: the real width is
// re-applied at most every RESIZE_FOLLOW_INTERVAL_MS and the standing
// 200ms width transition smooths each step, VS Code-style.
const resizeFollowTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const applyFollowWidth = React.useCallback(() => {
resizeFollowTimerRef.current = null;
const panel = panelRef.current;
const next = resizingWidthRef.current;
if (!panel || next === null) {
return;
}
panel.style.setProperty('--oc-context-panel-width', `${next}px`);
}, []);
React.useEffect(() => () => {
if (resizeFollowTimerRef.current !== null) {
clearTimeout(resizeFollowTimerRef.current);
}
}, []);
const clampWidthForDrag = React.useCallback((nextWidth: number) => {
const clamped = clampWidth(nextWidth);
const available = resizeAvailableWidthRef.current;
return available === null ? clamped : Math.min(clamped, Math.max(1, available));
}, []);
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
if (!isOpen || isExpanded || !directoryKey) {
return;
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
resizingWidthRef.current = width;
// Measure once per drag; no layout reads happen during pointermove.
resizeAvailableWidthRef.current = getAvailablePanelWidth(panelRef.current);
document.documentElement.style.cursor = 'col-resize';
event.preventDefault();
}, [directoryKey, isExpanded, isOpen, width]);
const finishResize = React.useCallback(() => {
// Apply the final width once, letting the regular 200ms width transition
// carry the panel to the release position.
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
resizingWidthRef.current = null;
resizeAvailableWidthRef.current = null;
if (resizeFollowTimerRef.current !== null) {
clearTimeout(resizeFollowTimerRef.current);
resizeFollowTimerRef.current = null;
}
document.documentElement.style.cursor = '';
if (directoryKey && activeModeForWidth) {
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth);
}
setIsResizing(false);
activeResizePointerIDRef.current = null;
}, [activeModeForWidth, clampWidthForDrag, directoryKey, setContextPanelWidth, width]);
// Window-level drag listeners: tracking the pointer via the 3px handle and
// pointer capture is unreliable (capture can fail over iframes and a missed
// pointerup leaves the drag stuck), so while resizing the whole window
// tracks the pointer and any release/cancel/blur ends the drag.
React.useEffect(() => {
if (!isResizing) {
return;
}
const handleMove = (event: PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId) {
return;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampWidthForDrag(startWidthRef.current + delta);
if (resizingWidthRef.current === nextWidth) {
return;
}
resizingWidthRef.current = nextWidth;
if (resizeFollowTimerRef.current === null) {
resizeFollowTimerRef.current = setTimeout(applyFollowWidth, RESIZE_FOLLOW_INTERVAL_MS);
}
};
const handleUp = (event: PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId) {
return;
}
finishResize();
};
const handleWindowBlur = () => {
finishResize();
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleUp);
window.addEventListener('blur', handleWindowBlur);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
window.removeEventListener('blur', handleWindowBlur);
};
}, [applyFollowWidth, clampWidthForDrag, finishResize, isResizing]);
React.useEffect(() => {
if (!isResizing) {
resizingWidthRef.current = null;
document.documentElement.style.cursor = '';
}
}, [isResizing]);
const handleClose = React.useCallback(() => {
if (!directoryKey) {
return;
}
closeContextPanel(directoryKey);
}, [closeContextPanel, directoryKey]);
const handleToggleExpanded = React.useCallback(() => {
if (!directoryKey) {
return;
}
toggleContextPanelExpanded(directoryKey);
}, [directoryKey, toggleContextPanelExpanded]);
const handlePanelKeyDownCapture = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
event.stopPropagation();
handleClose();
}, [handleClose]);
React.useEffect(() => {
if (!directoryKey || !activeTab) {
return;
}
if (activeTab.mode === 'file' && activeTab.targetPath) {
setSelectedFilePath(directoryKey, activeTab.targetPath, { allowOutsideRoot: true });
return;
}
}, [activeTab, directoryKey, setSelectedFilePath]);
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
const activeChatSessionID = activeTab?.mode === 'chat' ? getSessionIDFromDedupeKey(activeTab.dedupeKey) : null;
React.useEffect(() => {
if (!isOpen || !directoryKey || !activeChatSessionID || typeof window === 'undefined') {
return;
}
const markActiveChatViewed = () => {
if (document.visibilityState === 'hidden' || !document.hasFocus()) {
setExternallyViewedSession(directoryKey, activeChatSessionID, false);
return;
}
markSessionViewed(activeChatSessionID);
setExternallyViewedSession(directoryKey, activeChatSessionID, true);
};
markActiveChatViewed();
const interval = window.setInterval(markActiveChatViewed, 10_000);
window.addEventListener('focus', markActiveChatViewed);
window.addEventListener('blur', markActiveChatViewed);
document.addEventListener('visibilitychange', markActiveChatViewed);
return () => {
window.clearInterval(interval);
window.removeEventListener('focus', markActiveChatViewed);
window.removeEventListener('blur', markActiveChatViewed);
document.removeEventListener('visibilitychange', markActiveChatViewed);
setExternallyViewedSession(directoryKey, activeChatSessionID, false);
};
}, [activeChatSessionID, directoryKey, isOpen]);
const getEmbeddedChatSrc = React.useCallback((tabID: string, sessionID: string, readOnly: boolean): string => {
return getOrCreateEmbeddedSessionChatURL(chatFrameSrcByTabIDRef.current, tabID, sessionID, directoryKey || null, readOnly, {
mode: themeMode,
lightThemeId,
darkThemeId,
currentTheme,
});
}, [currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]);
React.useEffect(() => {
const liveTabIDs = new Set(tabs.map((tab) => tab.id));
for (const tabID of chatFrameSrcByTabIDRef.current.keys()) {
if (!liveTabIDs.has(tabID)) {
chatFrameSrcByTabIDRef.current.delete(tabID);
}
}
}, [tabs]);
const handleDiffScopeChange = React.useCallback((nextScope: PendingDiffScope) => {
if (!directoryKey || activeTab?.mode !== 'diff') {
return;
}
openContextPanelTab(directoryKey, {
mode: 'diff',
targetPath: activeTab.targetPath,
stagedDiff: nextScope === 'staged',
diffScope: nextScope,
});
}, [activeTab, directoryKey, openContextPanelTab]);
const postThemeSyncToEmbeddedChat = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
}
const payload = {
themeMode,
lightThemeId,
darkThemeId,
currentTheme,
};
for (const frame of chatFrameRefs.current.values()) {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
continue;
}
const directThemeSync = (frameWindow as unknown as {
__openchamberApplyThemeSync?: (themePayload: typeof payload) => void;
}).__openchamberApplyThemeSync;
if (typeof directThemeSync === 'function') {
try {
directThemeSync(payload);
continue;
} catch {
// fallback to postMessage below
}
}
frameWindow.postMessage(
{
type: 'openchamber:theme-sync',
payload,
},
window.location.origin,
);
}
}, [currentTheme, darkThemeId, lightThemeId, themeMode]);
const postChatSettingsSyncToEmbeddedChat = React.useCallback(() => {
if (typeof window === 'undefined') return;
const payload = { allowPromptingSubagentSessions };
for (const frame of chatFrameRefs.current.values()) {
const frameWindow = frame.contentWindow;
if (!frameWindow) continue;
const directSync = (frameWindow as unknown as {
__openchamberApplyChatSettingsSync?: (settings: typeof payload) => void;
}).__openchamberApplyChatSettingsSync;
if (typeof directSync === 'function') {
try {
directSync(payload);
continue;
} catch {
// fallback to postMessage below
}
}
frameWindow.postMessage({ type: 'openchamber:chat-settings-sync', payload }, window.location.origin);
}
}, [allowPromptingSubagentSessions]);
const postEmbeddedVisibilityToChats = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
}
for (const [tabID, frame] of chatFrameRefs.current.entries()) {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
continue;
}
const payload = { visible: activeChatTabID === tabID };
const directVisibilitySync = (frameWindow as unknown as {
__openchamberSetEmbeddedVisibility?: (visibilityPayload: typeof payload) => void;
}).__openchamberSetEmbeddedVisibility;
if (typeof directVisibilitySync === 'function') {
try {
directVisibilitySync(payload);
continue;
} catch {
// fallback to postMessage below
}
}
frameWindow.postMessage(
{
type: 'openchamber:embedded-visibility',
payload,
},
window.location.origin,
);
}
}, [activeChatTabID]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return;
}
const isKnownChatFrame = Array.from(chatFrameRefs.current.values())
.some((frame) => frame.contentWindow === event.source);
if (!isKnownChatFrame) {
return;
}
const data = event.data as { type?: unknown };
if (data?.type === 'openchamber:theme-sync-request') {
postThemeSyncToEmbeddedChat();
return;
}
if (data?.type === 'openchamber:chat-settings-request') {
postChatSettingsSyncToEmbeddedChat();
return;
}
if (data?.type !== 'openchamber:cycle-theme-request') {
return;
}
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const currentIndex = modes.indexOf(themeMode);
const nextIndex = (currentIndex + 1) % modes.length;
setThemeMode(modes[nextIndex]);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [postChatSettingsSyncToEmbeddedChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]);
React.useLayoutEffect(() => {
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
if (!hasAnyChatTab) {
return;
}
postThemeSyncToEmbeddedChat();
postChatSettingsSyncToEmbeddedChat();
postEmbeddedVisibilityToChats();
}, [darkThemeId, lightThemeId, postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]);
// The rail switches between surfaces (modes); the in-panel strip only lists
// instances of the active multi-instance surface (open files, split chats,
// preview targets).
const isMultiInstanceMode = activeTab?.mode === 'file' || activeTab?.mode === 'chat' || activeTab?.mode === 'preview';
const activeModeTabs = React.useMemo(
() => (activeTab ? tabs.filter((tab) => tab.mode === activeTab.mode) : []),
[activeTab, tabs],
);
const tabItems = React.useMemo(() => activeModeTabs.map((tab) => {
const rawLabel = getTabLabel(tab, sessionTitleById, t);
const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS);
const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory);
return {
id: tab.id,
label,
icon: getTabIcon(tab),
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
closeLabel: t('contextPanel.tab.closeTabAria', { label }),
};
}), [activeModeTabs, effectiveDirectory, sessionTitleById, t]);
const activeNonChatContent = activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'git'
? <GitView isActive={isOpen} />
: activeTab?.mode === 'pr'
? <PullRequestView />
: activeTab?.mode === 'notes'
? <ProjectContextPanel />
: activeTab?.mode === 'plan'
? <PlanView targetPath={activeTab.targetPath} />
: activeTab?.mode === 'preview'
? <PreviewPane rawUrl={activeTab.targetPath ?? ''} onNavigate={(url) => openContextPreview(effectiveDirectory, url)} />
: (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="global" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('contextPanel.preview.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.preview.description')}</div>
</div>
);
const chatTabs = React.useMemo(
() => tabs.filter((tab) => tab.mode === 'chat'),
[tabs],
);
const browserTabs = React.useMemo(
() => tabs.filter((tab) => tab.mode === 'browser'),
[tabs],
);
const diffTabs = React.useMemo(
() => tabs.filter((tab) => tab.mode === 'diff'),
[tabs],
);
const hasTerminalTab = React.useMemo(
() => tabs.some((tab) => tab.mode === 'terminal'),
[tabs],
);
const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane;
const hasFileTabs = React.useMemo(
() => tabs.some((tab) => tab.mode === 'file'),
[tabs],
);
const hasOpenEditorFile = React.useMemo(
() => tabs.some((tab) => tab.mode === 'file' && tab.targetPath),
[tabs],
);
const isFileTabActive = activeTab?.mode === 'file';
const header = (
<header className="flex h-10 items-stretch border-b border-border/40">
{isMultiInstanceMode ? (
<SortableTabsStrip
items={tabItems}
activeId={activeTab?.id ?? null}
onSelect={(tabID) => {
if (!directoryKey) {
return;
}
setActiveContextPanelTab(directoryKey, tabID);
}}
onClose={(tabID) => {
if (!directoryKey) {
return;
}
closeContextPanelTab(directoryKey, tabID);
}}
onReorder={(activeTabID, overTabID) => {
if (!directoryKey) {
return;
}
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
}}
layoutMode="scrollable"
variant="default"
/>
) : (
<div className="flex min-w-0 flex-1 items-center gap-1.5 px-3">
{activeTab ? getTabIcon(activeTab) : null}
<span className="truncate typography-ui-label text-foreground">
{activeTab ? getModeLabel(activeTab.mode, t) : null}
</span>
</div>
)}
<div className="flex items-center gap-1 px-1.5">
{isFileTabActive ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={toggleContextEditorTree}
className="h-7 w-7 p-0"
title={t('contextRail.editorTree.toggle')}
aria-label={t('contextRail.editorTree.toggle')}
aria-pressed={contextEditorTreeVisible}
>
<Icon name="layout-right" className="h-3.5 w-3.5" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleToggleExpanded}
className="h-7 w-7 p-0"
title={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
aria-label={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
>
{isExpanded ? <Icon name="fullscreen-exit" className="h-3.5 w-3.5" /> : <Icon name="fullscreen" className="h-3.5 w-3.5" />}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
className="h-7 w-7 p-0"
title={t('contextPanel.actions.closePanel')}
aria-label={t('contextPanel.actions.closePanel')}
>
<Icon name="close" className="h-3.5 w-3.5" />
</Button>
</div>
</header>
);
// width/min/max stay interpolable across open/close (no instant min/max
// jumps) so the 200ms width transition matches the sidebars.
const panelStyle: React.CSSProperties = !isOpen
? {
['--oc-context-panel-width' as string]: `${width}px`,
width: 0,
maxWidth: '100%',
overflowX: 'clip',
}
: isExpanded
? {
// px, not '100%': px↔% width changes do not interpolate, which
// would make the expand/collapse width snap instead of animating.
['--oc-context-panel-width' as string]: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%',
width: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%',
maxWidth: '100%',
}
: {
width: 'min(var(--oc-context-panel-width), 100%)',
maxWidth: '100%',
overflowX: 'clip',
['--oc-context-panel-width' as string]: `${width}px`,
};
return (
<aside
ref={panelRef}
data-context-panel="true"
tabIndex={-1}
inert={!isOpen || undefined}
className={cn(
'flex min-h-0 flex-col overflow-hidden bg-background',
// Right-anchored while expanded: `inset-0` would teleport the left
// edge instantly (position does not transition), so only the width
// animates and the panel grows leftwards from its docked position.
isExpanded
? 'absolute inset-y-0 right-0 z-20 min-w-0'
: 'relative h-full flex-shrink-0',
!isOpen && 'pointer-events-none',
'will-change-[width] motion-reduce:transition-none',
'transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]'
)}
onKeyDownCapture={handlePanelKeyDownCapture}
style={panelStyle}
>
{/* Painted divider instead of border-l: a real border eats 1px of the
content box only while collapsed, shifting the header controls by
1px between the collapsed and expanded states. */}
{isOpen && !isExpanded && (
<div aria-hidden="true" className="absolute left-0 top-0 z-40 h-full w-px bg-border/40" />
)}
{/* Divider between the panel and the icon rail on its right. */}
{isOpen && (
<div aria-hidden="true" className="absolute right-0 top-0 z-40 h-full w-px bg-border/40" />
)}
{!isExpanded && (
<div
className={cn(
'absolute left-0 top-0 z-50 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleResizeStart}
role="separator"
aria-orientation="vertical"
aria-label={t('contextPanel.actions.resizePanelAria')}
/>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 shrink-0 flex-col duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
// Width animates in sync with the panel (surface switches, resize
// release); during the drag itself nothing resizes — only the ghost
// guide line moves.
'transition-[width,opacity]',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
// px in the expanded state too: px↔% width changes cannot interpolate,
// so the header controls would snap instead of riding the animation.
style={{
width: isExpanded
? (availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%')
: 'var(--oc-context-panel-width)',
}}
aria-hidden={!isOpen}
>
{header}
<div className={cn('relative min-h-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}>
{hasFileTabs ? (
<div className={cn('absolute inset-0 flex', isFileTabActive ? 'flex' : 'hidden')}>
<div className="h-full min-w-0 flex-1">
{hasOpenEditorFile ? (
<FilesView mode="editor-only" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="file-code" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('contextPanel.editorEmpty.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.editorEmpty.description')}</div>
</div>
)}
</div>
<EditorTreeColumn visible={contextEditorTreeVisible} />
</div>
) : null}
{chatTabs.map((tab) => {
const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey);
if (!sessionID) {
return null;
}
const src = getEmbeddedChatSrc(tab.id, sessionID, tab.readOnly);
if (!src) {
return null;
}
return (
<iframe
key={tab.id}
ref={(node) => {
if (!node) {
chatFrameRefs.current.delete(tab.id);
return;
}
chatFrameRefs.current.set(tab.id, node);
}}
src={src}
title={t('contextPanel.iframe.sessionChatTitle', { sessionID })}
className={cn(
'absolute inset-0 h-full w-full border-0 bg-background',
activeChatTabID === tab.id ? 'block' : 'hidden'
)}
onLoad={() => {
postThemeSyncToEmbeddedChat();
postChatSettingsSyncToEmbeddedChat();
postEmbeddedVisibilityToChats();
}}
/>
);
})}
{browserTabs.map((tab) => (
<div
key={tab.id}
className={cn(
'absolute inset-0',
activeTab?.id !== tab.id && 'hidden'
)}
>
<BrowserPane initialUrl={tab.targetPath ?? ''} directory={directoryKey} tabID={tab.id} />
</div>
))}
{diffTabs.map((tab) => (
<div
key={tab.id}
className={cn(
'absolute inset-0',
activeTab?.id !== tab.id && 'hidden'
)}
>
<DiffView
hideStackedFileSidebar
stackedDefaultCollapsedAll
pinSelectedFileHeaderToTopOnNavigate
showOpenInEditorAction
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
onDiffScopeChange={handleDiffScopeChange}
targetFilePath={tab.targetPath}
flushContent
/>
</div>
))}
{hasTerminalTab ? (
<div className={cn('absolute inset-0', activeTab?.mode === 'terminal' ? 'block' : 'hidden')}>
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} />
</div>
) : null}
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' ? activeNonChatContent : null}
</div>
</div>
</aside>
);
};