Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
import React from 'react';
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
const BOTTOM_DOCK_MIN_HEIGHT = 180;
const BOTTOM_DOCK_MAX_HEIGHT = 640;
const BOTTOM_DOCK_COLLAPSE_THRESHOLD = 110;
interface BottomTerminalDockProps {
isOpen: boolean;
isMobile: boolean;
children: React.ReactNode;
}
export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen, isMobile, children }) => {
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
const [fullscreenHeight, setFullscreenHeight] = React.useState<number | null>(null);
const [isResizing, setIsResizing] = React.useState(false);
const dockRef = React.useRef<HTMLElement | null>(null);
const startYRef = React.useRef(0);
const startHeightRef = React.useRef(bottomTerminalHeight || 300);
const previousHeightRef = React.useRef(bottomTerminalHeight || 300);
const standardHeight = React.useMemo(
() => Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300)),
[bottomTerminalHeight],
);
React.useEffect(() => {
if (!isOpen) {
setFullscreenHeight(null);
setIsResizing(false);
}
}, [isOpen]);
React.useEffect(() => {
if (isMobile || !isOpen || !isFullscreen) {
return;
}
const updateFullscreenHeight = () => {
const parentHeight = dockRef.current?.parentElement?.getBoundingClientRect().height;
if (!parentHeight || parentHeight <= 0) {
return;
}
const next = Math.round(parentHeight);
setFullscreenHeight((prev) => (prev === next ? prev : next));
};
updateFullscreenHeight();
const parent = dockRef.current?.parentElement;
if (!parent) {
return;
}
const observer = new ResizeObserver(updateFullscreenHeight);
observer.observe(parent);
return () => {
observer.disconnect();
};
}, [isFullscreen, isMobile, isOpen]);
React.useEffect(() => {
if (isMobile || !isResizing || isFullscreen) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const delta = startYRef.current - event.clientY;
const nextHeight = Math.min(
BOTTOM_DOCK_MAX_HEIGHT,
Math.max(BOTTOM_DOCK_MIN_HEIGHT, startHeightRef.current + delta)
);
setBottomTerminalHeight(nextHeight);
};
const handlePointerUp = () => {
setIsResizing(false);
const latestState = useUIStore.getState();
if (latestState.bottomTerminalHeight <= BOTTOM_DOCK_COLLAPSE_THRESHOLD) {
setBottomTerminalOpen(false);
}
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp, { once: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [isFullscreen, isMobile, isResizing, setBottomTerminalHeight, setBottomTerminalOpen]);
if (isMobile) {
return null;
}
const appliedHeight = isOpen
? (isFullscreen ? Math.max(standardHeight, fullscreenHeight ?? standardHeight) : standardHeight)
: 0;
const handlePointerDown = (event: React.PointerEvent) => {
if (!isOpen || isFullscreen) return;
setIsResizing(true);
startYRef.current = event.clientY;
startHeightRef.current = appliedHeight;
event.preventDefault();
};
const toggleFullscreen = () => {
if (!isOpen) return;
if (isFullscreen) {
setBottomTerminalExpanded(false);
const restoreHeight = Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, previousHeightRef.current));
setBottomTerminalHeight(restoreHeight);
return;
}
previousHeightRef.current = standardHeight;
setBottomTerminalExpanded(true);
};
return (
<section
ref={dockRef}
className={cn(
'relative flex overflow-hidden border-t border-border bg-sidebar',
isResizing ? 'transition-none' : 'transition-[height] duration-300 ease-in-out',
!isOpen && 'border-t-0'
)}
style={{
height: `${appliedHeight}px`,
minHeight: `${appliedHeight}px`,
maxHeight: `${appliedHeight}px`,
}}
aria-hidden={!isOpen || appliedHeight === 0}
>
{isOpen && !isFullscreen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-[4px] w-full cursor-row-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
)}
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal panel"
/>
)}
{isOpen && (
<div className="absolute right-2 top-2 z-30 inline-flex items-center gap-1">
<button
type="button"
onClick={toggleFullscreen}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
aria-label={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
>
{isFullscreen ? <RiFullscreenExitLine className="h-5 w-5" /> : <RiFullscreenLine className="h-5 w-5" />}
</button>
<button
type="button"
onClick={() => setBottomTerminalOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title="Close terminal panel"
aria-label="Close terminal panel"
>
<RiCloseLine className="h-6 w-6" />
</button>
</div>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
aria-hidden={!isOpen}
>
{children}
</div>
</section>
);
};

View File

@@ -0,0 +1,575 @@
import React from 'react';
import { RiArrowLeftRightLine, RiChat4Line, RiCloseLine, RiDonutChartFill, RiFileTextLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Button } from '@/components/ui/button';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { DiffView, FilesView, PlanView } from '@/components/views';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ContextPanelContent } from './ContextSidebarTab';
const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
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;
};
const getModeLabel = (mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'): string => {
if (mode === 'chat') return 'Chat';
if (mode === 'file') return 'Files';
if (mode === 'diff') return 'Diff';
if (mode === 'plan') return 'Plan';
return '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: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null }): string => {
if (tab.label) {
return tab.label;
}
if (tab.mode === 'file') {
return getFileNameFromPath(tab.targetPath) || 'Files';
}
return getModeLabel(tab.mode);
};
const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; targetPath: string | null }): React.ReactNode | undefined => {
if (tab.mode === 'file') {
return tab.targetPath
? <FileTypeIcon filePath={tab.targetPath} className="h-3.5 w-3.5" />
: undefined;
}
if (tab.mode === 'diff') {
return <RiArrowLeftRightLine className="h-3.5 w-3.5" />;
}
if (tab.mode === 'plan') {
return <RiFileTextLine className="h-3.5 w-3.5" />;
}
if (tab.mode === 'context') {
return <RiDonutChartFill className="h-3.5 w-3.5" />;
}
if (tab.mode === 'chat') {
return <RiChat4Line className="h-3.5 w-3.5" />;
}
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;
};
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null): string => {
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);
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)}...`;
};
export const ContextPanel: React.FC = () => {
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 toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
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);
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);
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 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]);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
const panel = panelRef.current;
if (!panel) {
return;
}
panel.style.setProperty('--oc-context-panel-width', `${nextWidth}px`);
}, []);
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
if (!isOpen || isExpanded || !directoryKey) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore; fallback listeners still handle drag
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
resizingWidthRef.current = width;
applyLiveWidth(width);
event.preventDefault();
}, [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]);
const handleClose = React.useCallback(() => {
if (!directoryKey) {
return;
}
closeContextPanel(directoryKey);
}, [closeContextPanel, directoryKey]);
const handleToggleExpanded = React.useCallback(() => {
if (!directoryKey) {
return;
}
toggleContextPanelExpanded(directoryKey);
}, [directoryKey, toggleContextPanelExpanded]);
const handlePanelKeyDownCapture = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
event.stopPropagation();
handleClose();
}, [handleClose]);
React.useEffect(() => {
if (!directoryKey || !activeTab) {
return;
}
if (activeTab.mode === 'file' && activeTab.targetPath) {
setSelectedFilePath(directoryKey, activeTab.targetPath);
return;
}
if (activeTab.mode === 'diff' && activeTab.targetPath) {
setPendingDiffFile(activeTab.targetPath);
}
}, [activeTab, directoryKey, setPendingDiffFile, setSelectedFilePath]);
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
const postThemeSyncToEmbeddedChat = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
}
const payload = {
themeMode,
lightThemeId,
darkThemeId,
currentTheme,
};
for (const frame of chatFrameRefs.current.values()) {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
continue;
}
const directThemeSync = (frameWindow as unknown as {
__openchamberApplyThemeSync?: (themePayload: typeof payload) => void;
}).__openchamberApplyThemeSync;
if (typeof directThemeSync === 'function') {
try {
directThemeSync(payload);
continue;
} catch {
// fallback to postMessage below
}
}
frameWindow.postMessage(
{
type: 'openchamber:theme-sync',
payload,
},
window.location.origin,
);
}
}, [currentTheme, darkThemeId, lightThemeId, themeMode]);
const 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) => {
const rawLabel = getTabLabel(tab);
const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS);
const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory);
return {
id: tab.id,
label,
icon: getTabIcon(tab),
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
closeLabel: `Close ${label} tab`,
};
}), [effectiveDirectory, tabs]);
const activeNonChatContent = activeTab?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate showOpenInEditorAction />
: activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'plan'
? <PlanView />
: null;
const chatTabs = React.useMemo(
() => tabs.filter((tab) => tab.mode === 'chat'),
[tabs],
);
const hasFileTabs = React.useMemo(
() => tabs.some((tab) => tab.mode === 'file'),
[tabs],
);
const isFileTabActive = activeTab?.mode === 'file';
const header = (
<header className="flex h-8 items-stretch border-b border-border/40">
<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"
/>
<div className="flex items-center gap-1 px-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleToggleExpanded}
className="h-7 w-7 p-0"
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
>
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine 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="Close panel"
aria-label="Close panel"
>
<RiCloseLine className="h-3.5 w-3.5" />
</Button>
</div>
</header>
);
if (!isOpen) {
return null;
}
const panelStyle: React.CSSProperties = isExpanded
? {
['--oc-context-panel-width' as string]: '100%',
width: '100%',
minWidth: '100%',
maxWidth: '100%',
}
: {
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`,
};
return (
<aside
ref={panelRef}
data-context-panel="true"
tabIndex={-1}
className={cn(
'flex min-h-0 flex-col overflow-hidden bg-background',
!isExpanded && 'border-l border-border/40',
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}
style={panelStyle}
>
{!isExpanded && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize transition-colors hover:bg-primary/50',
isResizing && 'bg-primary'
)}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize context panel"
/>
)}
{header}
<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;
}
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null);
if (!src) {
return null;
}
return (
<iframe
key={tab.id}
ref={(node) => {
if (!node) {
chatFrameRefs.current.delete(tab.id);
return;
}
chatFrameRefs.current.set(tab.id, node);
}}
src={src}
title={`Session chat ${sessionID}`}
className={cn(
'absolute inset-0 h-full w-full border-0 bg-background',
activeChatTabID === tab.id ? 'block' : 'hidden'
)}
onLoad={() => {
postThemeSyncToEmbeddedChat();
postEmbeddedVisibilityToChats();
}}
/>
);
})}
{activeTab?.mode !== 'chat' && !isFileTabActive ? activeNonChatContent : null}
</div>
</aside>
);
};

View File

