Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
193
ui/src/components/layout/BottomTerminalDock.tsx
Normal file
193
ui/src/components/layout/BottomTerminalDock.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
575
ui/src/components/layout/ContextPanel.tsx
Normal file
575
ui/src/components/layout/ContextPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
607
ui/src/components/layout/ContextSidebarTab.tsx
Normal file
607
ui/src/components/layout/ContextSidebarTab.tsx
Normal 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>·</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>
|
||||
);
|
||||
};
|
||||
1932
ui/src/components/layout/Header.tsx
Normal file
1932
ui/src/components/layout/Header.tsx
Normal file
File diff suppressed because it is too large
Load Diff
838
ui/src/components/layout/MainLayout.tsx
Normal file
838
ui/src/components/layout/MainLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
919
ui/src/components/layout/NavRail.tsx
Normal file
919
ui/src/components/layout/NavRail.tsx
Normal 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 };
|
||||
818
ui/src/components/layout/ProjectActionsButton.tsx
Normal file
818
ui/src/components/layout/ProjectActionsButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
433
ui/src/components/layout/ProjectEditDialog.tsx
Normal file
433
ui/src/components/layout/ProjectEditDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
144
ui/src/components/layout/RightSidebar.tsx
Normal file
144
ui/src/components/layout/RightSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
46
ui/src/components/layout/RightSidebarTabs.tsx
Normal file
46
ui/src/components/layout/RightSidebarTabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
161
ui/src/components/layout/Sidebar.tsx
Normal file
161
ui/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
ui/src/components/layout/SidebarContextSummary.tsx
Normal file
61
ui/src/components/layout/SidebarContextSummary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
880
ui/src/components/layout/SidebarFilesTree.tsx
Normal file
880
ui/src/components/layout/SidebarFilesTree.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
731
ui/src/components/layout/VSCodeLayout.tsx
Normal file
731
ui/src/components/layout/VSCodeLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user