The terminal ran on the ghostty-web npm package plus a hand-written patch, and every rendering bug (recycled rows, duplicated reflow fragments, prompt artifacts) had to be worked around from outside. The emulator now is the official libghostty-vt C ABI compiled to WebAssembly, driven by a browser adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in packages/ui/src/lib/ghostty. The artifact is reproducible with scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on macOS 27 SDKs. On top of the port: one WASM instance per page with every tab kept mounted and hidden tabs paused; history replayed at the PTY size it was drawn for; shells spawned only after the first fitted grid so zsh never prints the PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn procedurally to the exact cell so TUI borders and block logos have no gaps between rows; a software-rasterized canvas so Gecko renders every tab's text with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN fetch; touch selection and scrolling driven through the surface API; a copy button in the tab strip for touch hosts; localized aria labels. Testing: bun tests run the real WASM (reflow, palette, replay isolation, recycled rows, box glyph geometry); viewport and view tests use a surface double; verified in Chromium and Zen (windowed and headless) for crisp text, new tabs, panel reopen, resize and box glyph rendering; package type-check, oxlint/eslint on new files, web build.
1304 lines
49 KiB
TypeScript
1304 lines
49 KiB
TypeScript
import React from 'react';
|
|
|
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
|
import { PullRequestView } from '@/components/views/PullRequestView';
|
|
import { TerminalView } from '@/components/views/TerminalView';
|
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
|
|
|
// Heavy views stay on-demand (same as MainLayout): importing DiffView/FilesView
|
|
// or the walkthrough statically pulls the CodeMirror and @pierre/diffs stacks
|
|
// into the eager startup graph even when no such tab is open.
|
|
const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/walkthrough/WalkthroughView').then((m) => ({ default: m.WalkthroughView })));
|
|
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView })));
|
|
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView })));
|
|
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView })));
|
|
// The Linear rail icon stays hidden until a workspace is connected, so most
|
|
// users never render this panel; keep it out of the main bundle.
|
|
const LinearIssuesView = lazyWithChunkRecovery(() => import('@/components/views/LinearIssuesView').then((m) => ({ default: m.LinearIssuesView })));
|
|
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView })));
|
|
import { ProjectContextPanel } from './RightSidebarTabs';
|
|
import { SidebarFilesTree } from './SidebarFilesTree';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore';
|
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
|
import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore';
|
|
import { markSessionViewed } from '@/sync/notification-store';
|
|
import { setExternallyViewedSession, useDirectoryStore } from '@/sync/sync-context';
|
|
import { ContextPanelContent } from './ContextSidebarTab';
|
|
import { BrowserPane } from '@/components/browser/BrowserPane';
|
|
import { browserUrlLabel } from '@/lib/browser/url';
|
|
import { registerBrowserOpener } from '@/lib/browser/controlClient';
|
|
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
|
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
|
|
import { getActiveRelayDescriptor } from '@/lib/relay/runtime-tunnel';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import {
|
|
EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST,
|
|
EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
|
|
EMBEDDED_VISIBILITY_REQUEST,
|
|
EMBEDDED_VISIBILITY_UPDATE,
|
|
getActiveEmbeddedSessionChatTab,
|
|
getOrCreateEmbeddedSessionChatURL,
|
|
type EmbeddedSessionChatURLCacheEntry,
|
|
type EmbeddedSessionRuntimeBootstrap,
|
|
} from './contextPanelEmbeddedChat';
|
|
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
|
|
import { isTerminalEventTarget } from '@/lib/terminalFocus';
|
|
|
|
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>();
|
|
|
|
|
|
|
|
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 === 'walkthrough') return t('contextPanel.mode.walkthrough');
|
|
if (mode === 'plan') return t('contextPanel.mode.plan');
|
|
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 === 'linear') return t('contextPanel.mode.linear');
|
|
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');
|
|
}
|
|
|
|
// Ahead of the stored label on purpose: a browser tab is named after the page
|
|
// it is showing, and the stored label is only ever the address it opened at.
|
|
// Keeping that would leave the tab claiming one host while the address bar
|
|
// shows another.
|
|
if (tab.mode === 'browser') {
|
|
return browserUrlLabel(tab.targetPath ?? '') || tab.label || t('contextPanel.mode.browser');
|
|
}
|
|
|
|
if (tab.label) {
|
|
return tab.label;
|
|
}
|
|
|
|
if (tab.mode === 'file') {
|
|
return getFileNameFromPath(tab.targetPath) || t('contextPanel.mode.files');
|
|
}
|
|
|
|
if (tab.mode === 'diff') {
|
|
return t('contextPanel.mode.diff');
|
|
}
|
|
|
|
return getModeLabel(tab.mode, t);
|
|
};
|
|
|
|
const getTabIcon = (
|
|
tab: { mode: ContextPanelMode; targetPath: string | null },
|
|
faviconByOrigin: Record<string, string> = {},
|
|
): 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 <DiffViewIcon className="h-3.5 w-3.5" />;
|
|
}
|
|
|
|
if (tab.mode === 'walkthrough') {
|
|
return <Icon name="route" 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="github" className="h-3.5 w-3.5" />;
|
|
}
|
|
|
|
if (tab.mode === 'linear') {
|
|
return <Icon name="linear" 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 === 'browser') {
|
|
const icon = browserFaviconFor(tab.targetPath ?? '', faviconByOrigin);
|
|
// The page's own icon when it has reported one; the placeholder otherwise,
|
|
// including in runtimes where a page never can.
|
|
return icon
|
|
? <img src={icon} alt="" aria-hidden="true" className="h-3.5 w-3.5 rounded-[3px] object-contain" />
|
|
: <Icon name="global" className="h-3.5 w-3.5" />;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const browserFaviconFor = (url: string, faviconByOrigin: Record<string, string>): string => {
|
|
try {
|
|
return faviconByOrigin[new URL(url).origin] ?? '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
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 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 truncateTabLabel = (value: string, maxChars: number): string => {
|
|
if (value.length <= maxChars) {
|
|
return value;
|
|
}
|
|
|
|
return `${value.slice(0, maxChars - 3)}...`;
|
|
};
|
|
|
|
|
|
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 openContextBrowser = useUIStore((state) => state.openContextBrowser);
|
|
|
|
// Lets an agent's browser.open create the tab it needs when none is open yet.
|
|
// Registered from the panel because opening a tab is panel state, not
|
|
// something the browser view itself can do before it exists. Reveal the
|
|
// panel so Electron gives the webview a composited surface; capturePage()
|
|
// cannot capture the zero-width webview inside a closed panel.
|
|
React.useEffect(() => {
|
|
if (!effectiveDirectory) return;
|
|
return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url));
|
|
}, [effectiveDirectory, openContextBrowser]);
|
|
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
|
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
|
const contextEditorTreeVisible = useUIStore((state) => state.contextEditorTreeVisible);
|
|
const toggleContextEditorTree = useUIStore((state) => state.toggleContextEditorTree);
|
|
const openNewContextBrowserTab = useUIStore((state) => state.openNewContextBrowserTab);
|
|
const faviconByOrigin = useBrowserFaviconStore((state) => state.byOrigin);
|
|
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;
|
|
}
|
|
|
|
// Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode).
|
|
// The terminal input listens in the bubble phase; stopping capture here
|
|
// would swallow the key before the terminal ever sees it (issue #2644).
|
|
if (isTerminalEventTarget(event.target)) {
|
|
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 chatTabs = React.useMemo(
|
|
() => tabs.filter((tab) => tab.mode === 'chat'),
|
|
[tabs],
|
|
);
|
|
const activeChatTabID = isOpen && activeTab?.mode === 'chat' ? activeTab.id : null;
|
|
const activeChatSessionID = isOpen && activeTab?.mode === 'chat' ? getSessionIDFromDedupeKey(activeTab.dedupeKey) : null;
|
|
const activeChatTab = getActiveEmbeddedSessionChatTab(chatTabs, activeChatTabID);
|
|
|
|
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,
|
|
}, { allowPromptingSubagentSessions });
|
|
}, [allowPromptingSubagentSessions, currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]);
|
|
|
|
const activeChatSrc = activeChatTab && activeChatSessionID
|
|
? getEmbeddedChatSrc(activeChatTab.id, activeChatSessionID, activeChatTab.readOnly)
|
|
: null;
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
|
|
frameWindow.postMessage({ type: 'openchamber:chat-settings-sync', payload }, window.location.origin);
|
|
}
|
|
}, [allowPromptingSubagentSessions]);
|
|
|
|
const postEmbeddedVisibilityToChat = React.useCallback((
|
|
tabID: string,
|
|
frame: HTMLIFrameElement,
|
|
targetOrigin: string,
|
|
) => {
|
|
const frameWindow = frame.contentWindow;
|
|
if (!frameWindow) {
|
|
return;
|
|
}
|
|
|
|
frameWindow.postMessage(
|
|
{
|
|
type: EMBEDDED_VISIBILITY_UPDATE,
|
|
payload: { visible: activeChatTabID === tabID },
|
|
},
|
|
targetOrigin,
|
|
);
|
|
}, [activeChatTabID]);
|
|
|
|
const postEmbeddedVisibilityToChats = React.useCallback(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
for (const [tabID, frame] of chatFrameRefs.current.entries()) {
|
|
postEmbeddedVisibilityToChat(tabID, frame, window.location.origin);
|
|
}
|
|
}, [postEmbeddedVisibilityToChat]);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const handleMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) {
|
|
return;
|
|
}
|
|
|
|
const sourceChatFrame = Array.from(chatFrameRefs.current.entries())
|
|
.find(([, frame]) => frame.contentWindow === event.source);
|
|
if (!sourceChatFrame) {
|
|
return;
|
|
}
|
|
|
|
const data = event.data as { type?: unknown; requestId?: unknown };
|
|
if (data?.type === EMBEDDED_VISIBILITY_REQUEST) {
|
|
const [tabID, frame] = sourceChatFrame;
|
|
postEmbeddedVisibilityToChat(tabID, frame, event.origin);
|
|
return;
|
|
}
|
|
if (data?.type === EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST) {
|
|
if (typeof data.requestId !== 'string' || !data.requestId) return;
|
|
const runtimeKey = getRuntimeKey();
|
|
const payload: EmbeddedSessionRuntimeBootstrap = {
|
|
apiBaseUrl: getRuntimeApiBaseUrl(),
|
|
clientToken: getRuntimeBearerTokenSync(),
|
|
localOrigin: typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string'
|
|
? window.__OPENCHAMBER_LOCAL_ORIGIN__
|
|
: '',
|
|
runtimeHeaders: getRuntimeExtraHeadersSync(),
|
|
relayHostId: runtimeKey.startsWith('host:') ? runtimeKey.slice('host:'.length) : '',
|
|
relay: getActiveRelayDescriptor() ?? undefined,
|
|
};
|
|
(event.source as WindowProxy | null)?.postMessage({
|
|
type: EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
|
|
requestId: data.requestId,
|
|
payload,
|
|
}, event.origin);
|
|
return;
|
|
}
|
|
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, postEmbeddedVisibilityToChat, 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,
|
|
// browser targets).
|
|
const isMultiInstanceMode = activeTab?.mode === 'file' || activeTab?.mode === 'chat' || activeTab?.mode === 'browser';
|
|
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, faviconByOrigin),
|
|
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
|
|
closeLabel: t('contextPanel.tab.closeTabAria', { label }),
|
|
};
|
|
}), [activeModeTabs, effectiveDirectory, faviconByOrigin, sessionTitleById, t]);
|
|
|
|
const activeNonChatContent = activeTab?.mode === 'context'
|
|
? <ContextPanelContent />
|
|
: activeTab?.mode === 'git'
|
|
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
|
|
: activeTab?.mode === 'pr'
|
|
? <PullRequestView />
|
|
: activeTab?.mode === 'linear'
|
|
? <React.Suspense fallback={null}><LinearIssuesView /></React.Suspense>
|
|
: activeTab?.mode === 'notes'
|
|
? <ProjectContextPanel />
|
|
: activeTab?.mode === 'plan'
|
|
? <React.Suspense fallback={null}><PlanView
|
|
targetPath={activeTab.targetPath}
|
|
savedProjectPlan={activeTab.projectPlanId && activeTab.projectPlanRef
|
|
? { projectRef: activeTab.projectPlanRef, planId: activeTab.projectPlanId }
|
|
: null}
|
|
/></React.Suspense>
|
|
: null;
|
|
|
|
const browserTabs = React.useMemo(
|
|
() => tabs.filter((tab) => tab.mode === 'browser'),
|
|
[tabs],
|
|
);
|
|
const diffTabs = React.useMemo(
|
|
() => tabs.filter((tab) => tab.mode === 'diff'),
|
|
[tabs],
|
|
);
|
|
const terminalTab = React.useMemo(
|
|
() => tabs.find((tab) => tab.mode === 'terminal') ?? null,
|
|
[tabs],
|
|
);
|
|
// Keep-alive: the walkthrough holds reading progress and scroll position that
|
|
// a remount would silently throw away.
|
|
const hasWalkthroughTab = React.useMemo(
|
|
() => tabs.some((tab) => tab.mode === 'walkthrough'),
|
|
[tabs],
|
|
);
|
|
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 closeContextPanelTabs = useUIStore((state) => state.closeContextPanelTabs);
|
|
const renderTabContextMenu = React.useCallback(
|
|
(args: { id: string; index: number; allIds: string[]; close: () => void }): React.ReactNode => {
|
|
if (!directoryKey) {
|
|
return null;
|
|
}
|
|
const { id, index, allIds, close } = args;
|
|
const closeOthers = () => closeContextPanelTabs(directoryKey, allIds.filter((tabId) => tabId !== id));
|
|
const closeToLeft = () => closeContextPanelTabs(directoryKey, allIds.slice(0, index));
|
|
const closeToRight = () => closeContextPanelTabs(directoryKey, allIds.slice(index + 1));
|
|
const closeAll = () => closeContextPanelTabs(directoryKey, allIds);
|
|
const hasOthers = allIds.length > 1;
|
|
const isFirst = index === 0;
|
|
const isLast = index === allIds.length - 1;
|
|
return (
|
|
<>
|
|
<ContextMenuItem onClick={close}>
|
|
<Icon name="close" className="mr-2 size-4" />
|
|
{t('contextPanel.tab.menu.close')}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onClick={closeOthers} disabled={!hasOthers}>
|
|
<Icon name="expand-horizontal" className="mr-2 size-4" />
|
|
{t('contextPanel.tab.menu.closeOthers')}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={closeToLeft} disabled={isFirst}>
|
|
<Icon name="expand-left" className="mr-2 size-4" />
|
|
{t('contextPanel.tab.menu.closeToLeft')}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={closeToRight} disabled={isLast}>
|
|
<Icon name="expand-right" className="mr-2 size-4" />
|
|
{t('contextPanel.tab.menu.closeToRight')}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onClick={closeAll} disabled={!hasOthers}>
|
|
<Icon name="close-circle" className="mr-2 size-4" />
|
|
{t('contextPanel.tab.menu.closeAll')}
|
|
</ContextMenuItem>
|
|
</>
|
|
);
|
|
},
|
|
[closeContextPanelTabs, directoryKey, t],
|
|
);
|
|
|
|
const header = (
|
|
<header className="flex h-10 items-stretch border-b border-border">
|
|
{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"
|
|
tabContextMenu={renderTabContextMenu}
|
|
/>
|
|
) : (
|
|
<div className="flex min-w-0 flex-1 items-center gap-1.5 px-3">
|
|
{activeTab ? getTabIcon(activeTab, faviconByOrigin) : 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">
|
|
{activeTab?.mode === 'browser' ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (!directoryKey) return;
|
|
openNewContextBrowserTab(directoryKey);
|
|
}}
|
|
className="h-7 w-7 p-0"
|
|
title={t('contextPanel.browser.newTab')}
|
|
aria-label={t('contextPanel.browser.newTab')}
|
|
>
|
|
<Icon name="add" className="h-3.5 w-3.5" />
|
|
</Button>
|
|
) : null}
|
|
{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" />
|
|
)}
|
|
{/* 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" />
|
|
)}
|
|
{!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 ? (
|
|
<React.Suspense fallback={null}><FilesView mode="editor-only" /></React.Suspense>
|
|
) : (
|
|
<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}
|
|
{activeChatTab && activeChatSessionID && activeChatSrc ? (
|
|
<iframe
|
|
key={activeChatTab.id}
|
|
ref={(node) => {
|
|
if (!node) {
|
|
chatFrameRefs.current.delete(activeChatTab.id);
|
|
return;
|
|
}
|
|
chatFrameRefs.current.set(activeChatTab.id, node);
|
|
}}
|
|
src={activeChatSrc}
|
|
title={t('contextPanel.iframe.sessionChatTitle', { sessionID: activeChatSessionID })}
|
|
className="absolute inset-0 h-full w-full border-0 bg-background"
|
|
onLoad={() => {
|
|
postThemeSyncToEmbeddedChat();
|
|
postChatSettingsSyncToEmbeddedChat();
|
|
postEmbeddedVisibilityToChats();
|
|
}}
|
|
/>
|
|
) : null}
|
|
{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'
|
|
)}
|
|
>
|
|
<React.Suspense fallback={null}>
|
|
<DiffView
|
|
hideStackedFileSidebar
|
|
stackedDefaultCollapsedAll
|
|
pinSelectedFileHeaderToTopOnNavigate
|
|
showOpenInEditorAction
|
|
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
|
|
onDiffScopeChange={handleDiffScopeChange}
|
|
targetFilePath={tab.targetPath}
|
|
flushContent
|
|
/>
|
|
</React.Suspense>
|
|
</div>
|
|
))}
|
|
{terminalTab ? (
|
|
<div className={cn('absolute inset-0', activeTab?.mode === 'terminal' ? 'block' : 'hidden')}>
|
|
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} directory={terminalTab.targetDirectory} />
|
|
</div>
|
|
) : null}
|
|
{hasWalkthroughTab ? (
|
|
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
|
|
<React.Suspense fallback={null}>
|
|
<WalkthroughView directory={effectiveDirectory} visible={activeTab?.mode === 'walkthrough'} />
|
|
</React.Suspense>
|
|
</div>
|
|
) : null}
|
|
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|