2026-02-16 14:15:19 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
2026-02-16 14:15:19 +02:00
|
|
|
import { Button } from '@/components/ui/button';
|
2026-03-02 02:11:33 +02:00
|
|
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
2026-05-06 02:43:13 +03:00
|
|
|
import { DiffView } from '@/components/views/DiffView';
|
|
|
|
|
import { FilesView } from '@/components/views/FilesView';
|
|
|
|
|
import { PlanView } from '@/components/views/PlanView';
|
2026-03-02 02:11:33 +02:00
|
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
2026-04-29 17:03:38 -04:00
|
|
|
import { openExternalUrl } from '@/lib/url';
|
|
|
|
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
2026-02-16 14:15:19 +02:00
|
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-02-16 14:15:19 +02:00
|
|
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
2026-05-14 14:45:04 +03:00
|
|
|
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
|
2026-04-29 17:03:38 -04:00
|
|
|
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
|
|
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
|
|
|
import { useInputStore } from '@/sync/input-store';
|
2026-02-17 18:25:03 +02:00
|
|
|
import { ContextPanelContent } from './ContextSidebarTab';
|
2026-04-29 17:03:38 -04:00
|
|
|
import { toast } from '@/components/ui';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-05-14 14:45:04 +03:00
|
|
|
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
|
|
|
|
|
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
2026-02-16 14:15:19 +02:00
|
|
|
|
|
|
|
|
const CONTEXT_PANEL_MIN_WIDTH = 360;
|
|
|
|
|
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
|
|
|
|
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
2026-03-02 02:11:33 +02:00
|
|
|
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
|
2026-04-26 14:03:39 +03:00
|
|
|
type TranslateFn = ReturnType<typeof useI18n>['t'];
|
2026-02-16 14:15:19 +02:00
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
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;
|
2026-05-07 00:02:01 +03:00
|
|
|
navigation?: unknown;
|
2026-04-29 17:03:38 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PreviewElementMetadata = {
|
|
|
|
|
frame: 'top';
|
|
|
|
|
tag: string;
|
|
|
|
|
text: string;
|
|
|
|
|
selector: string;
|
|
|
|
|
path: string;
|
|
|
|
|
bounds: { x: number; y: number; width: number; height: number };
|
|
|
|
|
center: { x: number; y: number };
|
|
|
|
|
attributes: Record<string, string>;
|
|
|
|
|
computedStyle: Record<string, string>;
|
|
|
|
|
ancestry: Array<{ tag: string; id?: string; className?: string; selectorPart: string }>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 isPreviewElementMetadata = (value: unknown): value is PreviewElementMetadata => {
|
|
|
|
|
if (!value || typeof value !== 'object') return false;
|
|
|
|
|
const record = value as Partial<PreviewElementMetadata>;
|
|
|
|
|
const bounds = record.bounds;
|
|
|
|
|
return typeof record.tag === 'string'
|
|
|
|
|
&& typeof record.selector === 'string'
|
|
|
|
|
&& typeof record.path === 'string'
|
|
|
|
|
&& Boolean(bounds)
|
|
|
|
|
&& typeof bounds?.x === 'number'
|
|
|
|
|
&& typeof bounds?.y === 'number'
|
|
|
|
|
&& typeof bounds?.width === 'number'
|
|
|
|
|
&& typeof bounds?.height === 'number';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatPreviewAnnotationMarkdown = ({
|
|
|
|
|
pageUrl,
|
|
|
|
|
viewport,
|
|
|
|
|
devicePixelRatio,
|
|
|
|
|
target,
|
|
|
|
|
screenshotAttached,
|
|
|
|
|
intro,
|
|
|
|
|
}: {
|
|
|
|
|
pageUrl: string;
|
|
|
|
|
viewport: { width: number; height: number };
|
|
|
|
|
devicePixelRatio: number;
|
|
|
|
|
target: PreviewElementMetadata;
|
|
|
|
|
screenshotAttached: boolean;
|
|
|
|
|
intro: string;
|
|
|
|
|
}): string => {
|
|
|
|
|
const text = target.text.trim();
|
|
|
|
|
const attributes = Object.entries(target.attributes)
|
|
|
|
|
.map(([key, value]) => `${key}="${value}"`)
|
|
|
|
|
.join(' ');
|
|
|
|
|
const styles = target.computedStyle;
|
|
|
|
|
const bounds = target.bounds;
|
|
|
|
|
const center = target.center;
|
|
|
|
|
const introLabel = intro.replace(/[.:]+$/g, '');
|
|
|
|
|
const ancestry = target.ancestry
|
|
|
|
|
.map((entry) => entry.selectorPart)
|
|
|
|
|
.join(' > ');
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
`${introLabel}:`,
|
|
|
|
|
`Page: ${pageUrl || 'preview'}`,
|
|
|
|
|
`Viewport: ${viewport.width}x${viewport.height}, DPR ${devicePixelRatio}`,
|
|
|
|
|
`Screenshot: ${screenshotAttached ? 'attached' : 'not attached'}`,
|
|
|
|
|
`Element: ${target.tag}`,
|
|
|
|
|
text ? `Text: ${text}` : null,
|
|
|
|
|
`- Selector: ${target.selector}`,
|
|
|
|
|
`- Path: ${target.path}`,
|
|
|
|
|
ancestry ? `- Ancestry: ${ancestry}` : null,
|
|
|
|
|
attributes ? `- Attributes: ${attributes}` : null,
|
|
|
|
|
`- Bounds: x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`,
|
|
|
|
|
`- Center: x=${Math.round(center.x)}, y=${Math.round(center.y)}`,
|
|
|
|
|
`Styles: display=${styles.display}; position=${styles.position}; font=${styles.fontWeight} ${styles.fontSize} / ${styles.lineHeight} ${styles.fontFamily}; color=${styles.color}; background=${styles.backgroundColor}; z-index=${styles.zIndex}`,
|
|
|
|
|
].filter((line): line is string => typeof line === 'string').join('\n');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderPreviewScreenshot = async (
|
|
|
|
|
iframe: HTMLIFrameElement,
|
|
|
|
|
target: PreviewElementMetadata,
|
|
|
|
|
): Promise<File | null> => {
|
|
|
|
|
const tauri = typeof window !== 'undefined'
|
|
|
|
|
? (window as unknown as { __TAURI__?: { core?: { invoke?: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> } } }).__TAURI__
|
|
|
|
|
: undefined;
|
|
|
|
|
if (typeof tauri?.core?.invoke === 'function') {
|
|
|
|
|
try {
|
|
|
|
|
const rect = iframe.getBoundingClientRect();
|
|
|
|
|
const capture = await tauri.core.invoke<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', {
|
|
|
|
|
x: rect.left,
|
|
|
|
|
y: rect.top,
|
|
|
|
|
width: rect.width,
|
|
|
|
|
height: rect.height,
|
|
|
|
|
});
|
|
|
|
|
const image = new Image();
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
image.onload = () => resolve();
|
|
|
|
|
image.onerror = () => reject(new Error('Failed to load desktop preview screenshot'));
|
|
|
|
|
image.src = `data:${capture.mime};base64,${capture.base64}`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const width = Math.max(1, image.naturalWidth || capture.width || Math.floor(rect.width));
|
|
|
|
|
const height = Math.max(1, image.naturalHeight || capture.height || Math.floor(rect.height));
|
|
|
|
|
const maxOutputWidth = 1200;
|
|
|
|
|
const outputScale = Math.min(1, maxOutputWidth / width);
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = Math.floor(width * outputScale);
|
|
|
|
|
canvas.height = Math.floor(height * outputScale);
|
|
|
|
|
const context = canvas.getContext('2d');
|
|
|
|
|
if (!context) return null;
|
|
|
|
|
|
|
|
|
|
context.scale(outputScale, outputScale);
|
|
|
|
|
context.drawImage(image, 0, 0, width, height);
|
|
|
|
|
const xScale = width / Math.max(1, rect.width);
|
|
|
|
|
const yScale = height / Math.max(1, rect.height);
|
|
|
|
|
context.fillStyle = 'rgba(37, 99, 235, 0.28)';
|
|
|
|
|
context.strokeStyle = 'rgb(37, 99, 235)';
|
|
|
|
|
context.lineWidth = Math.max(2, 2 * xScale);
|
|
|
|
|
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
|
|
|
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
|
|
|
|
|
|
|
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
|
|
|
|
|
if (!blob) return null;
|
|
|
|
|
return new File([blob], `preview-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('[preview] failed to capture annotation screenshot:', error);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
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 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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
const getModeLabel = (
|
2026-05-14 14:45:04 +03:00
|
|
|
mode: ContextPanelMode,
|
2026-04-26 14:03:39 +03:00
|
|
|
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');
|
2026-04-29 17:03:38 -04:00
|
|
|
if (mode === 'preview') return t('contextPanel.mode.preview');
|
2026-05-14 14:45:04 +03:00
|
|
|
if (mode === 'browser') return t('contextPanel.mode.browser');
|
2026-04-26 14:03:39 +03:00
|
|
|
return t('contextPanel.mode.context');
|
2026-03-02 02:11:33 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
const getTabLabel = (
|
2026-05-23 23:49:38 +02:00
|
|
|
tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; stagedDiff?: boolean },
|
2026-04-26 14:03:39 +03:00
|
|
|
t: TranslateFn
|
|
|
|
|
): string => {
|
2026-03-02 02:11:33 +02:00
|
|
|
if (tab.label) {
|
|
|
|
|
return tab.label;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tab.mode === 'file') {
|
2026-04-26 14:03:39 +03:00
|
|
|
return getFileNameFromPath(tab.targetPath) || t('contextPanel.mode.files');
|
2026-03-02 02:11:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 23:49:38 +02:00
|
|
|
if (tab.mode === 'diff') {
|
|
|
|
|
return tab.stagedDiff ? t('contextPanel.mode.stagedDiff') : t('contextPanel.mode.workingDiff');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
return getModeLabel(tab.mode, t);
|
2026-03-02 02:11:33 +02:00
|
|
|
};
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }): React.ReactNode | undefined => {
|
2026-03-02 02:11:33 +02:00
|
|
|
if (tab.mode === 'file') {
|
|
|
|
|
return tab.targetPath
|
|
|
|
|
? <FileTypeIcon filePath={tab.targetPath} className="h-3.5 w-3.5" />
|
|
|
|
|
: undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tab.mode === 'diff') {
|
2026-05-13 13:26:15 +03:00
|
|
|
return <Icon name="arrow-left-right" className="h-3.5 w-3.5" />;
|
2026-03-02 02:11:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tab.mode === 'plan') {
|
2026-05-13 13:26:15 +03:00
|
|
|
return <Icon name="file-text" className="h-3.5 w-3.5" />;
|
2026-03-02 02:11:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tab.mode === 'context') {
|
2026-05-13 13:26:15 +03:00
|
|
|
return <Icon name="donut-chart-fill" className="h-3.5 w-3.5" />;
|
2026-03-02 02:11:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tab.mode === 'chat') {
|
2026-05-13 13:26:15 +03:00
|
|
|
return <Icon name="chat-4" className="h-3.5 w-3.5" />;
|
2026-03-02 02:11:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
if (tab.mode === 'preview') {
|
2026-05-13 13:26:15 +03:00
|
|
|
return <Icon name="global" className="h-3.5 w-3.5 text-[var(--status-info)]" />;
|
2026-04-29 17:03:38 -04:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
if (tab.mode === 'browser') {
|
|
|
|
|
return <Icon name="global" className="h-3.5 w-3.5" />;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
return undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => {
|
|
|
|
|
if (!dedupeKey || !dedupeKey.startsWith('session:')) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sessionID = dedupeKey.slice('session:'.length).trim();
|
|
|
|
|
return sessionID || null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
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 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 desktopAnnotationToFile = async (
|
|
|
|
|
base64: string,
|
|
|
|
|
screenshotWidth: number,
|
|
|
|
|
screenshotHeight: number,
|
|
|
|
|
cssWidth: number,
|
|
|
|
|
cssHeight: number,
|
|
|
|
|
target: PreviewElementMetadata,
|
|
|
|
|
): Promise<File | null> => {
|
|
|
|
|
if (!base64) return null;
|
|
|
|
|
try {
|
|
|
|
|
const image = new Image();
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
image.onload = () => resolve();
|
|
|
|
|
image.onerror = () => reject(new Error('Failed to load desktop browser screenshot'));
|
|
|
|
|
image.src = `data:image/jpeg;base64,${base64}`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const width = Math.max(1, image.naturalWidth || screenshotWidth);
|
|
|
|
|
const height = Math.max(1, image.naturalHeight || screenshotHeight);
|
|
|
|
|
const maxOutputWidth = 1200;
|
|
|
|
|
const outputScale = Math.min(1, maxOutputWidth / width);
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = Math.floor(width * outputScale);
|
|
|
|
|
canvas.height = Math.floor(height * outputScale);
|
|
|
|
|
const context = canvas.getContext('2d');
|
|
|
|
|
if (!context) return null;
|
|
|
|
|
|
|
|
|
|
context.scale(outputScale, outputScale);
|
|
|
|
|
context.drawImage(image, 0, 0, width, height);
|
|
|
|
|
const xScale = width / Math.max(1, cssWidth || width);
|
|
|
|
|
const yScale = height / Math.max(1, cssHeight || height);
|
|
|
|
|
context.fillStyle = 'rgba(37, 99, 235, 0.14)';
|
|
|
|
|
context.strokeStyle = 'rgb(37, 99, 235)';
|
|
|
|
|
context.lineWidth = Math.max(2, 2 * xScale);
|
|
|
|
|
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
|
|
|
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
|
|
|
|
|
|
|
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
|
|
|
|
|
if (!blob) return null;
|
|
|
|
|
return new File([blob], `browser-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 22:16:04 +03:00
|
|
|
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null, readOnly: boolean): string => {
|
2026-03-02 02:11:33 +02:00
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = new URL(window.location.pathname, window.location.origin);
|
|
|
|
|
url.searchParams.set('ocPanel', 'session-chat');
|
|
|
|
|
url.searchParams.set('sessionId', sessionID);
|
2026-05-15 22:16:04 +03:00
|
|
|
if (readOnly) {
|
|
|
|
|
url.searchParams.set('readOnly', '1');
|
|
|
|
|
} else {
|
|
|
|
|
url.searchParams.delete('readOnly');
|
|
|
|
|
}
|
2026-03-02 02:11:33 +02:00
|
|
|
if (directory && directory.trim().length > 0) {
|
|
|
|
|
url.searchParams.set('directory', directory);
|
|
|
|
|
} else {
|
|
|
|
|
url.searchParams.delete('directory');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url.hash = '';
|
|
|
|
|
return url.toString();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const truncateTabLabel = (value: string, maxChars: number): string => {
|
|
|
|
|
if (value.length <= maxChars) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${value.slice(0, maxChars - 3)}...`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
type PreviewPaneProps = {
|
|
|
|
|
rawUrl: string;
|
|
|
|
|
onNavigate: (url: string) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PreviewProxyState =
|
|
|
|
|
| { status: 'idle' }
|
|
|
|
|
| { status: 'loading' }
|
|
|
|
|
| { status: 'ready'; proxyBasePath: string; expiresAt: number }
|
|
|
|
|
| { status: 'error'; message: string };
|
|
|
|
|
|
|
|
|
|
// Module-scoped, in-memory cache of registered proxy targets keyed by the
|
|
|
|
|
// fully-qualified upstream URL. Survives PreviewPane unmount/remount and tab
|
|
|
|
|
// switches, but intentionally does NOT survive a full page reload: the server
|
|
|
|
|
// holds the target map in memory and the auth cookie is HttpOnly + scoped to
|
|
|
|
|
// the proxy id, so a stale persisted entry would 404 after a server restart.
|
|
|
|
|
// Entries are evicted on registration error (refetched) or when the upstream
|
|
|
|
|
// returns 403 (cookie expired) / 404 (target unknown) at iframe load time.
|
|
|
|
|
type CachedProxyTarget = { proxyBasePath: string; expiresAt: number };
|
|
|
|
|
const previewProxyTargetCache = new Map<string, CachedProxyTarget>();
|
|
|
|
|
const PREVIEW_PROXY_CACHE_SAFETY_MS = 30_000;
|
|
|
|
|
|
|
|
|
|
const getCachedProxyTarget = (url: string): CachedProxyTarget | null => {
|
|
|
|
|
const entry = previewProxyTargetCache.get(url);
|
|
|
|
|
if (!entry) return null;
|
|
|
|
|
if (entry.expiresAt - Date.now() <= PREVIEW_PROXY_CACHE_SAFETY_MS) {
|
|
|
|
|
previewProxyTargetCache.delete(url);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return entry;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
|
|
|
|
const { t } = useI18n();
|
2026-05-07 00:02:01 +03:00
|
|
|
const { currentTheme } = useThemeSystem();
|
2026-04-29 17:03:38 -04:00
|
|
|
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 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 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() : '';
|
2026-05-07 00:02:01 +03:00
|
|
|
const previewColorScheme = currentTheme.metadata.variant;
|
2026-04-29 17:03:38 -04:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!targetKey || !isLoopback) {
|
|
|
|
|
setProxyState({ status: 'idle' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cached = getCachedProxyTarget(targetKey);
|
|
|
|
|
if (cached) {
|
|
|
|
|
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setProxyState({ status: 'loading' });
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/preview/targets', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
body: JSON.stringify({ url: targetKey }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
previewProxyTargetCache.delete(targetKey);
|
|
|
|
|
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; expiresAt?: unknown };
|
|
|
|
|
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
|
|
|
|
|
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
|
|
|
|
|
if (!proxyBasePath) {
|
|
|
|
|
previewProxyTargetCache.delete(targetKey);
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
previewProxyTargetCache.set(targetKey, { proxyBasePath, expiresAt });
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setProxyState({ status: 'ready', proxyBasePath, expiresAt });
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
previewProxyTargetCache.delete(targetKey);
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
setProxyState({ status: 'error', message });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [isLoopback, proxyRegistrationNonce, t, targetKey]);
|
|
|
|
|
|
|
|
|
|
const directSrc = normalizedUrl
|
|
|
|
|
&& (normalizedUrl.protocol === 'http:' || normalizedUrl.protocol === 'https:')
|
|
|
|
|
? normalizedUrl.toString()
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl
|
|
|
|
|
? (() => {
|
|
|
|
|
const path = normalizedUrl.pathname || '/';
|
|
|
|
|
const searchParams = new URLSearchParams(normalizedUrl.search);
|
|
|
|
|
searchParams.set('ocPreview', String(reloadNonce));
|
|
|
|
|
const search = searchParams.toString();
|
|
|
|
|
const hash = normalizedUrl.hash || '';
|
|
|
|
|
return `${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`;
|
|
|
|
|
})()
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
const effectiveSrc = isLoopback ? proxySrc : directSrc;
|
|
|
|
|
const headerSrc = effectiveSrc || directSrc;
|
|
|
|
|
const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle');
|
|
|
|
|
const showError = isLoopback && proxyState.status === 'error';
|
|
|
|
|
|
|
|
|
|
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
|
|
|
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
|
|
|
|
if (!sessionKey) {
|
|
|
|
|
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({
|
|
|
|
|
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, 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;
|
|
|
|
|
}
|
|
|
|
|
frameWindow.postMessage({
|
|
|
|
|
source: 'openchamber-preview-parent',
|
|
|
|
|
version: 1,
|
|
|
|
|
type: 'set-inspect-mode',
|
|
|
|
|
enabled: inspectMode,
|
|
|
|
|
}, window.location.origin);
|
|
|
|
|
}, [bridgeReady, inspectMode]);
|
|
|
|
|
|
2026-05-07 00:02:01 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
const frameWindow = iframeRef.current?.contentWindow;
|
|
|
|
|
if (!bridgeReady || !frameWindow) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
frameWindow.postMessage({
|
|
|
|
|
source: 'openchamber-preview-parent',
|
|
|
|
|
version: 1,
|
|
|
|
|
type: 'set-color-scheme',
|
|
|
|
|
scheme: previewColorScheme,
|
|
|
|
|
}, window.location.origin);
|
|
|
|
|
}, [bridgeReady, previewColorScheme]);
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
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 : '';
|
2026-05-07 00:02:01 +03:00
|
|
|
const navigation = data.navigation === 'external' ? 'external' : 'proxy';
|
|
|
|
|
if (nextUrl && navigation === 'external') {
|
|
|
|
|
void openExternalUrl(nextUrl);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 17:03:38 -04:00
|
|
|
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) {
|
|
|
|
|
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({
|
|
|
|
|
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, 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 HEAD
|
|
|
|
|
// 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 PREVIEW_STARTUP_GRACE_MS = 15_000;
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!proxySrc) {
|
|
|
|
|
setUpstreamState('unknown');
|
|
|
|
|
upstreamProbeStartedAtRef.current = 0;
|
|
|
|
|
upstreamProbeAttemptRef.current = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
if (!upstreamProbeStartedAtRef.current) {
|
|
|
|
|
upstreamProbeStartedAtRef.current = Date.now();
|
|
|
|
|
upstreamProbeAttemptRef.current = 0;
|
|
|
|
|
}
|
|
|
|
|
setUpstreamState('unknown');
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
const probe = async (): Promise<Response | null> => {
|
|
|
|
|
try {
|
|
|
|
|
return await fetch(proxySrc, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
cache: 'no-store',
|
|
|
|
|
redirect: 'manual',
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await probe();
|
|
|
|
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
|
|
|
|
if (!response) {
|
|
|
|
|
// Network-level failure (e.g. server itself is down) — treat as unreachable.
|
|
|
|
|
setUpstreamState('unreachable');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.status === 403 || response.status === 404) {
|
|
|
|
|
previewProxyTargetCache.delete(targetKey);
|
|
|
|
|
setProxyState({ status: 'loading' });
|
|
|
|
|
bumpProxyRegistration();
|
|
|
|
|
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) {
|
|
|
|
|
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)));
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
bumpReload();
|
|
|
|
|
}
|
|
|
|
|
}, delay).unref?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setUpstreamState('unreachable');
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [proxySrc, reloadNonce, targetKey]);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
if (location.origin !== window.location.origin) {
|
|
|
|
|
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, 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}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="refresh" className="h-3.5 w-3.5" />
|
2026-04-29 17:03:38 -04:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="external-link" className="h-3.5 w-3.5" />
|
2026-04-29 17:03:38 -04:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="cursor" className="h-3.5 w-3.5" />
|
2026-04-29 17:03:38 -04:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="terminal-box" className="h-3.5 w-3.5" />
|
2026-04-29 17:03:38 -04:00
|
|
|
{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"
|
2026-05-07 00:02:01 +03:00
|
|
|
style={{ colorScheme: previewColorScheme }}
|
2026-04-29 17:03:38 -04:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
type DesktopBrowserPaneProps = {
|
|
|
|
|
initialUrl: string;
|
|
|
|
|
directory: string;
|
|
|
|
|
tabID: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 [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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Check current loading state imperatively — we may have missed the event
|
|
|
|
|
try {
|
|
|
|
|
if (!webview.isLoading()) {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
syncUrl();
|
|
|
|
|
}
|
|
|
|
|
} catch { /* webview not ready */ }
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
}, [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({
|
|
|
|
|
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, 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={normalizeBrowserUrl(initialUrl)}
|
|
|
|
|
partition="persist:openchamber-browser"
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
export const ContextPanel: React.FC = () => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-02-16 14:15:19 +02:00
|
|
|
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);
|
2026-03-02 02:11:33 +02:00
|
|
|
const closeContextPanelTab = useUIStore((state) => state.closeContextPanelTab);
|
2026-02-16 14:15:19 +02:00
|
|
|
const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
|
|
|
|
|
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
|
2026-03-02 02:11:33 +02:00
|
|
|
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
|
|
|
|
|
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
|
|
|
|
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
2026-04-29 17:03:38 -04:00
|
|
|
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
2026-03-02 02:11:33 +02:00
|
|
|
const { themeMode, 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);
|
2026-02-16 14:15:19 +02:00
|
|
|
const isExpanded = Boolean(isOpen && panelState?.expanded);
|
|
|
|
|
const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH);
|
|
|
|
|
|
|
|
|
|
const [isResizing, setIsResizing] = React.useState(false);
|
|
|
|
|
const startXRef = React.useRef(0);
|
|
|
|
|
const startWidthRef = React.useRef(width);
|
2026-03-02 02:11:33 +02:00
|
|
|
const resizingWidthRef = React.useRef<number | null>(null);
|
|
|
|
|
const activeResizePointerIDRef = React.useRef<number | null>(null);
|
2026-02-16 14:15:19 +02:00
|
|
|
const panelRef = React.useRef<HTMLElement | null>(null);
|
2026-03-02 02:11:33 +02:00
|
|
|
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
|
2026-02-16 14:15:19 +02:00
|
|
|
const wasOpenRef = React.useRef(false);
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
|
|
|
|
const panel = panelRef.current;
|
|
|
|
|
if (!panel) {
|
2026-02-16 14:15:19 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
panel.style.setProperty('--oc-context-panel-width', `${nextWidth}px`);
|
|
|
|
|
}, []);
|
2026-02-16 14:15:19 +02:00
|
|
|
|
|
|
|
|
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
|
|
|
|
|
if (!isOpen || isExpanded || !directoryKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
try {
|
|
|
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore; fallback listeners still handle drag
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activeResizePointerIDRef.current = event.pointerId;
|
2026-02-16 14:15:19 +02:00
|
|
|
setIsResizing(true);
|
|
|
|
|
startXRef.current = event.clientX;
|
|
|
|
|
startWidthRef.current = width;
|
2026-03-02 02:11:33 +02:00
|
|
|
resizingWidthRef.current = width;
|
|
|
|
|
applyLiveWidth(width);
|
2026-02-16 14:15:19 +02:00
|
|
|
event.preventDefault();
|
2026-03-02 02:11:33 +02:00
|
|
|
}, [applyLiveWidth, directoryKey, isExpanded, isOpen, width]);
|
|
|
|
|
|
|
|
|
|
const handleResizeMove = React.useCallback((event: React.PointerEvent) => {
|
|
|
|
|
if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const delta = startXRef.current - event.clientX;
|
|
|
|
|
const nextWidth = clampWidth(startWidthRef.current + delta);
|
|
|
|
|
if (resizingWidthRef.current === nextWidth) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resizingWidthRef.current = nextWidth;
|
|
|
|
|
applyLiveWidth(nextWidth);
|
|
|
|
|
}, [applyLiveWidth, isResizing]);
|
|
|
|
|
|
|
|
|
|
const handleResizeEnd = React.useCallback((event: React.PointerEvent) => {
|
|
|
|
|
if (activeResizePointerIDRef.current !== event.pointerId || !directoryKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const finalWidth = resizingWidthRef.current ?? width;
|
|
|
|
|
setIsResizing(false);
|
|
|
|
|
activeResizePointerIDRef.current = null;
|
|
|
|
|
resizingWidthRef.current = null;
|
|
|
|
|
setContextPanelWidth(directoryKey, finalWidth);
|
|
|
|
|
}, [directoryKey, setContextPanelWidth, width]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isResizing) {
|
|
|
|
|
resizingWidthRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, [isResizing]);
|
2026-02-16 14:15:19 +02:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!directoryKey || !activeTab) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (activeTab.mode === 'file' && activeTab.targetPath) {
|
|
|
|
|
setSelectedFilePath(directoryKey, activeTab.targetPath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 23:49:38 +02:00
|
|
|
}, [activeTab, directoryKey, setSelectedFilePath]);
|
2026-02-16 14:15:19 +02:00
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
|
|
|
|
|
|
|
|
|
|
const postThemeSyncToEmbeddedChat = React.useCallback(() => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-16 14:15:19 +02:00
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
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 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.useLayoutEffect(() => {
|
|
|
|
|
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
|
|
|
|
|
if (!hasAnyChatTab) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
postThemeSyncToEmbeddedChat();
|
|
|
|
|
postEmbeddedVisibilityToChats();
|
|
|
|
|
}, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]);
|
|
|
|
|
|
|
|
|
|
const tabItems = React.useMemo(() => tabs.map((tab) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const rawLabel = getTabLabel(tab, t);
|
2026-03-02 02:11:33 +02:00
|
|
|
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,
|
2026-04-26 14:03:39 +03:00
|
|
|
closeLabel: t('contextPanel.tab.closeTabAria', { label }),
|
2026-03-02 02:11:33 +02:00
|
|
|
};
|
2026-04-26 14:03:39 +03:00
|
|
|
}), [effectiveDirectory, t, tabs]);
|
2026-03-02 02:11:33 +02:00
|
|
|
|
|
|
|
|
const activeNonChatContent = activeTab?.mode === 'diff'
|
2026-05-23 23:49:38 +02:00
|
|
|
? (
|
|
|
|
|
<DiffView
|
|
|
|
|
key={activeTab.id}
|
|
|
|
|
hideStackedFileSidebar
|
|
|
|
|
stackedDefaultCollapsedAll
|
|
|
|
|
hideFileSelector
|
|
|
|
|
pinSelectedFileHeaderToTopOnNavigate
|
|
|
|
|
showOpenInEditorAction
|
|
|
|
|
diffScope={activeTab.stagedDiff ? 'staged' : 'working'}
|
|
|
|
|
targetFilePath={activeTab.targetPath}
|
|
|
|
|
/>
|
|
|
|
|
)
|
2026-03-02 02:11:33 +02:00
|
|
|
: activeTab?.mode === 'context'
|
2026-02-17 18:25:03 +02:00
|
|
|
? <ContextPanelContent />
|
2026-03-02 02:11:33 +02:00
|
|
|
: activeTab?.mode === 'plan'
|
2026-04-29 17:03:38 -04:00
|
|
|
? <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">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="global" className="h-12 w-12 text-muted-foreground/50" />
|
2026-04-29 17:03:38 -04:00
|
|
|
<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>
|
|
|
|
|
);
|
2026-02-16 14:15:19 +02:00
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const chatTabs = React.useMemo(
|
|
|
|
|
() => tabs.filter((tab) => tab.mode === 'chat'),
|
|
|
|
|
[tabs],
|
|
|
|
|
);
|
2026-05-14 14:45:04 +03:00
|
|
|
const browserTabs = React.useMemo(
|
|
|
|
|
() => tabs.filter((tab) => tab.mode === 'browser'),
|
|
|
|
|
[tabs],
|
|
|
|
|
);
|
2026-03-02 02:11:33 +02:00
|
|
|
const hasFileTabs = React.useMemo(
|
|
|
|
|
() => tabs.some((tab) => tab.mode === 'file'),
|
|
|
|
|
[tabs],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const isFileTabActive = activeTab?.mode === 'file';
|
|
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
const header = (
|
2026-05-20 19:30:10 +03:00
|
|
|
<header className="flex h-10 items-stretch border-b border-transparent">
|
2026-03-02 02:11:33 +02:00
|
|
|
<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"
|
2026-04-19 15:52:54 +03:00
|
|
|
variant="default"
|
2026-03-02 02:11:33 +02:00
|
|
|
/>
|
2026-04-19 15:52:54 +03:00
|
|
|
<div className="flex items-center gap-1 px-1.5">
|
2026-03-02 02:11:33 +02:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleToggleExpanded}
|
|
|
|
|
className="h-7 w-7 p-0"
|
2026-04-26 14:03:39 +03:00
|
|
|
title={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
|
|
|
|
|
aria-label={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
|
2026-03-02 02:11:33 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
{isExpanded ? <Icon name="fullscreen-exit" className="h-3.5 w-3.5" /> : <Icon name="fullscreen" className="h-3.5 w-3.5" />}
|
2026-03-02 02:11:33 +02:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
className="h-7 w-7 p-0"
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('contextPanel.actions.closePanel')}
|
|
|
|
|
aria-label={t('contextPanel.actions.closePanel')}
|
2026-03-02 02:11:33 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
2026-03-02 02:11:33 +02:00
|
|
|
</Button>
|
2026-02-16 14:15:19 +02:00
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 11:52:01 -03:00
|
|
|
const panelStyle: React.CSSProperties = isExpanded
|
|
|
|
|
? {
|
2026-03-02 02:11:33 +02:00
|
|
|
['--oc-context-panel-width' as string]: '100%',
|
|
|
|
|
width: '100%',
|
|
|
|
|
minWidth: '100%',
|
|
|
|
|
maxWidth: '100%',
|
2026-02-20 11:52:01 -03:00
|
|
|
}
|
|
|
|
|
: {
|
2026-03-02 02:11:33 +02:00
|
|
|
width: 'var(--oc-context-panel-width)',
|
|
|
|
|
minWidth: 'var(--oc-context-panel-width)',
|
|
|
|
|
maxWidth: 'var(--oc-context-panel-width)',
|
|
|
|
|
['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`,
|
2026-02-20 11:52:01 -03:00
|
|
|
};
|
|
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
return (
|
|
|
|
|
<aside
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
data-context-panel="true"
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
className={cn(
|
2026-02-25 14:32:23 +02:00
|
|
|
'flex min-h-0 flex-col overflow-hidden bg-background',
|
|
|
|
|
!isExpanded && 'border-l border-border/40',
|
2026-02-16 14:15:19 +02:00
|
|
|
isExpanded
|
|
|
|
|
? 'absolute inset-0 z-20 min-w-0'
|
|
|
|
|
: 'relative h-full flex-shrink-0',
|
|
|
|
|
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
|
|
|
|
|
)}
|
|
|
|
|
onKeyDownCapture={handlePanelKeyDownCapture}
|
2026-02-20 11:52:01 -03:00
|
|
|
style={panelStyle}
|
2026-02-16 14:15:19 +02:00
|
|
|
>
|
|
|
|
|
{!isExpanded && (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-03-20 01:01:03 +02:00
|
|
|
'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)]'
|
2026-02-16 14:15:19 +02:00
|
|
|
)}
|
|
|
|
|
onPointerDown={handleResizeStart}
|
2026-03-02 02:11:33 +02:00
|
|
|
onPointerMove={handleResizeMove}
|
|
|
|
|
onPointerUp={handleResizeEnd}
|
|
|
|
|
onPointerCancel={handleResizeEnd}
|
2026-02-16 14:15:19 +02:00
|
|
|
role="separator"
|
|
|
|
|
aria-orientation="vertical"
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('contextPanel.actions.resizePanelAria')}
|
2026-02-16 14:15:19 +02:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{header}
|
2026-03-02 02:11:33 +02:00
|
|
|
<div className={cn('relative min-h-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}>
|
|
|
|
|
{hasFileTabs ? (
|
|
|
|
|
<div className={cn('absolute inset-0', isFileTabActive ? 'block' : 'hidden')}>
|
|
|
|
|
<FilesView mode="editor-only" />
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{chatTabs.map((tab) => {
|
|
|
|
|
const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey);
|
|
|
|
|
if (!sessionID) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 22:16:04 +03:00
|
|
|
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null, tab.readOnly);
|
2026-03-02 02:11:33 +02:00
|
|
|
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}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('contextPanel.iframe.sessionChatTitle', { sessionID })}
|
2026-03-02 02:11:33 +02:00
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-0 h-full w-full border-0 bg-background',
|
|
|
|
|
activeChatTabID === tab.id ? 'block' : 'hidden'
|
|
|
|
|
)}
|
|
|
|
|
onLoad={() => {
|
|
|
|
|
postThemeSyncToEmbeddedChat();
|
|
|
|
|
postEmbeddedVisibilityToChats();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-05-14 14:45:04 +03:00
|
|
|
{browserTabs.map((tab) => (
|
|
|
|
|
<div
|
|
|
|
|
key={tab.id}
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-0',
|
|
|
|
|
activeTab?.mode !== 'browser' && 'hidden'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<DesktopBrowserPane initialUrl={tab.targetPath ?? ''} directory={directoryKey} tabID={tab.id} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' ? activeNonChatContent : null}
|
2026-03-02 02:11:33 +02:00
|
|
|
</div>
|
2026-02-16 14:15:19 +02:00
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
};
|