feat(ui): polish chat and git workflows with mobile UX and reliability fixes (#569)
* feat: add chat option for user message rendering mode * feat: add chat option to toggle sticky user header * feat(ui): overhaul context panel with reusable tabs and embedded session chat Enable parallel context workflows with persistent tabbed views and isolated session chat while reducing resize and background runtime overhead. * feat: polish context panel and git sidebar tabs Refined context panel tab behavior and visuals for smoother switching and resizing Reused the new tabs component in right sidebar and git sidebar with fit layout Improved git section spacing, selection controls, and bulk revert confirmation flow * feat: open diff files in editor at changed lines Add edit actions in diff views to open files at the first changed line Support per-file open-in-editor from All Files headers and icon-only action in single-file view Improve file jump UX with load-aware navigation and reduced visual blink during line targeting * fix: stabilize pill tabs and prevent git commit pathspec failures Unified sortable tab variants to match animated styling behavior with responsive spacing and cleaner sidebar chrome Fixed active tab pill measurement so size/position recalculates correctly when dropdowns reopen Commit API now filters stale file paths before staging to avoid pathspec errors on deleted files * fix: align user message action row spacing and hover behavior * fix: persist user message view preferences in settings Save plain-text and sticky-header toggles to settings.json when changed Restore both chat display preferences from settings.json on startup Validate and accept both preference fields in the settings API * fix: improve git and sidebar tab layout on mobile * fix: refine mobile user message action row spacing Show mobile user-message actions in a consistent external row for sticky and non-sticky modes Tune button row height and vertical position to match both mobile variants Reduce sticky-header gradient tail and tighten assistant gap after user messages * fix: improve chat action hover zones and mobile top shadow logic Expand desktop trigger area so user action buttons reveal across the full row Add sticky-header phantom hover row so inline actions appear from the whole button lane Hide chat top scroll shadow on mobile only when sticky user headers are enabled * fix: remove commit message input scrollbar flicker Added optional scrollbar class support to shared textarea wrapper. Disabled overlay scrollbar for Git commit message input. Kept auto-resize behavior while preventing one-line empty-state micro-scroll. * feat: make model provider groups collapsible in selector Add collapsible provider headers in the chat model dropdown Persist expanded/collapsed provider state across sessions Refine provider header UX with inline chevrons and no hover highlight * feat: arrange chat settings into a compact two-column layout Places User Message Rendering next to Mermaid Rendering. Places Diff Layout next to Diff View Mode. Reduces right-column spacing to better match other settings sections. * fix: show worktree branch edit controls in draft sessions Detect worktree mode from current directory when session metadata is not yet bound Enable immediate branch rename UI in Git sidebar without session switching * feat: add beta badge to side panel menu action
This commit is contained in:
committed by
GitHub
parent
73e533a315
commit
b4cd16f55b
@@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/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';
|
||||
@@ -12,6 +15,7 @@ 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 '';
|
||||
@@ -52,23 +56,133 @@ const getRelativePathLabel = (filePath: string | null, directory: string): strin
|
||||
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 isOpen = Boolean(panelState?.isOpen && panelState?.mode);
|
||||
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(() => {
|
||||
@@ -85,39 +199,73 @@ export const ContextPanel: React.FC = () => {
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [isOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing || !directoryKey) {
|
||||
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = startXRef.current - event.clientX;
|
||||
setContextPanelWidth(directoryKey, startWidthRef.current + delta);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [directoryKey, isResizing, setContextPanelWidth]);
|
||||
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();
|
||||
}, [directoryKey, isExpanded, isOpen, width]);
|
||||
}, [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) {
|
||||
@@ -143,50 +291,190 @@ export const ContextPanel: React.FC = () => {
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null));
|
||||
React.useEffect(() => {
|
||||
if (!directoryKey || !activeTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : panelState?.mode === 'context' ? 'Context' : panelState?.mode === 'plan' ? 'Plan' : 'Panel';
|
||||
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : panelState?.mode === 'context' ? null : (panelState?.targetPath ?? null);
|
||||
const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory);
|
||||
if (activeTab.mode === 'file' && activeTab.targetPath) {
|
||||
setSelectedFilePath(directoryKey, activeTab.targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = panelState?.mode === 'diff'
|
||||
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
|
||||
: panelState?.mode === 'file'
|
||||
? <FilesView mode="editor-only" />
|
||||
: panelState?.mode === 'context'
|
||||
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 />
|
||||
: panelState?.mode === 'plan'
|
||||
: 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-10 items-center gap-2 border-b border-border/40 px-2.5">
|
||||
<div className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
||||
<span>{panelTitle}</span>
|
||||
{pathLabel ? <span className="ml-2 text-muted-foreground">{pathLabel}</span> : null}
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleExpanded}
|
||||
className="h-6 w-6 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-6 w-6 p-0"
|
||||
title="Close panel"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -196,13 +484,16 @@ export const ContextPanel: React.FC = () => {
|
||||
|
||||
const panelStyle: React.CSSProperties = isExpanded
|
||||
? {
|
||||
['--oc-context-panel-width' as string]: '100vw',
|
||||
['--oc-context-panel-width' as string]: '100%',
|
||||
width: '100%',
|
||||
minWidth: '100%',
|
||||
maxWidth: '100%',
|
||||
}
|
||||
: {
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
['--oc-context-panel-width' as string]: `${width}px`,
|
||||
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 (
|
||||
@@ -228,13 +519,57 @@ export const ContextPanel: React.FC = () => {
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize context panel"
|
||||
/>
|
||||
)}
|
||||
{header}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{content}</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
|
||||
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react';
|
||||
import { DiffIcon } from '@/components/icons/DiffIcon';
|
||||
@@ -121,6 +121,19 @@ const resolveTilde = (path: string, homeDir: string | null): string => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const getActiveContextMode = (panelState: {
|
||||
isOpen: boolean;
|
||||
activeTabId: string | null;
|
||||
tabs: Array<{ id: string; mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' }>;
|
||||
} | undefined): 'diff' | 'file' | 'context' | 'plan' | 'chat' | null => {
|
||||
if (!panelState?.isOpen || !Array.isArray(panelState.tabs) || panelState.tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTab = panelState.tabs.find((tab) => tab.id === panelState.activeTabId) ?? panelState.tabs[panelState.tabs.length - 1];
|
||||
return activeTab?.mode ?? null;
|
||||
};
|
||||
|
||||
interface TabConfig {
|
||||
id: MainTab;
|
||||
label: string;
|
||||
@@ -504,6 +517,28 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return { id: activeProject.id, path: activeProject.path };
|
||||
}, [activeProject]);
|
||||
|
||||
const lastProjectActionsContextRef = React.useRef<{
|
||||
projectRef: { id: string; path: string };
|
||||
directory: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeProjectRef || !actionDirectory) {
|
||||
return;
|
||||
}
|
||||
lastProjectActionsContextRef.current = {
|
||||
projectRef: activeProjectRef,
|
||||
directory: actionDirectory,
|
||||
};
|
||||
}, [actionDirectory, activeProjectRef]);
|
||||
|
||||
const projectActionsContext = React.useMemo(() => {
|
||||
if (activeProjectRef && actionDirectory) {
|
||||
return { projectRef: activeProjectRef, directory: actionDirectory };
|
||||
}
|
||||
return lastProjectActionsContextRef.current;
|
||||
}, [actionDirectory, activeProjectRef]);
|
||||
|
||||
|
||||
const [planTabAvailable, setPlanTabAvailable] = React.useState(false);
|
||||
const showPlanTab = planTabAvailable;
|
||||
@@ -644,7 +679,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
if (panelState?.isOpen && panelState.mode === 'context') {
|
||||
if (getActiveContextMode(panelState) === 'context') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
@@ -658,7 +693,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return Boolean(panelState?.isOpen && panelState.mode === 'context');
|
||||
return getActiveContextMode(panelState) === 'context';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
|
||||
const handleOpenContextPlan = React.useCallback(() => {
|
||||
@@ -668,7 +703,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
if (panelState?.isOpen && panelState.mode === 'plan') {
|
||||
if (getActiveContextMode(panelState) === 'plan') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
@@ -682,7 +717,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return Boolean(panelState?.isOpen && panelState.mode === 'plan');
|
||||
return getActiveContextMode(panelState) === 'plan';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
|
||||
const desktopHeaderIconButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
|
||||
@@ -818,6 +853,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return base;
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const servicesTabItems = React.useMemo(() => {
|
||||
return servicesTabs.map((tab) => ({
|
||||
id: tab.value,
|
||||
label: tab.label,
|
||||
icon: <tab.icon className="h-3.5 w-3.5" />,
|
||||
}));
|
||||
}, [servicesTabs]);
|
||||
|
||||
const quotaDisplayTabs = React.useMemo(() => {
|
||||
return [
|
||||
{ value: 'usage' as const, label: 'Used' },
|
||||
@@ -825,6 +868,17 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
];
|
||||
}, []);
|
||||
|
||||
const quotaDisplayTabItems = React.useMemo(() => {
|
||||
return quotaDisplayTabs.map((tab) => ({ id: tab.value, label: tab.label }));
|
||||
}, [quotaDisplayTabs]);
|
||||
|
||||
const mobileServicesTabItems = React.useMemo<SortableTabsStripItem[]>(() => {
|
||||
return [
|
||||
{ id: 'usage', label: 'Usage', icon: <RiTimerLine className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: 'MCP', icon: <RiCommandLine className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
|
||||
@@ -975,15 +1029,15 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</Tooltip>
|
||||
|
||||
{activeProjectLabel && (
|
||||
<div className="mr-3 min-w-0 max-w-[16rem] truncate typography-ui-label font-medium text-foreground">
|
||||
<div className="mr-3 min-w-0 max-w-[16rem] truncate pl-2 typography-ui-header text-[calc(var(--text-ui-header)+0.125rem)] font-medium text-foreground">
|
||||
{activeProjectLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProjectRef && actionDirectory && (
|
||||
{projectActionsContext && (
|
||||
<ProjectActionsButton
|
||||
projectRef={activeProjectRef}
|
||||
directory={actionDirectory}
|
||||
projectRef={projectActionsContext.projectRef}
|
||||
directory={projectActionsContext.directory}
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
@@ -1075,18 +1129,25 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
align="end"
|
||||
className="w-[min(30rem,calc(100vw-2rem))] max-h-[75vh] overflow-y-auto bg-[var(--surface-elevated)] p-0"
|
||||
>
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2">
|
||||
<AnimatedTabs<'instance' | 'usage' | 'mcp'>
|
||||
value={desktopServicesTab}
|
||||
onValueChange={(value) => {
|
||||
setDesktopServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
tabs={servicesTabs}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-px">
|
||||
<div className="h-9">
|
||||
<SortableTabsStrip
|
||||
items={servicesTabItems}
|
||||
activeId={desktopServicesTab}
|
||||
onSelect={(tabID) => {
|
||||
const value = tabID as 'instance' | 'usage' | 'mcp';
|
||||
setDesktopServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillInsetClassName="gap-0.5 px-px py-0"
|
||||
activePillButtonClassName="h-8"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDesktopApp && desktopServicesTab === 'instance' && (
|
||||
@@ -1113,13 +1174,17 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AnimatedTabs<'usage' | 'remaining'>
|
||||
value={quotaDisplayMode}
|
||||
onValueChange={handleDisplayModeChange}
|
||||
tabs={quotaDisplayTabs}
|
||||
size="sm"
|
||||
className="w-[10.5rem]"
|
||||
/>
|
||||
<div className="h-7 w-[10.5rem]">
|
||||
<SortableTabsStrip
|
||||
items={quotaDisplayTabItems}
|
||||
activeId={quotaDisplayMode}
|
||||
onSelect={(tabID) => handleDisplayModeChange(tabID as 'usage' | 'remaining')}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillInsetClassName="gap-0.5 px-px py-0"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -1526,10 +1591,10 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{activeProjectRef && actionDirectory && (
|
||||
{projectActionsContext && (
|
||||
<ProjectActionsButton
|
||||
projectRef={activeProjectRef}
|
||||
directory={actionDirectory}
|
||||
projectRef={projectActionsContext.projectRef}
|
||||
directory={projectActionsContext.directory}
|
||||
compact
|
||||
allowMobile
|
||||
className="h-9"
|
||||
@@ -1568,22 +1633,26 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
className="h-dvh w-[100vw] max-h-none rounded-none border-0 p-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full flex-col bg-[var(--surface-elevated)]">
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-3">
|
||||
<AnimatedTabs<'usage' | 'mcp'>
|
||||
value={mobileServicesTab}
|
||||
onValueChange={(value) => {
|
||||
setMobileServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
{ value: 'usage', label: 'Usage', icon: RiTimerLine },
|
||||
{ value: 'mcp', label: 'MCP', icon: RiCommandLine },
|
||||
]}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-px">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-0">
|
||||
<div className="h-10 min-w-0 flex-1">
|
||||
<SortableTabsStrip
|
||||
items={mobileServicesTabItems}
|
||||
activeId={mobileServicesTab}
|
||||
onSelect={(tabID) => {
|
||||
const value = tabID as 'usage' | 'mcp';
|
||||
setMobileServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillInsetClassName="gap-0.5 px-px py-0"
|
||||
activePillButtonClassName="h-8"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileRateLimitsOpen(false)}
|
||||
|
||||
@@ -76,7 +76,9 @@ export const MainLayout: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
const panelState = state.contextPanelByDirectory[directoryKey];
|
||||
return Boolean(panelState?.isOpen && panelState?.mode);
|
||||
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);
|
||||
|
||||
@@ -185,6 +185,7 @@ export const ProjectActionsButton = ({
|
||||
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 ?? '';
|
||||
@@ -224,22 +225,38 @@ export const ProjectActionsButton = ({
|
||||
|
||||
const loadActions = React.useCallback(async () => {
|
||||
if (!stableProjectRef) {
|
||||
setActions([]);
|
||||
setSelectedActionId(null);
|
||||
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(filtered[0]?.id ?? null);
|
||||
setSelectedActionId((current) => {
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (current && filtered.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return filtered[0]?.id ?? null;
|
||||
});
|
||||
} catch {
|
||||
setActions([]);
|
||||
setSelectedActionId(null);
|
||||
if (loadRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
// Keep last known actions while next project loads or transient fetch fails.
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (loadRequestIdRef.current === requestId) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [stableProjectRef]);
|
||||
|
||||
@@ -616,12 +633,10 @@ export const ProjectActionsButton = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
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:opacity-50',
|
||||
className
|
||||
)}
|
||||
aria-label="Add action"
|
||||
@@ -635,12 +650,10 @@ export const ProjectActionsButton = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
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',
|
||||
'disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={openProjectActionsSettings}
|
||||
@@ -673,7 +686,7 @@ export const ProjectActionsButton = ({
|
||||
'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:opacity-50',
|
||||
'disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
@@ -738,7 +751,7 @@ export const ProjectActionsButton = ({
|
||||
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:opacity-50'
|
||||
'transition-colors disabled:cursor-not-allowed'
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
>
|
||||
|
||||
@@ -16,33 +16,22 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) {
|
||||
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;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = startXRef.current - event.clientX;
|
||||
const nextWidth = Math.min(
|
||||
RIGHT_SIDEBAR_MAX_WIDTH,
|
||||
Math.max(RIGHT_SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
||||
);
|
||||
setRightSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [isResizing, setRightSidebarWidth]);
|
||||
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))
|
||||
@@ -52,23 +41,75 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
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: `${appliedWidth}px`,
|
||||
minWidth: `${appliedWidth}px`,
|
||||
maxWidth: `${appliedWidth}px`,
|
||||
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}
|
||||
@@ -80,6 +121,9 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize right panel"
|
||||
@@ -88,6 +132,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
<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}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { RiFolder3Line, RiGitBranchLine } from '@remixicon/react';
|
||||
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { GitView } from '@/components/views';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||
@@ -12,19 +12,29 @@ 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="border-b border-border/40 bg-transparent px-3 py-1.5">
|
||||
<AnimatedTabs<RightTab>
|
||||
value={rightSidebarTab}
|
||||
onValueChange={setRightSidebarTab}
|
||||
size="sm"
|
||||
collapseLabelsOnSmall
|
||||
collapseLabelsOnNarrow
|
||||
tabs={[
|
||||
{ value: 'git', label: 'Git', icon: RiGitBranchLine },
|
||||
{ value: 'files', label: 'Files', icon: RiFolder3Line },
|
||||
]}
|
||||
<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>
|
||||
|
||||
|
||||
@@ -18,33 +18,22 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile || !isResizing) {
|
||||
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;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = event.clientX - startXRef.current;
|
||||
const nextWidth = Math.min(
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
||||
);
|
||||
setSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [isMobile, isResizing, setSidebarWidth]);
|
||||
sidebar.style.setProperty('--oc-left-sidebar-width', `${nextWidth}px`);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile && isResizing) {
|
||||
@@ -52,6 +41,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
}
|
||||
}, [isMobile, isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) {
|
||||
resizingWidthRef.current = null;
|
||||
activeResizePointerIDRef.current = null;
|
||||
}
|
||||
}, [isResizing]);
|
||||
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
@@ -65,14 +61,58 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
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',
|
||||
@@ -80,9 +120,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
!isOpen && 'border-r-0'
|
||||
)}
|
||||
style={{
|
||||
width: `${appliedWidth}px`,
|
||||
minWidth: `${appliedWidth}px`,
|
||||
maxWidth: `${appliedWidth}px`,
|
||||
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}
|
||||
@@ -94,6 +135,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize left panel"
|
||||
@@ -102,9 +146,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
<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: `${appliedWidth}px`, overflowX: 'hidden' }}
|
||||
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user