@@ -0,0 +1,607 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { deriveMessageRole } from '@/components/chat/message/messageRole';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { copyTextToClipboard } from '@/lib/clipboard';
type SessionMessage = { info: Message; parts: Part[] };
const EMPTY_SESSION_MESSAGES: SessionMessage[] = [];
type ProviderModelLike = {
id?: string;
name?: string;
limit?: { context?: number };
};
type ProviderLike = {
id?: string;
name?: string;
models?: ProviderModelLike[];
};
type TokenBreakdown = {
input: number;
output: number;
reasoning: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
type ContextBuckets = {
user: number;
assistant: number;
tool: number;
other: number;
};
const EMPTY_BREAKDOWN: TokenBreakdown = {
input: 0,
output: 0,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
};
const EMPTY_BUCKETS: ContextBuckets = {
user: 0,
assistant: 0,
tool: 0,
other: 0,
};
const toNonNegativeNumber = (value: unknown): number => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return 0;
}
return value;
};
const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
const tokenCandidate = (message.info as { tokens?: unknown }).tokens;
const source =
tokenCandidate !== undefined
? tokenCandidate
: (message.parts.find((part) => (part as { tokens?: unknown }).tokens !== undefined) as { tokens?: unknown } | undefined)?.tokens;
if (typeof source === 'number') {
return {
...EMPTY_BREAKDOWN,
total: toNonNegativeNumber(source),
};
}
if (!source || typeof source !== 'object') {
return EMPTY_BREAKDOWN;
}
const breakdown = source as {
input?: unknown;
output?: unknown;
reasoning?: unknown;
cache?: { read?: unknown; write?: unknown };
};
const input = toNonNegativeNumber(breakdown.input);
const output = toNonNegativeNumber(breakdown.output);
const reasoning = toNonNegativeNumber(breakdown.reasoning);
const cacheRead = toNonNegativeNumber(breakdown.cache?.read);
const cacheWrite = toNonNegativeNumber(breakdown.cache?.write);
return {
input,
output,
reasoning,
cacheRead,
cacheWrite,
total: input + output + reasoning + cacheRead + cacheWrite,
};
};
const pickString = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return '';
};
const estimateTextLength = (value: unknown): number => {
if (typeof value === 'string') {
return value.length;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).length;
}
if (Array.isArray(value)) {
return value.reduce((sum, item) => sum + estimateTextLength(item), 0);
}
if (value && typeof value === 'object') {
return Object.values(value as Record<string, unknown>).reduce<number>((sum, item) => sum + estimateTextLength(item), 0);
}
return 0;
};
const estimatePartChars = (part: Part, role: 'user' | 'assistant' | 'tool' | 'other'): ContextBuckets => {
const partRecord = part as Record<string, unknown>;
const type = typeof partRecord.type === 'string' ? partRecord.type : '';
if (type === 'reasoning') {
return {
...EMPTY_BUCKETS,
assistant: estimateTextLength(partRecord.text) + estimateTextLength(partRecord.content),
};
}
const directText = pickString(
partRecord.text,
partRecord.content,
partRecord.value,
(partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.value,
(partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.text?.value,
);
if (type === 'tool' || role === 'tool') {
const toolInputOutputLength =
estimateTextLength(partRecord.input)
+ estimateTextLength(partRecord.output)
+ estimateTextLength(partRecord.error)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.input)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.output)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.error);
const toolPayloadLength =
toolInputOutputLength
+ estimateTextLength(partRecord.raw)
+ Math.round(estimateTextLength(partRecord.metadata) * 0.25)
+ Math.round(estimateTextLength(partRecord.state) * 0.1);
return { user: 0, assistant: 0, tool: toolPayloadLength, other: 0 };
}
if (role === 'user') {
return { user: directText.length, assistant: 0, tool: 0, other: 0 };
}
if (role === 'assistant') {
return { user: 0, assistant: directText.length, tool: 0, other: 0 };
}
return { user: 0, assistant: 0, tool: 0, other: directText.length };
};
const addBuckets = (target: ContextBuckets, value: ContextBuckets): ContextBuckets => ({
user: target.user + value.user,
assistant: target.assistant + value.assistant,
tool: target.tool + value.tool,
other: target.other + value.other,
});
const deriveRoleBucket = (message: SessionMessage): 'user' | 'assistant' | 'tool' | 'other' => {
const roleInfo = deriveMessageRole(message.info);
if (roleInfo.isUser) return 'user';
if (roleInfo.role === 'assistant') return 'assistant';
if (roleInfo.role === 'tool') return 'tool';
return 'other';
};
const computeContextBreakdown = (
sessionMessages: SessionMessage[],
systemPrompt: string,
): ContextBuckets => {
if (sessionMessages.length === 0) {
return { ...EMPTY_BUCKETS };
}
const totalChars = sessionMessages.reduce<ContextBuckets>((acc, message) => {
const role = deriveRoleBucket(message);
let bucket = { ...EMPTY_BUCKETS };
for (const part of message.parts) {
bucket = addBuckets(bucket, estimatePartChars(part, role));
}
return addBuckets(acc, bucket);
}, { ...EMPTY_BUCKETS });
totalChars.user += systemPrompt.length;
return {
user: Math.ceil(totalChars.user / 4),
assistant: Math.ceil(totalChars.assistant / 4),
tool: Math.ceil(totalChars.tool / 4),
other: Math.ceil(totalChars.other / 4),
};
};
const formatNumber = (value: number): string => value.toLocaleString();
const formatMoney = (value: number): string => {
if (!Number.isFinite(value) || value <= 0) return '$0.00';
if (value < 0.01) return `$${value.toFixed(4)}`;
return `$${value.toFixed(2)}`;
};
const formatDateTime = (timestamp: number | null): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
const value = new Date(timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
return value.replace(/, (\d{1,2}:\d{2} [AP]M)$/, ' at $1');
};
const formatMessageDateMeta = (timestamp: number | null): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
return new Date(timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
const capitalizeRole = (role: string): string => {
if (!role) return role;
return `${role[0].toUpperCase()}${role.slice(1)}`;
};
const resolveProviderAndModel = (
providers: ProviderLike[],
providerID: string,
modelID: string,
): { providerName: string; modelName: string; contextLimit: number | null } => {
const provider = providers.find((entry) => entry.id === providerID);
const model = provider?.models?.find((entry) => entry.id === modelID);
return {
providerName: provider?.name || providerID || '-',
modelName: model?.name || modelID || '-',
contextLimit: typeof model?.limit?.context === 'number' ? model.limit.context : null,
};
};
export const ContextPanelContent: React.FC = () => {
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
const copyResetTimeoutRef = React.useRef<number | null>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const sessionMessages = useSessionStore((state) => {
if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES;
return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES;
});
const providers = useConfigStore((state) => state.providers);
React.useEffect(() => {
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
copyResetTimeoutRef.current = null;
}
setExpandedRawMessages((prev) => (Object.keys(prev).length > 0 ? {} : prev));
setCopiedRawMessageId(null);
}, [currentSessionId]);
React.useEffect(() => {
return () => {
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
copyResetTimeoutRef.current = null;
}
};
}, []);
const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => {
const result = await copyTextToClipboard(value);
if (result.ok) {
setCopiedRawMessageId(messageId);
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
}
copyResetTimeoutRef.current = window.setTimeout(() => {
setCopiedRawMessageId((prev) => (prev === messageId ? null : prev));
copyResetTimeoutRef.current = null;
}, 2000);
} else {
setCopiedRawMessageId(null);
}
}, []);
const viewModel = React.useMemo(() => {
const currentSession = currentSessionId ? sessions.find((session) => session.id === currentSessionId) ?? null : null;
const assistantMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).role === 'assistant');
const userMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).isUser);
let contextMessage: SessionMessage | null = null;
for (let i = assistantMessages.length - 1; i >= 0; i -= 1) {
const message = assistantMessages[i];
if (extractTokenBreakdown(message).total > 0) {
contextMessage = message;
break;
}
}
const tokenBreakdown = contextMessage ? extractTokenBreakdown(contextMessage) : EMPTY_BREAKDOWN;
const totalAssistantCost = assistantMessages.reduce((sum, message) => {
const cost = toNonNegativeNumber((message.info as { cost?: unknown }).cost);
return sum + cost;
}, 0);
const latestAssistantInfo = (contextMessage?.info ?? null) as (Message & { providerID?: string; modelID?: string }) | null;
const providerModel = resolveProviderAndModel(
providers as ProviderLike[],
latestAssistantInfo?.providerID || '',
latestAssistantInfo?.modelID || '',
);
const contextLimit = providerModel.contextLimit;
const usagePercent = contextLimit && contextLimit > 0
? Math.min(999, (tokenBreakdown.total / contextLimit) * 100)
: 0;
const systemPrompt = ([...sessionMessages].reverse().find(
(entry) => deriveMessageRole(entry.info).isUser && typeof (entry.info as { system?: unknown }).system === 'string',
)?.info as { system?: string } | undefined)?.system || '';
const computedBreakdown = computeContextBreakdown(sessionMessages, systemPrompt);
const userTokens = computedBreakdown.user;
const assistantTokens = computedBreakdown.assistant;
const toolTokens = computedBreakdown.tool;
const otherTokens = Math.max(0, tokenBreakdown.input - userTokens - assistantTokens - toolTokens);
const breakdownTotal = userTokens + assistantTokens + toolTokens + otherTokens;
const firstMessageTs = sessionMessages[0]?.info?.time?.created;
const lastMessageTs = sessionMessages.length > 0
? sessionMessages[sessionMessages.length - 1]?.info?.time?.created
: null;
return {
sessionTitle: currentSession?.title || 'Untitled Session',
messagesCount: sessionMessages.length,
userMessagesCount: userMessages.length,
assistantMessagesCount: assistantMessages.length,
createdAt: (currentSession?.time?.created ?? firstMessageTs ?? null) as number | null,
lastActivityAt: (lastMessageTs ?? currentSession?.time?.created ?? null) as number | null,
providerModel,
tokenBreakdown,
usagePercent,
totalAssistantCost,
contextLimit,
breakdown: {
user: userTokens,
assistant: assistantTokens,
tool: toolTokens,
other: otherTokens,
},
breakdownTotal,
};
}, [currentSessionId, providers, sessionMessages, sessions]);
if (!currentSessionId) {
return (
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
Open a session to inspect context.
</div>
);
}
const segments: Array<{ key: string; label: string; value: number; color: string }> = [
{ key: 'user', label: 'User', value: viewModel.breakdown.user, color: 'var(--status-success)' },
{ key: 'assistant', label: 'Assistant', value: viewModel.breakdown.assistant, color: 'var(--primary-base)' },
{ key: 'tool', label: 'Tool Calls', value: viewModel.breakdown.tool, color: 'var(--status-warning)' },
{ key: 'other', label: 'Other', value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' },
];
return (
<div className="h-full overflow-y-auto bg-background">
<div className="mx-auto w-full max-w-[52rem] px-5 py-6">
{/* ── Session header ── */}
<div className="mb-6">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{viewModel.sessionTitle}</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 typography-micro text-muted-foreground/70">
<span>{viewModel.providerModel.providerName} / {viewModel.providerModel.modelName}</span>
{viewModel.createdAt && (
<>
<span>&middot;</span>
<span>{formatDateTime(viewModel.createdAt)}</span>
</>
)}
</div>
</div>
{/* ── Context usage ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="flex items-baseline justify-between">
<span className="typography-micro text-muted-foreground">Context</span>
<span className="typography-micro tabular-nums text-muted-foreground/70">
{formatNumber(viewModel.tokenBreakdown.total)}
{viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
</span>
</div>
<div className="mt-2.5 flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
{viewModel.usagePercent > 0 && (
<div
className="rounded-full transition-all duration-300"
style={{
width: `${Math.max(0.5, viewModel.usagePercent)}%`,
backgroundColor: viewModel.usagePercent > 80 ? 'var(--status-warning)' : 'var(--primary-base)',
}}
/>
)}
</div>
<div className="mt-1.5 typography-micro font-medium tabular-nums text-foreground/80">
{viewModel.usagePercent.toFixed(1)}% used
</div>
</div>
{/* ── Stat grid ── */}
<div className="mb-5 grid grid-cols-2 gap-2">
{([
{ label: 'Messages', value: formatNumber(viewModel.messagesCount) },
{ label: 'User', value: formatNumber(viewModel.userMessagesCount) },
{ label: 'Assistant', value: formatNumber(viewModel.assistantMessagesCount) },
{ label: 'Cost', value: formatMoney(viewModel.totalAssistantCost) },
] as const).map((item) => (
<div key={item.label} className="rounded-lg bg-[var(--surface-elevated)]/70 px-3 py-2.5">
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{item.value}</div>
</div>
))}
</div>
{/* ── Last turn tokens ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="typography-micro text-muted-foreground">Last Assistant Message</div>
<div className="mt-2.5 grid grid-cols-3 gap-x-4 gap-y-2.5">
{([
{ label: 'Input', value: viewModel.tokenBreakdown.input },
{ label: 'Output', value: viewModel.tokenBreakdown.output },
{ label: 'Reasoning', value: viewModel.tokenBreakdown.reasoning },
{ label: 'Cache Read', value: viewModel.tokenBreakdown.cacheRead },
{ label: 'Cache Write', value: viewModel.tokenBreakdown.cacheWrite },
] as const).map((item) => (
<div key={item.label}>
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{formatNumber(item.value)}</div>
</div>
))}
</div>
</div>
{/* ── Context breakdown ── */}
<div className="mb-6">
<div className="flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
{segments.map((segment) => {
if (segment.value <= 0 || viewModel.breakdownTotal <= 0) return null;
return (
<div
key={segment.key}
style={{
width: `${(segment.value / viewModel.breakdownTotal) * 100}%`,
backgroundColor: segment.color,
}}
/>
);
})}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
{segments.map((segment) => {
const pct = viewModel.breakdownTotal > 0 ? (segment.value / viewModel.breakdownTotal) * 100 : 0;
return (
<div key={segment.key} className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: segment.color }} />
<span className="typography-micro text-muted-foreground/70">
{segment.label} <span className="tabular-nums">{pct.toFixed(0)}%</span>
</span>
</div>
);
})}
</div>
</div>
{/* ── Raw messages ── */}
<div>
<div className="typography-micro text-muted-foreground">Raw Messages</div>
<div className="mt-2.5 space-y-1">
{[...sessionMessages].reverse().map((message) => {
const role = deriveMessageRole(message.info).role;
const isExpanded = expandedRawMessages[message.info.id] === true;
const isCopied = copiedRawMessageId === message.info.id;
const messageCreatedAt = (message.info.time?.created ?? null) as number | null;
const jsonValue = isExpanded
? JSON.stringify({ info: message.info, parts: message.parts }, null, 2)
: '';
return (
<div
key={message.info.id}
className="overflow-hidden rounded-lg bg-[var(--surface-elevated)]/70"
>
<button
type="button"
className="w-full cursor-pointer px-3 py-1.5 text-left hover:bg-[var(--interactive-hover)]"
aria-expanded={isExpanded}
onClick={() => {
setExpandedRawMessages((prev) => ({
...prev,
[message.info.id]: !(prev[message.info.id] === true),
}));
}}
>
<div className="flex items-center justify-between gap-2 whitespace-nowrap overflow-hidden">
<span className="min-w-0 inline-flex items-center gap-1.5">
<span className="typography-ui-label text-foreground shrink-0">{capitalizeRole(role)}</span>
<span className="min-w-0 truncate typography-micro text-muted-foreground">{message.info.id}</span>
</span>
<span className="typography-micro text-muted-foreground shrink-0">{formatMessageDateMeta(messageCreatedAt)}</span>
</div>
</button>
{isExpanded && (
<div className="border-t border-[var(--surface-subtle)] p-0">
<div className="group relative max-h-[26rem] w-full overflow-auto bg-[var(--surface-background)]">
<div className="absolute top-1 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
void handleCopyRawMessage(message.info.id, jsonValue);
}}
aria-label={isCopied ? 'Copied' : 'Copy JSON'}
title={isCopied ? 'Copied' : 'Copy'}
>
{isCopied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
</div>
<SyntaxHighlighter
language="json"
style={syntaxTheme}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.75rem',
background: 'transparent',
fontSize: 'var(--text-micro)',
lineHeight: '1.35',
}}
codeTagProps={{
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
},
}}
wrapLongLines
>
{jsonValue}
</SyntaxHighlighter>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,838 @@
import React, { useRef, useEffect } from 'react';
import { motion, useMotionValue, animate } from 'motion/react';
import { Header } from './Header';
import { BottomTerminalDock } from './BottomTerminalDock';
import { Sidebar } from './Sidebar';
import { NavRail } from './NavRail';
import { RightSidebar } from './RightSidebar';
import { RightSidebarTabs } from './RightSidebarTabs';
import { ContextPanel } from './ContextPanel';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { CommandPalette } from '../ui/CommandPalette';
import { HelpDialog } from '../ui/HelpDialog';
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
// Mobile drawer width as screen percentage
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
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;
};
export const MainLayout: React.FC = () => {
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700;
const {
isSidebarOpen,
isRightSidebarOpen,
isBottomTerminalOpen,
setRightSidebarOpen,
setBottomTerminalOpen,
activeMainTab,
setIsMobile,
isSessionSwitcherOpen,
isSettingsDialogOpen,
setSettingsDialogOpen,
isMultiRunLauncherOpen,
setMultiRunLauncherOpen,
multiRunLauncherPrefillPrompt,
} = useUIStore();
const { isMobile } = useDeviceInfo();
const effectiveDirectory = useEffectiveDirectory() ?? '';
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
const isContextPanelOpen = useUIStore((state) => {
if (!directoryKey) {
return false;
}
const panelState = state.contextPanelByDirectory[directoryKey];
const tabs = panelState?.tabs ?? [];
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1];
return Boolean(panelState?.isOpen && activeTab);
});
const setSidebarOpen = useUIStore((state) => state.setSidebarOpen);
const rightSidebarAutoClosedRef = React.useRef(false);
const bottomTerminalAutoClosedRef = React.useRef(false);
const leftSidebarAutoClosedByContextRef = React.useRef(false);
// Mobile drawer state
const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false);
const mobileRightDrawerOpenRef = React.useRef(false);
// Left drawer motion value
const leftDrawerX = useMotionValue(0);
const leftDrawerWidth = useRef(0);
// Right drawer motion value
const rightDrawerX = useMotionValue(0);
const rightDrawerWidth = useRef(0);
// Compute drawer width
useEffect(() => {
if (isMobile) {
leftDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100);
rightDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100);
}
}, [isMobile]);
// Sync left drawer state and motion value
useEffect(() => {
if (!isMobile) return;
const targetX = mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current;
animate(leftDrawerX, targetX, {
type: "spring",
stiffness: 400,
damping: 35,
mass: 0.8
});
}, [mobileLeftDrawerOpen, isMobile, leftDrawerX]);
// Sync right drawer state and motion value
useEffect(() => {
if (!isMobile) return;
mobileRightDrawerOpenRef.current = isRightSidebarOpen;
const targetX = isRightSidebarOpen ? 0 : rightDrawerWidth.current;
animate(rightDrawerX, targetX, {
type: "spring",
stiffness: 400,
damping: 35,
mass: 0.8
});
}, [isMobile, isRightSidebarOpen, rightDrawerX]);
// Sync session switcher state to left drawer (one-way)
useEffect(() => {
if (isMobile) {
setMobileLeftDrawerOpen(isSessionSwitcherOpen);
}
}, [isSessionSwitcherOpen, isMobile]);
// Sync right drawer and git sidebar state
useEffect(() => {
if (isMobile) {
mobileRightDrawerOpenRef.current = isRightSidebarOpen;
}
}, [isRightSidebarOpen, isMobile]);
// Trigger initial update check shortly after mount, then every hour.
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
React.useEffect(() => {
const initialDelayMs = 3000;
const periodicIntervalMs = 60 * 60 * 1000;
const timer = window.setTimeout(() => {
checkForUpdates();
}, initialDelayMs);
const interval = window.setInterval(() => {
checkForUpdates();
}, periodicIntervalMs);
return () => {
window.clearTimeout(timer);
window.clearInterval(interval);
};
}, [checkForUpdates]);
React.useEffect(() => {
const previous = useUIStore.getState().isMobile;
if (previous !== isMobile) {
setIsMobile(isMobile);
}
}, [isMobile, setIsMobile]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let timeoutId: number | undefined;
const handleResize = () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
useUIStore.getState().updateProportionalSidebarWidths();
}, 150);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, []);
React.useEffect(() => {
if (isContextPanelOpen) {
const currentlyOpen = useUIStore.getState().isSidebarOpen;
if (currentlyOpen) {
setSidebarOpen(false);
leftSidebarAutoClosedByContextRef.current = true;
}
return;
}
if (leftSidebarAutoClosedByContextRef.current) {
setSidebarOpen(true);
leftSidebarAutoClosedByContextRef.current = false;
}
}, [isContextPanelOpen, setSidebarOpen]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let timeoutId: number | undefined;
const handleResponsivePanels = () => {
const state = useUIStore.getState();
const width = window.innerWidth;
const height = window.innerHeight;
const shouldCloseRightSidebar = width < RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH;
const canAutoOpenRightSidebar = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH;
if (shouldCloseRightSidebar) {
if (state.isRightSidebarOpen) {
setRightSidebarOpen(false);
rightSidebarAutoClosedRef.current = true;
}
} else if (canAutoOpenRightSidebar && rightSidebarAutoClosedRef.current) {
setRightSidebarOpen(true);
rightSidebarAutoClosedRef.current = false;
}
const shouldCloseBottomTerminal =
height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT;
const canAutoOpenBottomTerminal =
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
if (shouldCloseBottomTerminal) {
if (state.isBottomTerminalOpen) {
setBottomTerminalOpen(false);
bottomTerminalAutoClosedRef.current = true;
}
} else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) {
setBottomTerminalOpen(true);
bottomTerminalAutoClosedRef.current = false;
}
};
const handleResize = () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
handleResponsivePanels();
}, 100);
};
handleResponsivePanels();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, [setBottomTerminalOpen, setRightSidebarOpen]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const unsubscribe = useUIStore.subscribe((state, prevState) => {
const width = window.innerWidth;
const height = window.innerHeight;
const rightCanAutoOpen = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH;
const bottomCanAutoOpen =
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
if (state.isRightSidebarOpen !== prevState.isRightSidebarOpen && rightCanAutoOpen) {
rightSidebarAutoClosedRef.current = false;
}
if (state.isBottomTerminalOpen !== prevState.isBottomTerminalOpen && bottomCanAutoOpen) {
bottomTerminalAutoClosedRef.current = false;
}
});
return () => {
unsubscribe();
};
}, []);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const root = document.documentElement;
let stickyKeyboardInset = 0;
let ignoreOpenUntilZero = false;
let previousHeight = 0;
let keyboardAvoidTarget: HTMLElement | null = null;
const setKeyboardOpen = useUIStore.getState().setKeyboardOpen;
const clearKeyboardAvoidTarget = () => {
if (!keyboardAvoidTarget) {
return;
}
keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px');
keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active');
keyboardAvoidTarget = null;
};
const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => {
if (!active) {
return null;
}
const explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id');
if (explicitTargetId) {
const explicitTarget = document.getElementById(explicitTargetId);
if (explicitTarget instanceof HTMLElement) {
return explicitTarget;
}
}
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
if (markedTarget) {
// data-keyboard-avoid="none" opts out of translateY avoidance entirely.
// Used by components with their own scroll (e.g. CodeMirror).
if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') {
return null;
}
return markedTarget;
}
if (active.classList.contains('overlay-scrollbar-container')) {
const parent = active.parentElement;
if (parent instanceof HTMLElement) {
return parent;
}
}
return active;
};
const forceKeyboardClosed = () => {
stickyKeyboardInset = 0;
ignoreOpenUntilZero = true;
root.style.setProperty('--oc-keyboard-inset', '0px');
setKeyboardOpen(false);
};
const updateVisualViewport = () => {
const viewport = window.visualViewport;
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
const active = document.activeElement as HTMLElement | null;
const tagName = active?.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
const isTextTarget = isInput || Boolean(active?.isContentEditable);
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
const viewportSum = height + offsetTop;
const rawInset = Math.max(0, layoutHeight - viewportSum);
// Keyboard heuristic:
// - when an input is focused, smaller deltas can still be keyboard
// - when not focused, treat only big deltas as keyboard (ignore toolbars)
const openThreshold = isTextTarget ? 120 : 180;
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
// Make the UI stable: treat keyboard inset as a step function.
// - When opening: take the first big inset and hold it.
// - When closing starts: immediately drop to 0 (even if keyboard animation continues).
// Closing start signals:
// - focus lost (handled via focusout)
// - visual viewport height starts increasing while inset is non-zero
if (ignoreOpenUntilZero) {
if (measuredInset === 0) {
ignoreOpenUntilZero = false;
}
stickyKeyboardInset = 0;
} else if (stickyKeyboardInset === 0) {
if (measuredInset > 0 && isTextTarget) {
stickyKeyboardInset = measuredInset;
}
} else {
// Only detect closing-by-height when focus is NOT on text input
// (prevents false positives during Android keyboard animation)
const closingByHeight = !isTextTarget && height > previousHeight + 6;
if (measuredInset === 0) {
stickyKeyboardInset = 0;
setKeyboardOpen(false);
} else if (closingByHeight) {
forceKeyboardClosed();
} else if (measuredInset > 0 && isTextTarget) {
// When focus is on text input, track actual inset (allows settling
// to correct value after Android animation fluctuations)
stickyKeyboardInset = measuredInset;
setKeyboardOpen(true);
} else if (measuredInset > stickyKeyboardInset) {
stickyKeyboardInset = measuredInset;
setKeyboardOpen(true);
}
}
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
previousHeight = height;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0;
root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`);
const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null;
if (!isMobile || !avoidTarget || !active) {
clearKeyboardAvoidTarget();
} else {
if (avoidTarget !== keyboardAvoidTarget) {
clearKeyboardAvoidTarget();
keyboardAvoidTarget = avoidTarget;
}
const viewportBottom = offsetTop + height;
const rect = active.getBoundingClientRect();
const overlap = rect.bottom - viewportBottom;
const clearance = 8;
const keyboardInset = Math.max(stickyKeyboardInset, measuredInset);
const avoidOffset = overlap > clearance && keyboardInset > 0
? Math.min(overlap, keyboardInset)
: 0;
const target = keyboardAvoidTarget;
if (target) {
target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`);
target.setAttribute('data-keyboard-avoid-active', 'true');
}
}
// Only force-scroll lock while an input is focused.
if (isMobile && isTextTarget) {
const scroller = document.scrollingElement;
if (scroller && scroller.scrollTop !== 0) {
scroller.scrollTop = 0;
}
if (window.scrollY !== 0) {
window.scrollTo(0, 0);
}
}
};
updateVisualViewport();
const viewport = window.visualViewport;
viewport?.addEventListener('resize', updateVisualViewport);
viewport?.addEventListener('scroll', updateVisualViewport);
window.addEventListener('resize', updateVisualViewport);
window.addEventListener('orientationchange', updateVisualViewport);
const isTextInputTarget = (element: HTMLElement | null) => {
if (!element) {
return false;
}
const tagName = element.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
return isInput || element.isContentEditable;
};
// Reset ignoreOpenUntilZero when focus moves to a text input.
// This allows keyboard detection to work when user taps input quickly
// while keyboard is still closing (common on Android).
const handleFocusIn = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (isTextInputTarget(target)) {
ignoreOpenUntilZero = false;
}
updateVisualViewport();
};
document.addEventListener('focusin', handleFocusIn, true);
const handleFocusOut = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (!isTextInputTarget(target)) {
return;
}
// Check if focus is moving to another input - if so, don't close keyboard
const related = event.relatedTarget as HTMLElement | null;
if (isTextInputTarget(related)) {
return;
}
// On mobile contenteditable editors (CodeMirror), focus can momentarily
// leave and return during drag-selection handles. Defer closing until the
// next frame and only close when no text target is focused and the
// visual viewport inset is actually zero.
window.requestAnimationFrame(() => {
if (isTextInputTarget(document.activeElement as HTMLElement | null)) {
return;
}
const currentViewport = window.visualViewport;
const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight;
const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0;
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
const viewportSum = height + offsetTop;
const rawInset = Math.max(0, layoutHeight - viewportSum);
if (rawInset > 0) {
updateVisualViewport();
return;
}
forceKeyboardClosed();
updateVisualViewport();
});
};
document.addEventListener('focusout', handleFocusOut, true);
return () => {
viewport?.removeEventListener('resize', updateVisualViewport);
viewport?.removeEventListener('scroll', updateVisualViewport);
window.removeEventListener('resize', updateVisualViewport);
window.removeEventListener('orientationchange', updateVisualViewport);
document.removeEventListener('focusin', handleFocusIn, true);
document.removeEventListener('focusout', handleFocusOut, true);
clearKeyboardAvoidTarget();
};
}, [isMobile]);
const secondaryView = React.useMemo(() => {
switch (activeMainTab) {
case 'plan':
return <PlanView />;
case 'git':
return <GitView />;
case 'diff':
return <DiffView />;
case 'terminal':
return <TerminalView />;
case 'files':
return <FilesView />;
default:
return null;
}
}, [activeMainTab]);
const isChatActive = activeMainTab === 'chat';
return (
<DiffWorkerProvider>
<div
className={cn(
'main-content-safe-area h-[100dvh]',
isMobile ? 'flex flex-col' : 'flex',
'bg-background'
)}
>
<CommandPalette />
<HelpDialog />
<OpenCodeStatusDialog />
<SessionDialogs />
{isMobile ? (
<DrawerProvider value={{
leftDrawerOpen: mobileLeftDrawerOpen,
rightDrawerOpen: isRightSidebarOpen,
toggleLeftDrawer: () => {
if (isRightSidebarOpen) {
setRightSidebarOpen(false);
}
setMobileLeftDrawerOpen(!mobileLeftDrawerOpen);
},
toggleRightDrawer: () => {
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerOpen(false);
}
setRightSidebarOpen(!isRightSidebarOpen);
},
leftDrawerX,
rightDrawerX,
leftDrawerWidth,
rightDrawerWidth,
setMobileLeftDrawerOpen,
setRightSidebarOpen,
}}>
{/* Mobile: header + drawer mode */}
{!(isSettingsDialogOpen || isMultiRunLauncherOpen) && <Header
onToggleLeftDrawer={() => {
if (isRightSidebarOpen) {
setRightSidebarOpen(false);
}
setMobileLeftDrawerOpen(!mobileLeftDrawerOpen);
}}
onToggleRightDrawer={() => {
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerOpen(false);
}
setRightSidebarOpen(!isRightSidebarOpen);
}}
leftDrawerOpen={mobileLeftDrawerOpen}
rightDrawerOpen={isRightSidebarOpen}
/>}
{/* Backdrop */}
<motion.button
type="button"
initial={false}
animate={{
opacity: mobileLeftDrawerOpen || isRightSidebarOpen ? 1 : 0,
pointerEvents: mobileLeftDrawerOpen || isRightSidebarOpen ? 'auto' : 'none',
}}
className="fixed inset-0 z-40 bg-black/50 cursor-default"
onClick={() => {
setMobileLeftDrawerOpen(false);
setRightSidebarOpen(false);
}}
aria-label="Close drawer"
/>
{/* Left drawer (Session) */}
<motion.aside
drag="x"
dragElastic={0.08}
dragMomentum={false}
dragConstraints={{ left: -(leftDrawerWidth.current || window.innerWidth * 0.85), right: 0 }}
style={{
width: `${MOBILE_DRAWER_WIDTH_PERCENT}%`,
x: leftDrawerX,
}}
onDragEnd={(_, info) => {
const drawerWidthPx = leftDrawerWidth.current || window.innerWidth * 0.85;
const threshold = drawerWidthPx * 0.3;
const velocityThreshold = 500;
const currentX = leftDrawerX.get();
const shouldClose = info.offset.x < -threshold || info.velocity.x < -velocityThreshold;
const shouldOpen = info.offset.x > threshold || info.velocity.x > velocityThreshold;
if (shouldClose) {
leftDrawerX.set(-drawerWidthPx);
setMobileLeftDrawerOpen(false);
} else if (shouldOpen) {
leftDrawerX.set(0);
setMobileLeftDrawerOpen(true);
} else {
if (currentX > -drawerWidthPx / 2) {
leftDrawerX.set(0);
} else {
leftDrawerX.set(-drawerWidthPx);
}
}
}}
className={cn(
'fixed left-0 top-0 z-50 h-full bg-transparent',
'cursor-grab active:cursor-grabbing'
)}
aria-hidden={!mobileLeftDrawerOpen}
>
<div className="h-full overflow-hidden flex bg-sidebar shadow-none drawer-safe-area">
<div onPointerDownCapture={(e) => e.stopPropagation()}>
<NavRail className="shrink-0" mobile />
</div>
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
<ErrorBoundary>
<SessionSidebar mobileVariant />
</ErrorBoundary>
</div>
</div>
</motion.aside>
{/* Right drawer (Git) */}
<motion.aside
drag="x"
dragElastic={0.08}
dragMomentum={false}
dragConstraints={{ left: 0, right: rightDrawerWidth.current || window.innerWidth * 0.85 }}
style={{
width: `${MOBILE_DRAWER_WIDTH_PERCENT}%`,
x: rightDrawerX,
}}
onDragEnd={(_, info) => {
const drawerWidthPx = rightDrawerWidth.current || window.innerWidth * 0.85;
const threshold = drawerWidthPx * 0.3;
const velocityThreshold = 500;
const currentX = rightDrawerX.get();
const shouldClose = info.offset.x > threshold || info.velocity.x > velocityThreshold;
const shouldOpen = info.offset.x < -threshold || info.velocity.x < -velocityThreshold;
if (shouldClose) {
rightDrawerX.set(drawerWidthPx);
setRightSidebarOpen(false);
} else if (shouldOpen) {
rightDrawerX.set(0);
setRightSidebarOpen(true);
} else {
if (currentX < drawerWidthPx / 2) {
rightDrawerX.set(0);
} else {
rightDrawerX.set(drawerWidthPx);
}
}
}}
className={cn(
'fixed right-0 top-0 z-50 h-full bg-transparent',
'cursor-grab active:cursor-grabbing'
)}
aria-hidden={!isRightSidebarOpen}
>
<div className="h-full overflow-hidden flex flex-col bg-background shadow-none drawer-safe-area">
<ErrorBoundary>
<GitView />
</ErrorBoundary>
</div>
</motion.aside>
{/* Main content area (fixed) */}
<div
className={cn(
'flex flex-1 overflow-hidden relative',
(isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
)}
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
>
<main className="w-full h-full overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
</div>
{/* Mobile multi-run launcher: full screen */}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background header-safe-area">
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
{/* Mobile settings: full screen */}
{isSettingsDialogOpen && (
<div className="absolute inset-0 z-10 bg-background header-safe-area">
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div>
)}
</DrawerProvider>
) : (
<>
{/* Desktop: Header always on top, then Sidebar + Content below */}
<div className="flex flex-1 flex-col overflow-hidden relative">
{/* Normal view: Header above Sidebar + content (like SettingsView) */}
<div className={cn('absolute inset-0 flex flex-col', isMultiRunLauncherOpen && 'invisible')}>
<Header />
<div className="flex flex-1 overflow-hidden">
<NavRail />
<div className="flex flex-1 min-w-0 overflow-hidden border-t border-l border-border/50 rounded-tl-xl">
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
<SessionSidebar hideProjectSelector />
</Sidebar>
<div className="flex flex-1 min-w-0 flex-col overflow-hidden">
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
<ContextPanel />
</div>
<RightSidebar isOpen={isRightSidebarOpen}>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
<ErrorBoundary><TerminalView /></ErrorBoundary>
</BottomTerminalDock>
</div>
</div>
</div>
</div>
{/* Multi-Run Launcher: replaces tabs content only */}
{isMultiRunLauncherOpen && (
<div className={cn('absolute inset-0 z-10 bg-background')}>
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
</div>
{/* Desktop settings: windowed dialog with blur */}
<SettingsWindow
open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
/>
</>
)}
</div>
</DiffWorkerProvider>
);
};

View File

@@ -0,0 +1,919 @@
import React from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
RiFolderAddLine,
RiSettings3Line,
RiQuestionLine,
RiDownloadLine,
RiInformationLine,
RiPencilLine,
RiCloseLine,
RiMenuFoldLine,
RiMenuUnfoldLine,
} from '@remixicon/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
import { useLongPress } from '@/hooks/useLongPress';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { sessionEvents } from '@/lib/sessionEvents';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ProjectEntry } from '@/lib/api/types';
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
const NAV_RAIL_WIDTH = 56;
const NAV_RAIL_EXPANDED_WIDTH = 200;
const NAV_RAIL_TEXT_FADE_MS = 180;
const PROJECT_TEXT_FADE_IN_DELAY_MS = 24;
const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
type NavRailActionButtonProps = {
onClick: () => void;
disabled?: boolean;
ariaLabel: string;
icon: React.ReactNode;
tooltipLabel: string;
shortcutHint?: string;
showExpandedShortcutHint?: boolean;
buttonClassName: string;
showExpandedContent: boolean;
actionTextVisible: boolean;
};
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
onClick,
disabled = false,
ariaLabel,
icon,
tooltipLabel,
shortcutHint,
showExpandedShortcutHint = true,
buttonClassName,
showExpandedContent,
actionTextVisible,
}) => {
const pointerTriggeredRef = React.useRef(false);
const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({
active: false,
pointerId: null,
});
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
if (disabled || event.button !== 0) {
pointerPressRef.current = { active: false, pointerId: null };
return;
}
pointerPressRef.current = { active: true, pointerId: event.pointerId };
}, [disabled]);
const clearPointerPress = React.useCallback(() => {
pointerPressRef.current = { active: false, pointerId: null };
}, []);
const handlePointerUp = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
if (disabled) return;
if (event.button !== 0) return;
const pointerPress = pointerPressRef.current;
if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) {
return;
}
clearPointerPress();
pointerTriggeredRef.current = true;
onClick();
}, [clearPointerPress, disabled, onClick]);
const handleClick = React.useCallback(() => {
if (disabled) return;
if (pointerTriggeredRef.current) {
pointerTriggeredRef.current = false;
return;
}
onClick();
}, [disabled, onClick]);
const btn = (
<button
type="button"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={clearPointerPress}
onPointerLeave={clearPointerPress}
onClick={handleClick}
className={buttonClassName}
aria-label={ariaLabel}
disabled={disabled}
>
{showExpandedContent && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg bg-transparent transition-colors group-hover:bg-[var(--interactive-hover)]/50"
/>
)}
<span className="relative z-10 flex size-8 basis-8 shrink-0 grow-0 items-center justify-center">
{icon}
</span>
<span
aria-hidden={!actionTextVisible}
className={cn(
'relative z-10 min-w-0 flex items-center justify-between gap-1 overflow-hidden transition-opacity duration-[180ms] ease-in-out',
showExpandedContent ? 'flex-1' : 'w-0 flex-none',
actionTextVisible ? 'opacity-100' : 'opacity-0',
)}
>
<span className="truncate text-left text-[13px]">{tooltipLabel}</span>
{shortcutHint && showExpandedShortcutHint && (
<span className="shrink-0 text-[10px] text-[var(--surface-mutedForeground)] opacity-70">
{shortcutHint}
</span>
)}
</span>
</button>
);
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
{!showExpandedContent && (
<TooltipContent side="right" sideOffset={8}>
<p>{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}</p>
</TooltipContent>
)}
</Tooltip>
);
};
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
colorVar,
children,
}) => (
<span
className="relative flex h-full w-full items-center justify-center rounded-lg overflow-hidden"
style={{ backgroundColor: 'var(--surface-muted)' }}
>
{colorVar && (
<span
className="absolute inset-0 opacity-15"
style={{ backgroundColor: colorVar }}
/>
)}
<span className="relative z-10 flex items-center justify-center">
{children}
</span>
</span>
);
/** First-letter avatar fallback */
const LetterAvatar: React.FC<{ label: string; color?: string | null }> = ({
label,
color,
}) => {
const letter = label.charAt(0).toUpperCase() || '?';
const colorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
return (
<span
className="flex h-4 w-4 items-center justify-center text-[15px] font-medium leading-none select-none"
style={{ color: colorVar ?? 'var(--surface-foreground)', fontFamily: 'var(--font-mono, monospace)' }}
>
{letter}
</span>
);
};
const ProjectStatusDots: React.FC<{
color: string;
variant?: 'streaming' | 'attention' | 'none';
size?: 'sm' | 'md';
}> = ({ color, variant = 'none', size = 'md' }) => (
<span className="inline-flex items-center justify-center gap-px" aria-hidden="true">
{Array.from({ length: 3 }).map((_, index) => (
<span key={index} className="inline-flex h-[3px] w-[3px] items-center justify-center">
<span
className={cn(
size === 'sm' ? 'h-[2.5px] w-[2.5px]' : 'h-[3px] w-[3px]',
'rounded-full',
variant === 'streaming' && 'animate-grid-pulse',
variant === 'attention' && 'animate-attention-diamond-pulse'
)}
style={{
backgroundColor: color,
animationDelay: variant === 'streaming'
? `${index * 150}ms`
: variant === 'attention'
? (index === 1 ? '0ms' : '130ms')
: undefined,
}}
/>
</span>
))}
</span>
);
/** Single project tile in the nav rail — right-click for context menu (no visible 3-dot) */
const ProjectTile: React.FC<{
project: ProjectEntry;
isActive: boolean;
hasStreaming: boolean;
hasUnread: boolean;
label: string;
expanded: boolean;
projectTextVisible: boolean;
onClick: () => void;
onEdit: () => void;
onClose: () => void;
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
const { currentTheme } = useThemeSystem();
const [menuOpen, setMenuOpen] = React.useState(false);
const [iconImageFailed, setIconImageFailed] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconImageUrl = !iconImageFailed
? getProjectIconImageUrl(project, {
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
})
: null;
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
const showStreamingDots = hasStreaming;
const showAttentionDots = !hasStreaming && hasUnread;
React.useEffect(() => {
setIconImageFailed(false);
}, [project.id, project.iconImage?.updatedAt]);
const longPressHandlers = useLongPress({
onLongPress: () => setMenuOpen(true),
onTap: onClick,
});
const iconElement = (
<TileBackground colorVar={projectColorVar}>
<span className="relative h-full w-full leading-none">
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
{projectIconImageUrl ? (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={projectIconImageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setIconImageFailed(true)}
/>
</span>
) : ProjectIcon ? (
<ProjectIcon
className="h-4 w-4 shrink-0"
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
/>
) : (
<LetterAvatar label={label} color={project.color} />
)}
</span>
{showStreamingDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--primary)" variant="streaming" />
</span>
)}
{showAttentionDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--status-info)" variant="attention" />
</span>
)}
</span>
</TileBackground>
);
const tileButton = (
<button
type="button"
{...longPressHandlers}
className={cn(
'group relative flex cursor-pointer items-center rounded-lg overflow-hidden',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
expanded ? 'h-9 w-full gap-2.5 pr-1.5 pl-[7px]' : 'h-9 w-9 justify-center',
!expanded && (
isActive
? 'bg-transparent border border-[var(--surface-foreground)]'
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]'
),
!expanded && menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
)}
>
{expanded && (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg border transition-colors',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--interactive-border)]'
: 'bg-transparent border-transparent group-hover:bg-[var(--interactive-hover)]/50 group-hover:border-[var(--interactive-border)]',
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
)}
/>
)}
<span className="flex size-[34px] basis-[34px] shrink-0 grow-0 items-center justify-center">
{iconElement}
</span>
<span
aria-hidden={!projectTextVisible}
className={cn(
'min-w-0 truncate text-left text-[13px] leading-tight transition-opacity duration-[180ms] ease-in-out',
expanded ? 'flex-1' : 'w-0 flex-none',
projectTextVisible ? 'opacity-100' : 'opacity-0',
isActive && expanded ? 'font-medium text-[var(--interactive-selection-foreground)]' : 'text-[var(--surface-foreground)]',
)}
>
{label}
</span>
</button>
);
return (
<>
{expanded ? (
<div
className="relative w-full"
onContextMenu={(e) => {
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
e.preventDefault();
setMenuOpen(true);
}
}}
>
{tileButton}
</div>
) : (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<div
className="relative"
onContextMenu={(e) => {
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
e.preventDefault();
setMenuOpen(true);
}
}}
>
{tileButton}
</div>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
)}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<span className="sr-only">Project options</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" sideOffset={4} className="min-w-[160px]">
<DropdownMenuItem onClick={onEdit} className="gap-2">
<RiPencilLine className="h-4 w-4" />
Edit project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive gap-2"
>
<RiCloseLine className="h-4 w-4" />
Close project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
/** Constrain drag to Y axis only */
const restrictToYAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
/** Sortable wrapper for ProjectTile */
const SortableProjectTile: React.FC<{
id: string;
children: React.ReactNode;
}> = ({ id, children }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={cn(isDragging && 'opacity-30 z-50')}
{...attributes}
{...listeners}
>
{children}
</div>
);
};
interface NavRailProps {
className?: string;
mobile?: boolean;
}
export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((s) => s.setActiveProjectIdOnly);
const addProject = useProjectsStore((s) => s.addProject);
const removeProject = useProjectsStore((s) => s.removeProject);
const reorderProjects = useProjectsStore((s) => s.reorderProjects);
const updateProjectMeta = useProjectsStore((s) => s.updateProjectMeta);
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const isOverlayBlockingNavRailActions = useUIStore((s) => (
s.isSettingsDialogOpen
|| s.isHelpDialogOpen
|| s.isCommandPaletteOpen
|| s.isSessionSwitcherOpen
|| s.isAboutDialogOpen
|| s.isOpenCodeStatusDialogOpen
|| s.isSessionCreateDialogOpen
|| s.isModelSelectorOpen
|| s.isTimelineDialogOpen
|| s.isMultiRunLauncherOpen
|| s.isImagePreviewOpen
));
const isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const expanded = !mobile && isNavRailExpanded;
const [showExpandedContent, setShowExpandedContent] = React.useState(expanded);
const [projectTextVisible, setProjectTextVisible] = React.useState(expanded);
const [actionTextVisible, setActionTextVisible] = React.useState(expanded);
React.useEffect(() => {
if (expanded) {
setShowExpandedContent(true);
setProjectTextVisible(false);
setActionTextVisible(false);
const projectTimer = window.setTimeout(() => {
setProjectTextVisible(true);
}, PROJECT_TEXT_FADE_IN_DELAY_MS);
const actionTimer = window.setTimeout(() => {
setActionTextVisible(true);
}, ACTION_TEXT_FADE_IN_DELAY_MS);
return () => {
window.clearTimeout(projectTimer);
window.clearTimeout(actionTimer);
};
}
setProjectTextVisible(false);
setActionTextVisible(false);
const timer = window.setTimeout(() => {
setShowExpandedContent(false);
}, NAV_RAIL_TEXT_FADE_MS);
return () => {
window.clearTimeout(timer);
};
}, [expanded]);
const shortcutLabel = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]);
const sessionStatus = useSessionStore((s) => s.sessionStatus);
const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates);
const sessionsByDirectory = useSessionStore((s) => s.sessionsByDirectory);
const getSessionsByDirectory = useSessionStore((s) => s.getSessionsByDirectory);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
const updateStore = useUpdateStore();
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
const [editingProject, setEditingProject] = React.useState<{
id: string;
name: string;
path: string;
icon?: string | null;
color?: string | null;
iconBackground?: string | null;
} | null>(null);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const formatLabel = React.useCallback(
(project: ProjectEntry): string => {
return (
project.label?.trim() ||
formatDirectoryName(project.path, homeDirectory) ||
project.path
);
},
[homeDirectory],
);
const projectIndicators = React.useMemo(() => {
const result = new Map<string, { hasStreaming: boolean; hasUnread: boolean }>();
for (const project of projects) {
const projectRoot = normalize(project.path);
if (!projectRoot) {
result.set(project.id, { hasStreaming: false, hasUnread: false });
continue;
}
const dirs: string[] = [projectRoot];
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
for (const meta of worktrees) {
const p =
meta && typeof meta === 'object' && 'path' in meta
? (meta as { path?: unknown }).path
: null;
if (typeof p === 'string' && p.trim()) {
const normalized = normalize(p);
if (normalized && normalized !== projectRoot) {
dirs.push(normalized);
}
}
}
const seen = new Set<string>();
let hasStreaming = false;
let hasUnread = false;
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) continue;
seen.add(session.id);
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
hasStreaming = true;
}
const isCurrentVisible =
session.id === currentSessionId && project.id === activeProjectId;
if (
!isCurrentVisible &&
sessionAttentionStates.get(session.id)?.needsAttention === true
) {
hasUnread = true;
}
if (hasStreaming && hasUnread) break;
}
if (hasStreaming && hasUnread) break;
}
result.set(project.id, { hasStreaming, hasUnread });
}
return result;
}, [
activeProjectId,
availableWorktreesByProject,
currentSessionId,
getSessionsByDirectory,
projects,
sessionAttentionStates,
sessionStatus,
sessionsByDirectory,
]);
const handleAddProject = React.useCallback(() => {
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
sessionEvents.requestDirectoryDialog();
return;
}
requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', { description: result.error });
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
});
}, [addProject, tauriIpcAvailable]);
const handleEditProject = React.useCallback(
(projectId: string) => {
const project = projects.find((p) => p.id === projectId);
if (!project) return;
setEditingProject({
id: project.id,
name: formatLabel(project),
path: project.path,
icon: project.icon,
color: project.color,
iconBackground: project.iconBackground,
});
},
[projects, formatLabel],
);
const handleSaveProjectEdit = React.useCallback(
(data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
if (!editingProject) return;
updateProjectMeta(editingProject.id, data);
setEditingProject(null);
},
[editingProject, updateProjectMeta],
);
const handleCloseProject = React.useCallback(
(projectId: string) => {
removeProject(projectId);
},
[removeProject],
);
// Cmd/Ctrl+number to switch projects
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= projects.length) {
e.preventDefault();
const target = projects[num - 1];
if (target && target.id !== activeProjectId) {
setActiveProjectIdOnly(target.id);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, activeProjectId, setActiveProjectIdOnly]);
// Drag-to-reorder
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const projectIds = React.useMemo(() => projects.map((p) => p.id), [projects]);
const handleDragEnd = React.useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const fromIndex = projects.findIndex((p) => p.id === active.id);
const toIndex = projects.findIndex((p) => p.id === over.id);
if (fromIndex !== -1 && toIndex !== -1) {
reorderProjects(fromIndex, toIndex);
}
},
[projects, reorderProjects],
);
const navRailActionButtonClass = cn(
'group relative flex h-8 cursor-pointer items-center rounded-lg disabled:cursor-not-allowed',
showExpandedContent ? 'w-full justify-start gap-2.5 pr-2 pl-2' : 'w-8 justify-center',
showExpandedContent
? 'text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)]'
: 'text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)]/50 hover:text-[var(--surface-foreground)]',
'transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
);
const navRailActionIconClass = 'h-4.5 w-4.5 shrink-0';
return (
<>
<nav
className={cn(
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
showExpandedContent ? 'items-stretch' : 'items-center',
navRailInteractionBlocked && 'pointer-events-none',
className,
)}
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
aria-label="Project navigation"
>
{/* Projects list */}
<div className="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToYAxis]}
>
<SortableContext items={projectIds} strategy={verticalListSortingStrategy}>
<div className={cn('flex flex-col gap-3 pt-1 pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
{projects.map((project) => {
const isActive = project.id === activeProjectId;
const indicators = projectIndicators.get(project.id);
return (
<SortableProjectTile key={project.id} id={project.id}>
<ProjectTile
project={project}
isActive={isActive}
hasStreaming={indicators?.hasStreaming ?? false}
hasUnread={indicators?.hasUnread ?? false}
label={formatLabel(project)}
expanded={showExpandedContent}
projectTextVisible={projectTextVisible}
onClick={() => {
if (project.id !== activeProjectId) {
setActiveProjectIdOnly(project.id);
}
}}
onEdit={() => handleEditProject(project.id)}
onClose={() => handleCloseProject(project.id)}
/>
</SortableProjectTile>
);
})}
</div>
</SortableContext>
</DndContext>
{/* Add project button */}
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
<NavRailActionButton
onClick={handleAddProject}
disabled={navRailInteractionBlocked}
ariaLabel="Add project"
icon={<RiFolderAddLine className={navRailActionIconClass} />}
tooltipLabel="Add project"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
</div>
</div>
{/* Bottom actions */}
<div className={cn(
'shrink-0 w-full pt-3 pb-4 flex flex-col gap-1',
showExpandedContent ? 'items-stretch px-1' : 'items-center',
)}>
{(updateAvailable || updateDownloaded) && (
<NavRailActionButton
onClick={() => setUpdateDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="Update available"
icon={<RiDownloadLine className={navRailActionIconClass} />}
tooltipLabel="Update available"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
<NavRailActionButton
onClick={() => setAboutDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="About"
icon={<RiInformationLine className={navRailActionIconClass} />}
tooltipLabel="About OpenChamber"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
{!mobile && (
<NavRailActionButton
onClick={toggleHelpDialog}
disabled={navRailInteractionBlocked}
ariaLabel="Keyboard shortcuts"
icon={<RiQuestionLine className={navRailActionIconClass} />}
tooltipLabel="Shortcuts"
shortcutHint={shortcutLabel('open_help')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
<NavRailActionButton
onClick={() => setSettingsDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="Settings"
icon={<RiSettings3Line className={navRailActionIconClass} />}
tooltipLabel="Settings"
shortcutHint={shortcutLabel('open_settings')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
{/* Toggle expand/collapse (desktop only) */}
{!mobile && (
<NavRailActionButton
onClick={toggleNavRail}
disabled={navRailInteractionBlocked}
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
icon={expanded
? <RiMenuFoldLine className={navRailActionIconClass} />
: <RiMenuUnfoldLine className={navRailActionIconClass} />
}
tooltipLabel={expanded ? 'Collapse' : 'Expand'}
shortcutHint={shortcutLabel('toggle_nav_rail')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
</div>
</nav>
{/* Dialogs */}
{editingProject && (
<ProjectEditDialog
open={!!editingProject}
onOpenChange={(open) => {
if (!open) setEditingProject(null);
}}
projectId={editingProject.id}
projectName={editingProject.name}
projectPath={editingProject.path}
initialIcon={editingProject.icon}
initialColor={editingProject.color}
initialIconBackground={editingProject.iconBackground}
onSave={handleSaveProjectEdit}
/>
)}
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</>
);
};
export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH };

View File

@@ -0,0 +1,818 @@
import React from 'react';
import {
RiAddLine,
RiArrowDownSLine,
RiLoader4Line,
RiPlayLine,
RiStopLine,
} from '@remixicon/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import {
getProjectActionsState,
type OpenChamberProjectAction,
type ProjectRef,
} from '@/lib/openchamberConfig';
import {
normalizeProjectActionDirectory,
PROJECT_ACTIONS_UPDATED_EVENT,
PROJECT_ACTION_ICON_MAP,
resolveProjectActionDesktopForwardUrl,
toProjectActionRunKey,
} from '@/lib/projectActions';
type RunningEntry = {
key: string;
directory: string;
actionId: string;
tabId: string;
sessionId: string;
status: 'running' | 'stopping';
};
type UrlWatchEntry = {
lastSeenChunkId: number | null;
openedUrl: boolean;
tail: string;
};
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
};
interface ProjectActionsButtonProps {
projectRef: ProjectRef | null;
directory: string;
className?: string;
compact?: boolean;
allowMobile?: boolean;
}
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
const URL_GLOBAL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
const stripControlChars = (value: string): string => {
let next = '';
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
const isControl = (code >= 0 && code <= 8)
|| code === 11
|| code === 12
|| (code >= 14 && code <= 31)
|| code === 127;
if (!isControl) {
next += value[index];
}
}
return next;
};
const normalizeManualOpenUrl = (value: string | undefined): string | null => {
const raw = (value || '').trim();
if (!raw) {
return null;
}
const candidate = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
try {
const parsed = new URL(candidate);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
};
const extractBestUrl = (value: string): string | null => {
const cleaned = value.replace(ANSI_ESCAPE_PATTERN, '');
const matches = cleaned.match(URL_GLOBAL_PATTERN);
if (!matches || matches.length === 0) {
return null;
}
const normalized = matches
.map((entry) => entry.replace(/[),.;]+$/, ''))
.filter(Boolean);
if (normalized.length === 0) {
return null;
}
const portCandidates: Array<{ raw: string; parsed: URL }> = [];
for (const candidate of normalized) {
try {
const parsed = new URL(candidate);
if (parsed.port && parsed.port.length > 0) {
portCandidates.push({ raw: candidate, parsed });
}
} catch {
// noop
}
}
if (portCandidates.length > 0) {
const scoreCandidate = (entry: { raw: string; parsed: URL }): number => {
const { parsed } = entry;
const host = parsed.hostname.toLowerCase();
const isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
const normalizedPath = parsed.pathname || '/';
const pathSegments = normalizedPath.split('/').filter(Boolean).length;
const hasRootPath = normalizedPath === '/' || normalizedPath === '';
const hasQueryOrHash = Boolean(parsed.search || parsed.hash);
let score = 0;
if (isLocalHost) score += 50;
if (hasRootPath) score += 30;
score -= Math.min(pathSegments * 5, 20);
if (hasQueryOrHash) score -= 10;
return score;
};
portCandidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
return portCandidates[0]?.parsed.origin ?? portCandidates[0]?.raw ?? null;
}
return normalized[0] ?? null;
};
export const ProjectActionsButton = ({
projectRef,
directory,
className,
compact = false,
allowMobile = false,
}: ProjectActionsButtonProps) => {
const { terminal, runtime } = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const terminalSessions = useTerminalStore((state) => state.sessions);
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
const setActiveTab = useTerminalStore((state) => state.setActiveTab);
const setConnecting = useTerminalStore((state) => state.setConnecting);
const setTabSessionId = useTerminalStore((state) => state.setTabSessionId);
const [actions, setActions] = React.useState<OpenChamberProjectAction[]>([]);
const [selectedActionId, setSelectedActionId] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [runningByKey, setRunningByKey] = React.useState<Record<string, RunningEntry>>({});
const tabByKeyRef = React.useRef<Record<string, string>>({});
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
const loadRequestIdRef = React.useRef(0);
const projectId = projectRef?.id ?? null;
const projectPath = projectRef?.path ?? '';
const stableProjectRef = React.useMemo(() => {
if (!projectId) {
return null;
}
return { id: projectId, path: projectPath };
}, [projectId, projectPath]);
React.useEffect(() => {
if (!isDesktopShellApp) {
return;
}
void loadDesktopSsh().catch(() => undefined);
}, [isDesktopShellApp, loadDesktopSsh]);
const openExternal = React.useCallback(async (url: string) => {
try {
const tauri = (window as unknown as {
__TAURI__?: {
shell?: {
open?: (target: string) => Promise<unknown>;
};
};
}).__TAURI__;
if (tauri?.shell?.open) {
await tauri.shell.open(url);
return;
}
} catch {
// noop
}
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const loadActions = React.useCallback(async () => {
if (!stableProjectRef) {
return;
}
const requestId = loadRequestIdRef.current + 1;
loadRequestIdRef.current = requestId;
setIsLoading(true);
try {
const state = await getProjectActionsState(stableProjectRef);
if (loadRequestIdRef.current !== requestId) {
return;
}
const filtered = state.actions;
setActions(filtered);
setSelectedActionId((current) => {
if (filtered.length === 0) {
return null;
}
if (current && filtered.some((entry) => entry.id === current)) {
return current;
}
return filtered[0]?.id ?? null;
});
} catch {
if (loadRequestIdRef.current !== requestId) {
return;
}
// Keep last known actions while next project loads or transient fetch fails.
} finally {
if (loadRequestIdRef.current === requestId) {
setIsLoading(false);
}
}
}, [stableProjectRef]);
React.useEffect(() => {
void loadActions();
}, [loadActions]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (!projectId) {
return;
}
if (detail?.projectId && detail.projectId !== projectId) {
return;
}
void loadActions();
};
window.addEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
return () => {
window.removeEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
};
}, [loadActions, projectId]);
React.useEffect(() => {
if (!selectedActionId) {
return;
}
if (!actions.some((entry) => entry.id === selectedActionId)) {
setSelectedActionId(actions[0]?.id ?? null);
}
}, [actions, selectedActionId]);
React.useEffect(() => {
setRunningByKey((prev) => {
let changed = false;
const next: Record<string, RunningEntry> = {};
for (const [key, entry] of Object.entries(prev)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || tab.terminalSessionId !== entry.sessionId) {
changed = true;
continue;
}
next[key] = entry;
}
return changed ? next : prev;
});
}, [terminalSessions]);
React.useEffect(() => {
for (const [runKey, entry] of Object.entries(runningByKey)) {
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '' };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = actions.find((item) => item.id === entry.actionId);
if (!action) {
continue;
}
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) {
continue;
}
const nextChunks = tab.bufferChunks.filter((chunk) => {
if (watch.lastSeenChunkId === null) {
return true;
}
return chunk.id > watch.lastSeenChunkId;
});
if (nextChunks.length === 0) {
continue;
}
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
watch.lastSeenChunkId = lastChunkId;
watch.tail = textForScan.slice(-512);
if (maybeUrl) {
watch.openedUrl = true;
void openExternal(maybeUrl);
toast.success('Opened URL from action output');
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
if (!runningByKey[runKey]) {
delete urlWatchByRunKeyRef.current[runKey];
}
}
}, [actions, openExternal, runningByKey, terminalSessions]);
const normalizedDirectory = React.useMemo(() => {
return normalizeProjectActionDirectory(directory || stableProjectRef?.path || '');
}, [directory, stableProjectRef?.path]);
const selectedAction = React.useMemo(() => {
if (!selectedActionId) {
return actions[0] ?? null;
}
return actions.find((entry) => entry.id === selectedActionId) ?? actions[0] ?? null;
}, [actions, selectedActionId]);
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => {
if (!normalizedDirectory) {
throw new Error('No active directory');
}
const key = toProjectActionRunKey(normalizedDirectory, action.id);
ensureDirectory(normalizedDirectory);
const currentStore = useTerminalStore.getState();
const existingDirectoryState = currentStore.getDirectoryState(normalizedDirectory);
let tabId = tabByKeyRef.current[key] || null;
const hasTab = tabId
? Boolean(existingDirectoryState?.tabs.some((entry) => entry.id === tabId))
: false;
if (!tabId || !hasTab) {
tabId = currentStore.createTab(normalizedDirectory);
tabByKeyRef.current[key] = tabId;
}
setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`);
setActiveTab(normalizedDirectory, tabId);
setBottomTerminalOpen(true);
setActiveMainTab('terminal');
const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory);
const tab = stateAfterTab?.tabs.find((entry) => entry.id === tabId);
return {
key,
tabId,
sessionId: tab?.terminalSessionId ?? null,
};
}, [
ensureDirectory,
normalizedDirectory,
setActiveMainTab,
setActiveTab,
setBottomTerminalOpen,
setTabLabel,
]);
const runAction = React.useCallback(async (action: OpenChamberProjectAction) => {
if (runtime.isVSCode || (!allowMobile && isMobile)) {
return;
}
if (!normalizedDirectory) {
toast.error('No active directory for action');
return;
}
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
const existingRun = runningByKey[runKey];
if (existingRun && existingRun.status === 'running') {
return;
}
try {
const { key, tabId, sessionId } = await getOrCreateActionTab(action);
let activeSessionId = sessionId;
let createdSession = false;
if (!activeSessionId) {
setConnecting(normalizedDirectory, tabId, true);
try {
const created = await terminal.createSession({ cwd: normalizedDirectory });
activeSessionId = created.sessionId;
createdSession = true;
setTabSessionId(normalizedDirectory, tabId, activeSessionId);
} finally {
setConnecting(normalizedDirectory, tabId, false);
}
}
if (!activeSessionId) {
throw new Error('Failed to create terminal session');
}
if (createdSession) {
await sleep(350);
}
setRunningByKey((prev) => ({
...prev,
[key]: {
key,
directory: normalizedDirectory,
actionId: action.id,
tabId,
sessionId: activeSessionId,
status: 'running',
},
}));
const hasCustomOpenUrl = action.autoOpenUrl === true && (action.openUrl || '').trim().length > 0;
const hasDesktopForwardSelection = action.autoOpenUrl === true
&& isDesktopShellApp
&& (action.desktopOpenSshForward || '').trim().length > 0;
const manualOpenUrl = action.autoOpenUrl ? normalizeManualOpenUrl(action.openUrl) : null;
const desktopForwardUrl = action.autoOpenUrl && isDesktopShellApp
? resolveProjectActionDesktopForwardUrl(action.desktopOpenSshForward, desktopSshInstances)
: null;
if (desktopForwardUrl) {
void openExternal(desktopForwardUrl);
toast.success('Opened forwarded URL');
} else if (manualOpenUrl) {
void openExternal(manualOpenUrl);
toast.success('Opened action URL');
} else if (hasCustomOpenUrl) {
toast.error('Invalid custom URL format');
} else if (hasDesktopForwardSelection) {
toast.error('Selected desktop SSH forward is unavailable');
}
urlWatchByRunKeyRef.current[key] = {
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
};
const normalizedCommand = stripControlChars(action.command.trim().replace(/\r\n|\r/g, '\n'));
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
} catch (error) {
setRunningByKey((prev) => {
const next = { ...prev };
delete next[runKey];
return next;
});
delete urlWatchByRunKeyRef.current[runKey];
toast.error(error instanceof Error ? error.message : 'Failed to run action');
}
}, [
desktopSshInstances,
getOrCreateActionTab,
allowMobile,
isMobile,
isDesktopShellApp,
normalizedDirectory,
openExternal,
runningByKey,
runtime.isVSCode,
setConnecting,
setTabSessionId,
terminal,
]);
const stopAction = React.useCallback(async (action: OpenChamberProjectAction) => {
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
const activeRun = runningByKey[runKey];
if (!activeRun) {
return;
}
setRunningByKey((prev) => ({
...prev,
[runKey]: {
...activeRun,
status: 'stopping',
},
}));
try {
await terminal.sendInput(activeRun.sessionId, '\x03');
} catch {
// noop
}
await new Promise((resolve) => {
window.setTimeout(resolve, 1000);
});
const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs
.find((entry) => entry.id === activeRun.tabId);
const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId;
if (sessionStillSame) {
if (typeof terminal.forceKill === 'function') {
try {
await terminal.forceKill({ sessionId: activeRun.sessionId });
} catch {
// noop
}
} else {
try {
await terminal.close(activeRun.sessionId);
} catch {
// noop
}
}
setTabSessionId(activeRun.directory, activeRun.tabId, null);
}
setRunningByKey((prev) => {
const next = { ...prev };
delete next[runKey];
return next;
});
delete urlWatchByRunKeyRef.current[runKey];
}, [normalizedDirectory, runningByKey, setTabSessionId, terminal]);
const handlePrimaryClick = React.useCallback(() => {
if (!selectedAction) {
return;
}
const runKey = toProjectActionRunKey(normalizedDirectory, selectedAction.id);
const runningEntry = runningByKey[runKey];
if (runningEntry?.status === 'stopping') {
return;
}
if (runningEntry) {
void stopAction(selectedAction);
return;
}
void runAction(selectedAction);
}, [normalizedDirectory, runAction, runningByKey, selectedAction, stopAction]);
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
setSelectedActionId(action.id);
if (!toggleStopIfRunning) {
void runAction(action);
return;
}
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
const runningEntry = runningByKey[runKey];
if (runningEntry?.status === 'stopping') {
return;
}
if (runningEntry) {
void stopAction(action);
return;
}
void runAction(action);
}, [normalizedDirectory, runAction, runningByKey, stopAction]);
const openProjectActionsSettings = React.useCallback(() => {
if (!stableProjectRef?.id) {
return;
}
setSettingsProjectsSelectedId(stableProjectRef.id);
setSettingsPage('projects');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]);
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
return null;
}
if (actions.length === 0) {
if (compact) {
return (
<button
type="button"
className={cn(
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md p-2',
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
className
)}
aria-label="Add action"
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-5 w-5" />
</button>
);
}
return (
<button
type="button"
className={cn(
'app-region-no-drag inline-flex h-7 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] pl-1.5 pr-2.5 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
className
)}
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-4 w-4 text-muted-foreground" />
<span className="header-open-label">Add action</span>
</button>
);
}
const resolvedSelected = selectedAction ?? actions[0] ?? null;
if (!resolvedSelected) {
return null;
}
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
const selectedRunning = runningByKey[selectedRunKey];
const isStoppingSelected = selectedRunning?.status === 'stopping';
if (compact) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={isLoading || isStoppingSelected}
className={cn(
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md p-2',
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
'disabled:cursor-not-allowed',
className
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
>
{isStoppingSelected
? <RiLoader4Line className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
: selectedRunning
? <RiStopLine className="h-5 w-5 text-[var(--status-warning)]" />
: <SelectedIcon className="h-5 w-5" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
const runState = runningByKey[runKey];
const isRunning = Boolean(runState);
const isStopping = runState?.status === 'stopping';
return (
<DropdownMenuItem
key={entry.id}
className="flex items-center gap-2"
onClick={() => {
handleSelectAction(entry, true);
}}
>
<Icon className="h-4 w-4" />
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
{isStopping
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
: isRunning
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
: null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<div
className={cn(
'app-region-no-drag inline-flex items-center self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-none overflow-hidden',
compact ? 'h-9' : 'h-7',
className
)}
>
<button
type="button"
onClick={handlePrimaryClick}
disabled={isLoading || isStoppingSelected}
className={cn(
'inline-flex h-full items-center typography-ui-label font-medium text-foreground hover:bg-interactive-hover',
compact ? 'w-9 justify-center px-0' : 'gap-2 pl-2 pr-3',
'transition-colors disabled:cursor-not-allowed'
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
>
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
{isStoppingSelected
? <RiLoader4Line className="h-4 w-4 animate-spin text-[var(--status-warning)]" />
: selectedRunning
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
: <SelectedIcon className="h-4 w-4" />}
</span>
{!compact ? <span className="header-open-label">{resolvedSelected.name}</span> : null}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
'border-l border-[var(--interactive-border)] text-muted-foreground',
'hover:bg-interactive-hover hover:text-foreground transition-colors'
)}
aria-label="Choose project action"
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" alignOffset={8} className="w-52 max-h-[70vh] overflow-y-auto">
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
const runState = runningByKey[runKey];
const isRunning = Boolean(runState);
const isStopping = runState?.status === 'stopping';
return (
<DropdownMenuItem
key={entry.id}
className="flex items-center gap-2"
onClick={() => {
handleSelectAction(entry);
}}
>
<Icon className="h-4 w-4" />
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
{isStopping
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
: isRunning
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
: null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -0,0 +1,433 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
interface ProjectEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
projectName: string;
projectPath: string;
initialIcon?: string | null;
initialColor?: string | null;
initialIconBackground?: string | null;
onSave: (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => void;
}
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
const normalizeIconBackground = (value: string | null): string | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
};
export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
open,
onOpenChange,
projectId,
projectName,
projectPath,
initialIcon = null,
initialColor = null,
initialIconBackground = null,
onSave,
}) => {
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null);
const { currentTheme } = useThemeSystem();
const [name, setName] = React.useState(projectName);
const [icon, setIcon] = React.useState<string | null>(initialIcon);
const [color, setColor] = React.useState<string | null>(initialColor);
const [iconBackground, setIconBackground] = React.useState<string | null>(normalizeIconBackground(initialIconBackground));
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
const [pendingRemoveImageIcon, setPendingRemoveImageIcon] = React.useState(false);
const [pendingUploadIconFile, setPendingUploadIconFile] = React.useState<File | null>(null);
const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState<string | null>(null);
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const clearPendingUploadIcon = React.useCallback(() => {
setPendingUploadIconFile(null);
setPendingUploadIconPreviewUrl((previousUrl) => {
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
return null;
});
}, []);
React.useEffect(() => {
if (open) {
setName(projectName);
setIcon(initialIcon);
setColor(initialColor);
setIconBackground(normalizeIconBackground(initialIconBackground));
setPendingRemoveImageIcon(false);
clearPendingUploadIcon();
setPreviewImageFailed(false);
}
}, [open, projectName, initialIcon, initialColor, initialIconBackground, clearPendingUploadIcon]);
React.useEffect(() => {
return () => {
clearPendingUploadIcon();
};
}, [clearPendingUploadIcon]);
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
if (pendingUploadIconFile) {
setIsUploadingIcon(true);
const uploadResult = await uploadProjectIcon(projectId, pendingUploadIconFile);
setIsUploadingIcon(false);
if (!uploadResult.ok) {
toast.error(uploadResult.error || 'Failed to upload project icon');
return;
}
toast.success('Project icon updated');
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
}
const willRemoveImageIcon = pendingRemoveImageIcon && hasStoredImageIcon;
if (willRemoveImageIcon) {
setIsRemovingCustomIcon(true);
const result = await removeProjectIcon(projectId);
setIsRemovingCustomIcon(false);
if (!result.ok) {
toast.error(result.error || 'Failed to remove project icon');
return;
}
toast.success('Project icon removed');
setPendingRemoveImageIcon(false);
setIconBackground(null);
}
onSave({
label: trimmed,
icon,
color,
iconBackground: normalizeIconBackground(willRemoveImageIcon ? null : iconBackground),
});
onOpenChange(false);
};
const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
const hasStoredImageIcon = Boolean(currentIconImage);
const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl);
const hasCustomIcon = currentIconImage?.source === 'custom';
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
const hasRemovableImageIcon = effectiveHasImageIcon;
const iconPreviewUrl = !previewImageFailed
? (hasPendingUploadImageIcon
? pendingUploadIconPreviewUrl
: (hasStoredImageIcon && !pendingRemoveImageIcon
? getProjectIconImageUrl(
{ id: projectId, iconImage: currentIconImage ?? null },
{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
},
)
: null))
: null;
React.useEffect(() => {
setPreviewImageFailed(false);
}, [projectId, currentIconImage?.updatedAt]);
const handleUploadIcon = React.useCallback((file: File | null) => {
if (!projectId || !file || isUploadingIcon) {
return;
}
setPendingRemoveImageIcon(false);
setPreviewImageFailed(false);
setPendingUploadIconFile(file);
setPendingUploadIconPreviewUrl((previousUrl) => {
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
return URL.createObjectURL(file);
});
}, [isUploadingIcon, projectId]);
const handleRemoveImageIcon = React.useCallback(() => {
if (!projectId || !hasRemovableImageIcon || isRemovingCustomIcon) {
return;
}
if (hasPendingUploadImageIcon) {
clearPendingUploadIcon();
}
if (hasStoredImageIcon) {
setPendingRemoveImageIcon(true);
} else {
setPendingRemoveImageIcon(false);
}
setPreviewImageFailed(false);
}, [
clearPendingUploadIcon,
hasPendingUploadImageIcon,
hasRemovableImageIcon,
hasStoredImageIcon,
isRemovingCustomIcon,
projectId,
]);
const handleDiscoverIcon = React.useCallback(async () => {
if (!projectId || isDiscoveringIcon) {
return;
}
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
setPreviewImageFailed(false);
setIsDiscoveringIcon(true);
void discoverProjectIcon(projectId)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to discover project icon');
return;
}
if (result.skipped) {
toast.success('Custom icon already set for this project');
return;
}
toast.success('Project icon discovered');
})
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader className="min-w-0">
<DialogTitle>Edit project</DialogTitle>
</DialogHeader>
<div className="min-w-0 space-y-5 py-1">
{/* Name */}
<div className="min-w-0 space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">
Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
}
}}
autoFocus
/>
<p className="typography-meta text-muted-foreground truncate" title={projectPath}>
{projectPath}
</p>
</div>
{/* Color */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Color
</label>
<div className="flex gap-2 flex-wrap">
{/* No color option */}
<button
type="button"
onClick={() => setColor(null)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
color === null
? 'border-foreground scale-110'
: 'border-border hover:border-border/80'
)}
title="None"
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
{PROJECT_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => setColor(c.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all',
color === c.key
? 'border-foreground scale-110'
: 'border-transparent hover:border-border'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
{/* Icon */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon
</label>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,.png,.jpg,.jpeg,.svg"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
void handleUploadIcon(file);
event.currentTarget.value = '';
}}
/>
<div className="flex gap-2 flex-wrap">
{/* No icon option */}
<button
type="button"
onClick={() => setIcon(null)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
icon === null
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
: 'border-border hover:border-border/80'
)}
title="None"
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
{PROJECT_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
type="button"
onClick={() => setIcon(i.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
icon === i.key
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
: 'border-border hover:border-border/80'
)}
title={i.label}
>
<IconComponent
className="w-4 h-4"
style={currentColorVar ? { color: currentColorVar } : undefined}
/>
</button>
);
})}
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-[var(--surface-elevated)] p-1">
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
<img
src={iconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
</span>
</span>
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
{!hasCustomIcon && (
<>
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={isUploadingIcon}>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
</Button>
<Button size="sm" variant="outline" onClick={() => void handleDiscoverIcon()} disabled={isDiscoveringIcon}>
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
</Button>
</>
)}
{hasRemovableImageIcon && (
<Button size="sm" variant="outline" onClick={() => void handleRemoveImageIcon()} disabled={isRemovingCustomIcon}>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
</Button>
)}
{pendingRemoveImageIcon && (
<Button size="sm" variant="outline" onClick={() => setPendingRemoveImageIcon(false)} disabled={isRemovingCustomIcon}>
Undo Remove
</Button>
)}
</div>
</div>
{effectiveHasImageIcon && (
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon Background
</label>
<div className="flex flex-wrap items-center gap-2">
<input
type="color"
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label="Project icon background color"
/>
<Input
value={iconBackground ?? ''}
onChange={(event) => setIconBackground(event.target.value)}
placeholder="#000000"
className="h-8 w-[8.5rem]"
/>
<Button size="sm" variant="outline" onClick={() => setIconBackground(null)}>
Clear
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim() || isUploadingIcon || isRemovingCustomIcon}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,144 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
const RIGHT_SIDEBAR_MAX_WIDTH = 860;
interface RightSidebarProps {
isOpen: boolean;
children: React.ReactNode;
}
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children }) => {
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(rightSidebarWidth || 420);
const resizingWidthRef = React.useRef<number | null>(null);
const activeResizePointerIDRef = React.useRef<number | null>(null);
const sidebarRef = React.useRef<HTMLElement | null>(null);
const clampRightSidebarWidth = React.useCallback((value: number) => {
return Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, value));
}, []);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
const sidebar = sidebarRef.current;
if (!sidebar) {
return;
}
sidebar.style.setProperty('--oc-right-sidebar-width', `${nextWidth}px`);
}, []);
const appliedWidth = isOpen
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420))
: 0;
const handlePointerDown = (event: React.PointerEvent) => {
if (!isOpen) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = appliedWidth;
resizingWidthRef.current = appliedWidth;
applyLiveWidth(appliedWidth);
event.preventDefault();
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) {
return;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampRightSidebarWidth(startWidthRef.current + delta);
if (resizingWidthRef.current === nextWidth) {
return;
}
resizingWidthRef.current = nextWidth;
applyLiveWidth(nextWidth);
};
const handlePointerEnd = (event: React.PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampRightSidebarWidth(resizingWidthRef.current ?? appliedWidth);
activeResizePointerIDRef.current = null;
resizingWidthRef.current = null;
setIsResizing(false);
setRightSidebarWidth(finalWidth);
};
React.useEffect(() => {
if (!isResizing) {
resizingWidthRef.current = null;
activeResizePointerIDRef.current = null;
}
}, [isResizing]);
return (
<aside
ref={sidebarRef}
className={cn(
'relative flex h-full overflow-hidden border-l border-border/40 bg-sidebar/50',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-l-0'
)}
style={{
width: 'var(--oc-right-sidebar-width)',
minWidth: 'var(--oc-right-sidebar-width)',
maxWidth: 'var(--oc-right-sidebar-width)',
['--oc-right-sidebar-width' as string]: `${isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth}px`,
overflowX: 'clip',
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize right panel"
/>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
isResizing && 'pointer-events-none',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
aria-hidden={!isOpen}
>
{isOpen ? children : null}
</div>
</aside>
);
};

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { RiFolder3Line, RiGitBranchLine } from '@remixicon/react';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { GitView } from '@/components/views';
import { useUIStore } from '@/stores/useUIStore';
import { SidebarFilesTree } from './SidebarFilesTree';
type RightTab = 'git' | 'files';
export const RightSidebarTabs: React.FC = () => {
const rightSidebarTab = useUIStore((state) => state.rightSidebarTab);
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
const tabItems = React.useMemo(() => [
{
id: 'git',
label: 'Git',
icon: <RiGitBranchLine className="h-3.5 w-3.5" />,
},
{
id: 'files',
label: 'Files',
icon: <RiFolder3Line className="h-3.5 w-3.5" />,
},
], []);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-transparent">
<div className="h-9 bg-transparent pt-1 px-2">
<SortableTabsStrip
items={tabItems}
activeId={rightSidebarTab}
onSelect={(tabID) => setRightSidebarTab(tabID as RightTab)}
layoutMode="fit"
variant="active-pill"
className="h-full"
/>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{rightSidebarTab === 'git' ? <GitView /> : <SidebarFilesTree />}
</div>
</div>
);
};

View File

@@ -0,0 +1,161 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useUIStore } from '@/stores/useUIStore';
export const SIDEBAR_CONTENT_WIDTH = 250;
const SIDEBAR_MIN_WIDTH = 250;
const SIDEBAR_MAX_WIDTH = 500;
interface SidebarProps {
isOpen: boolean;
isMobile: boolean;
children: React.ReactNode;
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
const { sidebarWidth, setSidebarWidth } = useUIStore();
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
const resizingWidthRef = React.useRef<number | null>(null);
const activeResizePointerIDRef = React.useRef<number | null>(null);
const sidebarRef = React.useRef<HTMLElement | null>(null);
const clampSidebarWidth = React.useCallback((value: number) => {
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, value));
}, []);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
const sidebar = sidebarRef.current;
if (!sidebar) {
return;
}
sidebar.style.setProperty('--oc-left-sidebar-width', `${nextWidth}px`);
}, []);
React.useEffect(() => {
if (isMobile && isResizing) {
setIsResizing(false);
}
}, [isMobile, isResizing]);
React.useEffect(() => {
if (!isResizing) {
resizingWidthRef.current = null;
activeResizePointerIDRef.current = null;
}
}, [isResizing]);
if (isMobile) {
return null;
}
const appliedWidth = isOpen ? Math.min(
SIDEBAR_MAX_WIDTH,
Math.max(SIDEBAR_MIN_WIDTH, sidebarWidth || SIDEBAR_CONTENT_WIDTH)
) : 0;
const handlePointerDown = (event: React.PointerEvent) => {
if (!isOpen) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = appliedWidth;
resizingWidthRef.current = appliedWidth;
applyLiveWidth(appliedWidth);
event.preventDefault();
};
const handlePointerMove = (event: React.PointerEvent) => {
if (isMobile || !isResizing || activeResizePointerIDRef.current !== event.pointerId) {
return;
}
const delta = event.clientX - startXRef.current;
const nextWidth = clampSidebarWidth(startWidthRef.current + delta);
if (resizingWidthRef.current === nextWidth) {
return;
}
resizingWidthRef.current = nextWidth;
applyLiveWidth(nextWidth);
};
const handlePointerEnd = (event: React.PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId || isMobile) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampSidebarWidth(resizingWidthRef.current ?? appliedWidth);
activeResizePointerIDRef.current = null;
resizingWidthRef.current = null;
setIsResizing(false);
setSidebarWidth(finalWidth);
};
return (
<aside
ref={sidebarRef}
className={cn(
'relative flex h-full overflow-hidden border-r border-border/40',
'bg-sidebar/50',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-r-0'
)}
style={{
width: 'var(--oc-left-sidebar-width)',
minWidth: 'var(--oc-left-sidebar-width)',
maxWidth: 'var(--oc-left-sidebar-width)',
['--oc-left-sidebar-width' as string]: `${isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth}px`,
overflowX: 'clip',
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize left panel"
/>
)}
<div
className={cn(
'relative z-10 flex h-full flex-col transition-opacity duration-300 ease-in-out',
isResizing && 'pointer-events-none',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
aria-hidden={!isOpen}
>
<div className="flex-1 overflow-hidden">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { cn } from '@/lib/utils';
interface SidebarContextSummaryProps {
className?: string;
}
const formatSessionTitle = (title?: string | null) => {
if (!title) {
return 'Untitled Session';
}
const trimmed = title.trim();
return trimmed.length > 0 ? trimmed : 'Untitled Session';
};
const formatDirectoryPath = (path?: string) => {
if (!path || path.length === 0) {
return '/';
}
return path;
};
export const SidebarContextSummary: React.FC<SidebarContextSummaryProps> = ({ className }) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const { currentDirectory } = useDirectoryStore();
const activeSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
return 'No active session';
}
const session = sessions.find((item) => item.id === currentSessionId);
return session ? formatSessionTitle(session.title) : 'No active session';
}, [currentSessionId, sessions]);
const directoryFull = React.useMemo(() => {
return formatDirectoryPath(currentDirectory);
}, [currentDirectory]);
const directoryDisplay = React.useMemo(() => {
if (!directoryFull || directoryFull === '/') {
return directoryFull;
}
const segments = directoryFull.split('/').filter(Boolean);
return segments.length ? segments[segments.length - 1] : directoryFull;
}, [directoryFull]);
return (
<div className={cn('hidden min-h-[48px] flex-col justify-center gap-0.5 border-b bg-sidebar/60 px-3 py-2 backdrop-blur md:flex md:pb-2', className)}>
<span className="typography-meta text-muted-foreground">Session</span>
<span className="typography-ui-label font-semibold text-foreground truncate" title={activeSessionTitle}>
{activeSessionTitle}
</span>
<span className="typography-meta text-muted-foreground truncate" title={directoryFull}>
{directoryDisplay}
</span>
</div>
);
};

View File

@@ -0,0 +1,880 @@
import React from 'react';
import {
RiCloseLine,
RiDeleteBinLine,
RiEditLine,
RiFileAddLine,
RiFileCopyLine,
RiFolder3Fill,
RiFolderAddLine,
RiFolderOpenFill,
RiFolderReceivedLine,
RiLoader4Line,
RiMore2Fill,
RiRefreshLine,
RiSearchLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitStatus } from '@/stores/useGitStore';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
type FileNode = {
name: string;
path: string;
type: 'file' | 'directory';
extension?: string;
relativePath?: string;
};
const sortNodes = (items: FileNode[]) =>
items.slice().sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const normalizePath = (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 isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']);
const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name);
const shouldIgnorePath = (path: string): boolean => {
const normalized = normalizePath(path);
return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/');
};
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
return <FileTypeIcon filePath={filePath} extension={extension} />;
};
// --- Git status indicators (matching FilesView) ---
type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted';
const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
const color = {
open: 'var(--status-info)',
modified: 'var(--status-warning)',
'git-modified': 'var(--status-warning)',
'git-added': 'var(--status-success)',
'git-deleted': 'var(--status-error)',
}[status];
return <span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />;
};
// --- FileRow with context menu (matching FilesView) ---
interface FileRowProps {
node: FileNode;
isExpanded: boolean;
isActive: boolean;
status?: FileStatus | null;
badge?: { modified: number; added: number } | null;
permissions: {
canRename: boolean;
canCreateFile: boolean;
canCreateFolder: boolean;
canDelete: boolean;
canReveal: boolean;
};
contextMenuPath: string | null;
setContextMenuPath: (path: string | null) => void;
onSelect: (node: FileNode) => void;
onToggle: (path: string) => void;
onRevealPath: (path: string) => void;
onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void;
}
const FileRow: React.FC<FileRowProps> = ({
node,
isExpanded,
isActive,
status,
badge,
permissions,
contextMenuPath,
setContextMenuPath,
onSelect,
onToggle,
onRevealPath,
onOpenDialog,
}) => {
const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return;
event?.preventDefault();
setContextMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setContextMenuPath]);
const handleInteraction = React.useCallback(() => {
if (isDir) {
onToggle(node.path);
} else {
onSelect(node);
}
}, [isDir, node, onSelect, onToggle]);
const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setContextMenuPath(node.path);
}, [node.path, setContextMenuPath]);
return (
<div
className="group relative flex items-center"
onContextMenu={handleContextMenu}
>
<button
type="button"
onClick={handleInteraction}
onContextMenu={handleContextMenu}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
)}
>
{isDir ? (
isExpanded ? (
<RiFolderOpenFill className="h-4 w-4 flex-shrink-0 text-primary/60" />
) : (
<RiFolder3Fill className="h-4 w-4 flex-shrink-0 text-primary/60" />
)
) : (
getFileIcon(node.path, node.extension)
)}
<span className="min-w-0 flex-1 truncate typography-meta" title={node.path}>
{node.name}
</span>
{!isDir && status && <FileStatusDot status={status} />}
{isDir && badge && (
<span className="text-xs flex items-center gap-1 ml-auto mr-1">
{badge.modified > 0 && <span className="text-[var(--status-warning)]">M{badge.modified}</span>}
{badge.added > 0 && <span className="text-[var(--status-success)]">+{badge.added}</span>}
</span>
)}
</button>
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100 group-hover:opacity-100">
<DropdownMenu
open={contextMenuPath === node.path}
onOpenChange={(open) => setContextMenuPath(open ? node.path : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleMenuButtonClick}
>
<RiMore2Fill className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
<RiEditLine className="mr-2 h-4 w-4" /> Rename
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void copyTextToClipboard(node.path).then((result) => {
if (result.ok) {
toast.success('Path copied');
return;
}
toast.error('Copy failed');
});
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</DropdownMenuItem>
{canReveal && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> Reveal in Finder
</DropdownMenuItem>
)}
{isDir && (canCreateFile || canCreateFolder) && (
<>
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
</DropdownMenuItem>
)}
</>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
};
// --- Main component ---
export const SidebarFilesTree: React.FC = () => {
const { files, runtime } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim());
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const openContextFile = useUIStore((state) => state.openContextFile);
const gitStatus = useGitStatus(currentDirectory);
const [searchQuery, setSearchQuery] = React.useState('');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
const [searching, setSearching] = React.useState(false);
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
const EMPTY_PATHS: string[] = React.useMemo(() => [], []);
const EMPTY_CONTEXT_TABS: Array<{ mode: string; targetPath: string | null }> = React.useMemo(() => [], []);
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath);
const contextTabs = useUIStore((state) => (root ? (state.contextPanelByDirectory[root]?.tabs ?? EMPTY_CONTEXT_TABS) : EMPTY_CONTEXT_TABS));
const openContextFilePaths = React.useMemo(() => new Set(
contextTabs
.map((tab) => (tab.mode === 'file' ? tab.targetPath : null))
.filter((targetPath): targetPath is string => typeof targetPath === 'string' && targetPath.length > 0)
.map((targetPath) => normalizePath(targetPath))
), [contextTabs]);
// Context menu state
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
// Dialog state for CRUD operations
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
const [dialogInputValue, setDialogInputValue] = React.useState('');
const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
const canRename = Boolean(files.rename);
const canDelete = Boolean(files.delete);
const canReveal = Boolean(files.revealPath);
const handleRevealPath = React.useCallback((targetPath: string) => {
if (!files.revealPath) return;
void files.revealPath(targetPath).catch(() => {
toast.error('Failed to reveal path');
});
}, [files]);
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
setActiveDialog(type);
setDialogData(data);
setDialogInputValue(type === 'rename' ? data.name || '' : '');
setIsDialogSubmitting(false);
}, []);
const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => {
const nodes = entries
.filter((entry) => entry && typeof entry.name === 'string' && entry.name.length > 0)
.filter((entry) => showHidden || !entry.name.startsWith('.'))
.filter((entry) => showGitignored || !shouldIgnoreEntryName(entry.name))
.map<FileNode>((entry) => {
const name = entry.name;
const normalizedEntryPath = normalizePath(entry.path || '');
const path = normalizedEntryPath
? (isAbsolutePath(normalizedEntryPath)
? normalizedEntryPath
: normalizePath(`${dirPath}/${normalizedEntryPath}`))
: normalizePath(`${dirPath}/${name}`);
const type = entry.isDirectory ? 'directory' : 'file';
const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined;
return { name, path, type, extension };
});
return sortNodes(nodes);
}, [showGitignored, showHidden]);
const loadDirectory = React.useCallback(async (dirPath: string) => {
const normalizedDir = normalizePath(dirPath.trim());
if (!normalizedDir) return;
if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) return;
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.add(normalizedDir);
const respectGitignore = !showGitignored;
const listPromise = runtime.isDesktop
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})))
: opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore }).then((result) => result.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
await listPromise
.then((entries) => {
const mapped = mapDirectoryEntries(normalizedDir, entries);
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
})
.catch(() => {
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: prev[normalizedDir] ?? [],
}));
})
.finally(() => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
});
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
const refreshRoot = React.useCallback(async () => {
if (!root) return;
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
await loadDirectory(root);
}, [loadDirectory, root]);
React.useEffect(() => {
if (!root) return;
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
void loadDirectory(root);
}, [loadDirectory, root, showHidden, showGitignored]);
React.useEffect(() => {
if (!root || expandedPaths.length === 0) return;
for (const expandedPath of expandedPaths) {
const normalized = normalizePath(expandedPath);
if (!normalized || normalized === root) continue;
if (!normalized.startsWith(`${root}/`)) continue;
if (loadedDirsRef.current.has(normalized) || inFlightDirsRef.current.has(normalized)) continue;
void loadDirectory(normalized);
}
}, [expandedPaths, loadDirectory, root]);
// --- Fuzzy search scoring (matching FilesView) ---
React.useEffect(() => {
if (!currentDirectory) {
setSearchResults([]);
setSearching(false);
return;
}
const trimmedQuery = debouncedSearchQuery.trim();
if (!trimmedQuery) {
setSearchResults([]);
setSearching(false);
return;
}
let cancelled = false;
setSearching(true);
searchFiles(currentDirectory, trimmedQuery, 150, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'file',
})
.then((hits) => {
if (cancelled) return;
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
const mapped: FileNode[] = filtered.map((hit) => ({
name: hit.name,
path: normalizePath(hit.path),
type: 'file',
extension: hit.extension,
relativePath: hit.relativePath,
}));
setSearchResults(mapped);
})
.catch(() => {
if (!cancelled) {
setSearchResults([]);
}
})
.finally(() => {
if (!cancelled) {
setSearching(false);
}
});
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
// --- Git status helpers (matching FilesView) ---
const getFileStatus = React.useCallback((path: string): FileStatus | null => {
if (openContextFilePaths.has(path)) return 'open';
if (gitStatus?.files) {
const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
const file = gitStatus.files.find((f) => f.path === relative);
if (file) {
if (file.index === 'A' || file.working_dir === '?') return 'git-added';
if (file.index === 'D') return 'git-deleted';
if (file.index === 'M' || file.working_dir === 'M') return 'git-modified';
}
}
return null;
}, [openContextFilePaths, gitStatus, root]);
const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => {
if (!gitStatus?.files) return null;
const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath;
const prefix = relativeDir ? `${relativeDir}/` : '';
let modified = 0, added = 0;
for (const f of gitStatus.files) {
if (f.path.startsWith(prefix)) {
if (f.index === 'M' || f.working_dir === 'M') modified++;
if (f.index === 'A' || f.working_dir === '?') added++;
}
}
return modified + added > 0 ? { modified, added } : null;
}, [gitStatus, root]);
// --- File operations ---
const handleOpenFile = React.useCallback(async (node: FileNode) => {
if (!root) return;
const openValidation = await validateContextFileOpen(files, node.path);
if (!openValidation.ok) {
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
return;
}
setSelectedPath(root, node.path);
addOpenPath(root, node.path);
openContextFile(root, node.path);
}, [addOpenPath, files, openContextFile, root, setSelectedPath]);
const toggleDirectory = React.useCallback(async (dirPath: string) => {
const normalized = normalizePath(dirPath);
if (!root) return;
toggleExpandedPath(root, normalized);
if (!loadedDirsRef.current.has(normalized)) {
await loadDirectory(normalized);
}
}, [loadDirectory, root, toggleExpandedPath]);
// --- Dialog submit (matching FilesView) ---
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
if (!dialogData || !activeDialog) return;
setIsDialogSubmitting(true);
const done = () => setIsDialogSubmitting(false);
const closeDialog = () => setActiveDialog(null);
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) {
toast.error('Filename is required');
done();
return;
}
if (!files.writeFile) {
toast.error('Write not supported');
done();
return;
}
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
await files.writeFile(newPath, '')
.then(async (result) => {
if (result.success) {
toast.success('File created');
await refreshRoot();
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) {
toast.error('Folder name is required');
done();
return;
}
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
await files.createDirectory(newPath)
.then(async (result) => {
if (result.success) {
toast.success('Folder created');
await refreshRoot();
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) {
toast.error('Name is required');
done();
return;
}
if (!files.rename) {
toast.error('Rename not supported');
done();
return;
}
const oldPath = dialogData.path;
const parentDir = oldPath.split('/').slice(0, -1).join('/');
const prefix = parentDir ? `${parentDir}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
await files.rename(oldPath, newPath)
.then(async (result) => {
if (result.success) {
toast.success('Renamed successfully');
await refreshRoot();
if (root) {
removeOpenPathsByPrefix(root, oldPath);
}
if (selectedPath === oldPath || (selectedPath && selectedPath.startsWith(`${oldPath}/`))) {
setSelectedPath(root, null);
}
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
if (activeDialog === 'delete') {
if (!files.delete) {
toast.error('Delete not supported');
done();
return;
}
await files.delete(dialogData.path)
.then(async (result) => {
if (result.success) {
toast.success('Deleted successfully');
await refreshRoot();
if (root) {
removeOpenPathsByPrefix(root, dialogData.path);
}
if (selectedPath === dialogData.path || (selectedPath && selectedPath.startsWith(dialogData.path + '/'))) {
setSelectedPath(root, null);
}
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.finally(done);
return;
}
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
// --- Tree rendering (matching FilesView with indent guides) ---
function renderTree(dirPath: string, depth: number): React.ReactNode {
const nodes = childrenByDir[dirPath] ?? [];
return nodes.map((node, index) => {
const isDir = node.type === 'directory';
const isExpanded = isDir && expandedPaths.includes(node.path);
const isActive = selectedPath === node.path;
const isLast = index === nodes.length - 1;
return (
<li key={node.path} className="relative">
{depth > 0 && (
<>
<span className="absolute top-3.5 left-[-12px] w-3 h-px bg-border/40" />
{isLast && (
<span className="absolute top-3.5 bottom-0 left-[-13px] w-[2px] bg-sidebar/50" />
)}
</>
)}
<FileRow
node={node}
isExpanded={isExpanded}
isActive={isActive}
status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined}
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
contextMenuPath={contextMenuPath}
setContextMenuPath={setContextMenuPath}
onSelect={handleOpenFile}
onToggle={toggleDirectory}
onRevealPath={handleRevealPath}
onOpenDialog={handleOpenDialog}
/>
{isDir && isExpanded && (
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
{renderTree(node.path, depth + 1)}
</ul>
)}
</li>
);
});
}
const hasTree = Boolean(root && childrenByDir[root]);
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-transparent">
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
<div className="relative min-w-0 flex-1">
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search files..."
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 ? (
<button
type="button"
aria-label="Clear search"
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<RiCloseLine className="h-4 w-4" />
</button>
) : null}
</div>
{canCreateFile && (
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New File"
>
<RiFileAddLine className="h-4 w-4" />
</Button>
)}
{canCreateFolder && (
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New Folder"
>
<RiFolderAddLine className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0" title="Refresh">
<RiRefreshLine className="h-4 w-4" />
</Button>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-2">
<ul className="flex flex-col">
{searching ? (
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Searching...
</li>
) : searchResults.length > 0 ? (
searchResults.map((node) => {
const isActive = selectedPath === node.path;
return (
<li key={node.path}>
<button
type="button"
onClick={() => handleOpenFile(node)}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
)}
title={node.path}
>
{getFileIcon(node.path, node.extension)}
<span
className="min-w-0 flex-1 truncate typography-meta"
style={{ direction: 'rtl', textAlign: 'left' }}
>
{node.relativePath ?? node.path}
</span>
</button>
</li>
);
})
) : hasTree && root ? (
renderTree(root, 0)
) : (
<li className="px-2 py-1 typography-meta text-muted-foreground">Loading...</li>
)}
</ul>
</ScrollableOverlay>
{/* CRUD dialogs (matching FilesView) */}
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{activeDialog === 'createFile' && 'Create File'}
{activeDialog === 'createFolder' && 'Create Folder'}
{activeDialog === 'rename' && 'Rename'}
{activeDialog === 'delete' && 'Delete'}
</DialogTitle>
<DialogDescription>
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
{activeDialog !== 'delete' && (
<div className="py-4">
<Input
value={dialogInputValue}
onChange={(e) => setDialogInputValue(e.target.value)}
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleDialogSubmit();
}
}}
autoFocus
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
Cancel
</Button>
<Button
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
onClick={() => void handleDialogSubmit()}
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
>
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
activeDialog === 'delete' ? 'Delete' : 'Confirm'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
};

View File

@@ -0,0 +1,731 @@
import React from 'react';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { ChatView, SettingsView } from '@/components/views';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence';
import type { UsageWindow } from '@/types';
import { RiAddLine, RiArrowLeftLine, RiRefreshLine, RiRobot2Line, RiSettings3Line, RiTimerLine } from '@remixicon/react';
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-';
try {
return new Date(timestamp).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
} catch {
return '-';
}
};
// Width threshold for mobile vs desktop layout in settings
const MOBILE_WIDTH_THRESHOLD = 550;
// Width threshold for expanded layout (sidebar + chat side by side)
const EXPANDED_LAYOUT_THRESHOLD = 1400;
// Sessions sidebar width in expanded layout
const SESSIONS_SIDEBAR_WIDTH = 280;
type VSCodeView = 'sessions' | 'chat' | 'settings';
export const VSCodeLayout: React.FC = () => {
const runtimeApis = useRuntimeAPIs();
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
const configured =
typeof window !== 'undefined'
? (window as unknown as { __VSCODE_CONFIG__?: { viewMode?: unknown } }).__VSCODE_CONFIG__?.viewMode
: null;
return configured === 'editor' ? 'editor' : 'sidebar';
}, []);
const initialSessionId = React.useMemo<string | null>(() => {
const configured =
typeof window !== 'undefined'
? (window as unknown as { __VSCODE_CONFIG__?: { initialSessionId?: unknown } }).__VSCODE_CONFIG__?.initialSessionId
: null;
if (typeof configured === 'string' && configured.trim().length > 0) {
return configured.trim();
}
return null;
}, []);
const hasAppliedInitialSession = React.useRef(false);
const bootDraftOpen = React.useMemo(() => {
try {
return Boolean(useSessionStore.getState().newSessionDraft?.open);
} catch {
return false;
}
}, []);
const [currentView, setCurrentView] = React.useState<VSCodeView>(() => (bootDraftOpen ? 'chat' : 'sessions'));
const [containerWidth, setContainerWidth] = React.useState<number>(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const activeSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
}, [currentSessionId, sessions]);
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
const isSyncingMessages = useSessionStore((state) => state.isSyncing);
const hasActiveSessionWork = useSessionStore((state) => {
const statuses = state.sessionStatus;
if (!statuses || statuses.size === 0) {
return false;
}
for (const status of statuses.values()) {
if (status?.type === 'busy' || status?.type === 'retry') {
return true;
}
}
return false;
});
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() => (typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
: 'connecting') || 'connecting'
);
const configInitialized = useConfigStore((state) => state.isInitialized);
const initializeConfig = useConfigStore((state) => state.initializeApp);
const loadSessions = useSessionStore((state) => state.loadSessions);
const loadMessages = useSessionStore((state) => state.loadMessages);
const messages = useSessionStore((state) => state.messages);
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
const lastBootstrapAttemptAt = React.useRef<number>(0);
// Navigate to chat when a session is selected
React.useEffect(() => {
if (currentSessionId) {
setCurrentView('chat');
}
}, [currentSessionId]);
React.useEffect(() => {
const vscodeApi = runtimeApis.vscode;
if (!vscodeApi) {
return;
}
void vscodeApi.executeCommand('openchamber.setActiveSession', currentSessionId, activeSessionTitle);
}, [activeSessionTitle, currentSessionId, runtimeApis.vscode]);
// If the active session disappears (e.g., deleted), go back to sessions list
React.useEffect(() => {
if (viewMode === 'editor') {
return;
}
if (currentView !== 'chat') {
return;
}
if (currentSessionId || newSessionDraftOpen || isSyncingMessages || hasActiveSessionWork) {
return;
}
const timeoutId = window.setTimeout(() => {
const state = useSessionStore.getState();
const stillNoSession = !state.currentSessionId;
const draftStillClosed = !state.newSessionDraft?.open;
const stillSyncing = state.isSyncing;
const stillActiveWork = (() => {
const statuses = state.sessionStatus;
if (!statuses || statuses.size === 0) return false;
for (const status of statuses.values()) {
if (status?.type === 'busy' || status?.type === 'retry') return true;
}
return false;
})();
if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) {
setCurrentView('sessions');
}
}, 900);
return () => {
window.clearTimeout(timeoutId);
};
}, [currentSessionId, newSessionDraftOpen, currentView, viewMode, isSyncingMessages, hasActiveSessionWork]);
const handleBackToSessions = React.useCallback(() => {
setCurrentView('sessions');
}, []);
// Listen for connection status changes
React.useEffect(() => {
// Catch up with the latest status even if the extension posted the connection message
// before this component registered the event listener.
const current =
(typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status
: undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined;
if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') {
setConnectionStatus(current);
}
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
const status = detail?.status;
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
setConnectionStatus(status);
}
};
window.addEventListener('openchamber:connection-status', handler as EventListener);
return () => window.removeEventListener('openchamber:connection-status', handler as EventListener);
}, []);
// Listen for navigation events from VS Code extension title bar buttons
React.useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ view?: string }>).detail;
const view = detail?.view;
if (view === 'settings') {
setCurrentView('settings');
} else if (view === 'chat') {
setCurrentView('chat');
} else if (view === 'sessions') {
setCurrentView('sessions');
}
};
window.addEventListener('openchamber:navigate', handler as EventListener);
return () => window.removeEventListener('openchamber:navigate', handler as EventListener);
}, []);
// Bootstrap config and sessions when connected
React.useEffect(() => {
const runBootstrap = async () => {
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
return;
}
const now = Date.now();
if (now - lastBootstrapAttemptAt.current < 750) {
return;
}
lastBootstrapAttemptAt.current = now;
setIsInitializing(true);
try {
const debugEnabled = (() => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem('openchamber_stream_debug') === '1';
} catch {
return false;
}
})();
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] attempt', { configInitialized });
if (!configInitialized) {
await initializeConfig();
}
const configStore = useConfigStore.getState();
// Keep trying to fetch core datasets on cold starts.
if (configStore.isConnected) {
if (configStore.providers.length === 0) {
await configStore.loadProviders();
}
if (configStore.agents.length === 0) {
await configStore.loadAgents();
}
}
const configState = useConfigStore.getState();
// If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries.
// Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts.
if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) {
return;
}
await loadSessions();
const sessionsError = useSessionStore.getState().error;
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', {
providers: configState.providers.length,
agents: configState.agents.length,
sessions: useSessionStore.getState().sessions.length,
sessionsError,
});
if (typeof sessionsError === 'string' && sessionsError.length > 0) {
return;
}
setHasInitializedOnce(true);
} catch {
// Ignore bootstrap failures
} finally {
setIsInitializing(false);
}
};
void runBootstrap();
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
React.useEffect(() => {
if (viewMode !== 'editor') {
return;
}
if (hasAppliedInitialSession.current) {
return;
}
if (!hasInitializedOnce || connectionStatus !== 'connected') {
return;
}
// No initialSessionId means open a new session draft
if (!initialSessionId) {
hasAppliedInitialSession.current = true;
openNewSessionDraft();
return;
}
if (!sessions.some((session) => session.id === initialSessionId)) {
return;
}
hasAppliedInitialSession.current = true;
void useSessionStore.getState().setCurrentSession(initialSessionId);
}, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]);
// Hydrate messages when viewing chat
React.useEffect(() => {
const hydrateMessages = async () => {
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) {
return;
}
if (!currentSessionId) {
return;
}
const hasMessagesEntry = messages.has(currentSessionId);
if (hasMessagesEntry) {
return;
}
try {
await loadMessages(currentSessionId);
} catch {
/* ignored */
}
};
void hydrateMessages();
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]);
// Track container width for responsive settings layout
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(container);
// Set initial width
setContainerWidth(container.clientWidth);
return () => observer.disconnect();
}, []);
const usesMobileLayout = containerWidth > 0 && containerWidth < MOBILE_WIDTH_THRESHOLD;
const usesExpandedLayout = containerWidth >= EXPANDED_LAYOUT_THRESHOLD;
// In expanded layout, always show chat (with sidebar alongside)
// Navigate to chat automatically when expanded layout is enabled and we're on sessions view
React.useEffect(() => {
if (usesExpandedLayout && currentView === 'sessions' && viewMode === 'sidebar') {
setCurrentView('chat');
}
}, [usesExpandedLayout, currentView, viewMode]);
return (
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
{viewMode === 'editor' ? (
// Editor mode: just chat, no sidebar
<div className="flex flex-col h-full">
<VSCodeHeader
title={sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
showMcp
showContextUsage
/>
<div className="flex-1 overflow-hidden">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</div>
</div>
) : currentView === 'settings' ? (
// Settings view
<SettingsView
onClose={() => setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
forceMobile={usesMobileLayout}
/>
) : usesExpandedLayout ? (
// Expanded layout: sessions sidebar + chat side by side
<div className="flex h-full">
{/* Sessions sidebar */}
<div
className="h-full border-r border-border overflow-hidden flex-shrink-0"
style={{ width: SESSIONS_SIDEBAR_WIDTH }}
>
<SessionSidebar
mobileVariant
allowReselect
hideDirectoryControls
showOnlyMainWorkspace
/>
</div>
{/* Chat content */}
<div className="flex-1 flex flex-col min-w-0">
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
showMcp
showContextUsage
/>
<div className="flex-1 overflow-hidden">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</div>
</div>
</div>
) : (
// Compact layout: drill-down between sessions list and chat
<>
{/* Sessions list view */}
<div className={cn('flex flex-col h-full', currentView !== 'sessions' && 'hidden')}>
<VSCodeHeader
title="Sessions"
/>
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
showOnlyMainWorkspace
/>
</div>
</div>
{/* Chat view */}
<div className={cn('flex flex-col h-full', currentView !== 'chat' && 'hidden')}>
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
showBack
onBack={handleBackToSessions}
showMcp
showContextUsage
showRateLimits
/>
<div className="flex-1 overflow-hidden">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</div>
</div>
</>
)}
</div>
);
};
interface VSCodeHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
onNewSession?: () => void;
onSettings?: () => void;
onAgentManager?: () => void;
showMcp?: boolean;
showContextUsage?: boolean;
showRateLimits?: boolean;
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => {
const { getCurrentModel } = useConfigStore();
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const quotaResults = useQuotaStore((state) => state.results);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated);
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode);
useQuotaAutoRefresh();
React.useEffect(() => {
void loadQuotaSettings();
}, [loadQuotaSettings]);
const currentModel = getCurrentModel();
const limits = (currentModel?.limit && typeof currentModel.limit === 'object'
? currentModel.limit
: null) as { context?: number; output?: number } | null;
const contextLimit = typeof limits?.context === 'number' ? limits.context : 0;
const outputLimit = typeof limits?.output === 'number' ? limits.output : 0;
const contextUsage = getContextUsage(contextLimit, outputLimit);
const rateLimitGroups = React.useMemo(() => {
const groups: Array<{
providerId: string;
providerName: string;
entries: Array<[string, UsageWindow]>;
error?: string;
}> = [];
for (const provider of QUOTA_PROVIDERS) {
if (!dropdownProviderIds.includes(provider.id)) {
continue;
}
const result = quotaResults.find((entry) => entry.providerId === provider.id);
const windows = (result?.usage?.windows ?? {}) as Record<string, UsageWindow>;
const entries = Object.entries(windows);
const error = (result && !result.ok && result.configured) ? result.error : undefined;
if (entries.length > 0 || error) {
groups.push({ providerId: provider.id, providerName: provider.name, entries, error });
}
}
return groups;
}, [dropdownProviderIds, quotaResults]);
const hasRateLimits = rateLimitGroups.length > 0;
const handleDisplayModeChange = React.useCallback(async (mode: 'usage' | 'remaining') => {
setQuotaDisplayMode(mode);
try {
await updateDesktopSettings({ usageDisplayMode: mode });
} catch (error) {
console.warn('Failed to update usage display mode:', error);
}
}, [setQuotaDisplayMode]);
return (
<div className="flex items-center gap-1.5 pl-1 pr-2 py-1 border-b border-border bg-background shrink-0">
{showBack && onBack && (
<button
onClick={onBack}
className="inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Back to sessions"
>
<RiArrowLeftLine className="h-5 w-5" />
</button>
)}
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
{onNewSession && (
<button
onClick={onNewSession}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="New session"
>
<RiAddLine className="h-5 w-5" />
</button>
)}
{onAgentManager && (
<button
onClick={onAgentManager}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Open Agent Manager"
>
<RiRobot2Line className="h-5 w-5" />
</button>
)}
{showMcp && (
<McpDropdown
headerIconButtonClass="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
/>
)}
{showRateLimits && (
<DropdownMenu
onOpenChange={(open) => {
if (open && quotaResults.length === 0) {
fetchAllQuotas();
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Rate limits"
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
disabled={isQuotaLoading}
>
<RiTimerLine className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 max-h-[70vh] overflow-y-auto overflow-x-hidden bg-[var(--surface-elevated)] p-0"
>
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)]">
<DropdownMenuLabel className="flex items-center justify-between gap-3 typography-ui-header font-semibold text-foreground">
<span>Rate limits</span>
<div className="flex items-center gap-1">
<div className="flex items-center rounded-md border border-[var(--interactive-border)] p-0.5">
<button
type="button"
className={
`px-2 py-0.5 rounded-sm typography-micro text-[10px] transition-colors ${
quotaDisplayMode === 'usage'
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'text-muted-foreground hover:text-foreground'
}`
}
onClick={() => void handleDisplayModeChange('usage')}
aria-label="Show used quota"
>
Used
</button>
<button
type="button"
className={
`px-2 py-0.5 rounded-sm typography-micro text-[10px] transition-colors ${
quotaDisplayMode === 'remaining'
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'text-muted-foreground hover:text-foreground'
}`
}
onClick={() => void handleDisplayModeChange('remaining')}
aria-label="Show remaining quota"
>
Remaining
</button>
</div>
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={() => fetchAllQuotas()}
disabled={isQuotaLoading}
aria-label="Refresh rate limits"
>
<RiRefreshLine className="h-4 w-4" />
</button>
</div>
</DropdownMenuLabel>
</div>
<div className="border-b border-[var(--interactive-border)] px-2 pb-2 typography-micro text-muted-foreground text-[10px]">
Last updated {formatTime(quotaLastUpdated)}
</div>
{!hasRateLimits && (
<DropdownMenuItem className="cursor-default" onSelect={(event) => event.preventDefault()}>
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
</DropdownMenuItem>
)}
{rateLimitGroups.map((group, index) => (
<React.Fragment key={group.providerId}>
<DropdownMenuLabel className="flex items-center gap-2 bg-[var(--surface-elevated)] typography-ui-label text-foreground">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
{group.providerName}
</DropdownMenuLabel>
{group.entries.length === 0 ? (
<DropdownMenuItem
key={`${group.providerId}-empty`}
className="cursor-default"
onSelect={(event) => event.preventDefault()}
>
<span className="typography-ui-label text-muted-foreground">
{group.error ?? 'No rate limits reported.'}
</span>
</DropdownMenuItem>
) : (
group.entries.map(([label, window]) => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
const expectedMarker = paceInfo?.dailyAllocationPercent != null
? (quotaDisplayMode === 'remaining'
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
: null;
return (
<DropdownMenuItem
key={`${group.providerId}-${label}`}
className="cursor-default items-start"
onSelect={(event) => event.preventDefault()}
>
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{formatWindowLabel(label)}</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</span>
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && (
<div className="mt-0.5">
<PaceIndicator paceInfo={paceInfo} compact />
</div>
)}
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
<span>{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}</span>
</span>
</span>
</DropdownMenuItem>
);
})
)}
{index < rateLimitGroups.length - 1 && <DropdownMenuSeparator />}
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{onSettings && (
<button
onClick={onSettings}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Settings"
>
<RiSettings3Line className="h-5 w-5" />
</button>
)}
{showContextUsage && contextUsage && contextUsage.totalTokens > 0 && (
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
)}
</div>
);
};