feat(ui): redesign workspace shell with context panel, tabbed sidebars, and faster diff UX (#433)

* feat: tabbed right sidebar, context panel, floating diff comments

* fix: auto-close left sidebar when context panel opens

- Increase default context panel width from 520 to 600 pixels
- Increase sidebar minimum width from 200 to 300 pixels
- Replace collapsible component with custom button in diff view

* refactoring: rework sidebars, tabs, and file tree layout

- Rewrite AnimatedTabs as segment-style with sliding indicator
- Upgrade SidebarFilesTree to match FilesView features (context menus,
  git status, file icons, CRUD dialogs, fuzzy search ranking)
- Restructure FilesView header: tabs row + actions row, remove breadcrumbs
- Show relative path in context panel header, track active tab
- Allow left sidebar to stay open alongside context panel
- Hide diff/files tabs from header on desktop (mobile-only)
- Move chevron after group name in session sidebar
- Compact tab heights in right sidebar and git view
- Size PreviewToggleButton to match other action buttons
- Remove directory loading spinner from folder icons

* feat: add project icon and color customization

- Enable users to assign custom icons to projects
- Allow users to choose accent colors for projects
- Stabilize repo status UI during project switching

* feat: add scroll fade indicators to editor tabs

* style: reduce spacing and icon sizes in header

* style: adjust tab component padding from uniform to vertical-horizontal

* feat: Add session state indicators to project tabs

* feat: Enhance session status handling and improve UI responsiveness

* fix: preserve upstream tracking on branch rename

* fix: improve initial remote selection for pull requests

- Uses saved remote name from previous session when available
- Selects remote based on tracking branch when possible
- Falls back to origin or first available remote

* perf(diff): faster highlight, stable stacked scroll

- split/unified Pierre worker pools; prefer shiki-wasm
- align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks
- harden stacked pin/align (cancel on user scroll/input); prevent overscroll
- make overlay scrollbar MutationObserver optional; disable for diff container

* feat: handle binary files in diff view

* fix: adjust project tabs layout and drag regions

* style: update drag overlay visual styling

* feat: enable number keys to switch projects in the sidebar

* fix: recognize octet-stream as text-based MIME type

* feat: add keyboard navigation to context panel

* feat: add session pinning to sidebar

- Pin important sessions to keep them at the top
- Pinned sessions persist across browser sessions

* refactor: move context usage display from chat input to header
This commit is contained in:
Bohdan Triapitsyn
2026-02-16 14:15:19 +02:00
committed by GitHub
parent 12606b9e53
commit 47c943b487
42 changed files with 4874 additions and 1163 deletions
+14 -11
View File
@@ -204,11 +204,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
const resolvedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined;
if (!resolvedAgent && !resolvedProvider && !resolvedModel && !resolvedVariant) {
return null;
}
return {
agentName: resolvedAgent,
providerId: resolvedProvider,
@@ -344,7 +344,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const variants = model?.variants;
return Boolean(variants && Object.keys(variants).length > 0);
}, [isUser, modelID, providerID, providers]);
const displayAgentName = useStickyDisplayValue<string>(agentName);
const displayProviderIDValue = useStickyDisplayValue<string>(providerID ?? undefined);
const displayModelName = useStickyDisplayValue<string>(modelName);
@@ -532,36 +532,36 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const shouldShowHeader = React.useMemo(() => {
if (isUser) return true;
// Use turn grouping context if available for more precise control
const headerMessageId = turnGroupingContext?.headerMessageId;
if (headerMessageId) {
// For turn grouping: only show header for the first assistant message in the turn
const isFirstAssistantInTurn = message.info.id === headerMessageId;
if (isFirstAssistantInTurn) {
// For completed messages, always show header (historical messages)
if (streamPhase === 'completed') {
return true;
}
// For streaming messages: show header when streaming starts and keep it visible
const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
const hasStartedStreaming = shouldShowHeaderRef.current;
// Update the ref when streaming starts
if (isCurrentlyStreaming && !hasStartedStreaming) {
shouldShowHeaderRef.current = true;
}
// Show header if streaming has started or is currently active
return hasStartedStreaming || isCurrentlyStreaming;
}
// For non-first assistant messages, don't show header
return false;
}
// Fallback to original logic when turn grouping is not available
if (!previousRole) return true;
return previousRole.isUser;
@@ -600,7 +600,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined;
const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined;
const assistantSummaryCandidate =
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
? turnGroupingContext.summaryBody
@@ -634,6 +634,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
if (!detail) {
return undefined;
}
if (errorName === 'SessionRetry') {
return `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``;
}
return `Opencode failed to send message with error:\n\`${detail}\``;
}, [isUser, message.info]);
@@ -1,5 +1,6 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useShallow } from 'zustand/react/shallow';
import ChatMessage from './ChatMessage';
import { PermissionCard } from './PermissionCard';
@@ -10,6 +11,7 @@ import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScro
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
import { useSessionStore } from '@/stores/useSessionStore';
interface ChatMessageEntry {
info: Message;
@@ -211,7 +213,7 @@ const MessageList: React.FC<MessageListProps> = ({
onMessageContentChange('permission');
}, [permissions, questions, onMessageContentChange]);
const displayMessages = React.useMemo(() => {
const baseDisplayMessages = React.useMemo(() => {
const seenIds = new Set<string>();
return messages
.filter((message) => {
@@ -238,6 +240,101 @@ const MessageList: React.FC<MessageListProps> = ({
});
}, [messages]);
const activeRetryStatus = useSessionStore(
useShallow((state) => {
const sessionId = state.currentSessionId;
if (!sessionId) return null;
const status = state.sessionStatus?.get(sessionId);
if (!status || status.type !== 'retry') return null;
const rawMessage = typeof status.message === 'string' ? status.message.trim() : '';
return {
sessionId,
message: rawMessage || 'Quota limit reached. Retrying automatically.',
confirmedAt: status.confirmedAt,
};
})
);
const displayMessages = React.useMemo(() => {
if (!activeRetryStatus) {
return baseDisplayMessages;
}
const retryError = {
name: 'SessionRetry',
message: activeRetryStatus.message,
data: { message: activeRetryStatus.message },
};
const resolveRole = (message: ChatMessageEntry): string | null => {
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
return (typeof info.clientRole === 'string' ? info.clientRole : null)
?? (typeof info.role === 'string' ? info.role : null)
?? null;
};
let lastUserIndex = -1;
for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) {
if (resolveRole(baseDisplayMessages[index]) === 'user') {
lastUserIndex = index;
break;
}
}
if (lastUserIndex < 0) {
return baseDisplayMessages;
}
// Prefer attaching retry error to the assistant message in the current turn (if one exists)
// to avoid rendering a separate header-only placeholder + error block.
let targetAssistantIndex = -1;
for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) {
if (resolveRole(baseDisplayMessages[index]) === 'assistant') {
targetAssistantIndex = index;
break;
}
}
if (targetAssistantIndex >= 0) {
const existing = baseDisplayMessages[targetAssistantIndex];
const existingInfo = existing.info as unknown as { error?: unknown };
if (existingInfo.error) {
return baseDisplayMessages;
}
return baseDisplayMessages.map((message, index) => {
if (index !== targetAssistantIndex) {
return message;
}
return {
...message,
info: {
...(message.info as unknown as Record<string, unknown>),
error: retryError,
} as unknown as Message,
};
});
}
const eventTime = typeof activeRetryStatus.confirmedAt === 'number' ? activeRetryStatus.confirmedAt : Date.now();
const syntheticId = `synthetic_retry_notice_${activeRetryStatus.sessionId}`;
const synthetic: ChatMessageEntry = {
info: {
id: syntheticId,
sessionID: activeRetryStatus.sessionId,
role: 'assistant',
time: { created: eventTime, completed: eventTime },
finish: 'stop',
error: retryError,
} as unknown as Message,
parts: [],
};
const next = baseDisplayMessages.slice();
next.splice(lastUserIndex + 1, 0, synthetic);
return next;
}, [activeRetryStatus, baseDisplayMessages]);
const { turns, ungroupedMessages } = React.useMemo(() => {
const groupedTurns = detectTurns(displayMessages);
const groupedMessageIds = new Set<string>();
@@ -0,0 +1,198 @@
import React from 'react';
import type { EditorView } from '@codemirror/view';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { InlineCommentCard } from './InlineCommentCard';
import { InlineCommentInput } from './InlineCommentInput';
type SelectedLineRange = { start: number; end: number };
type CommentPos = {
top: number;
flipUp: boolean;
};
const COMMENT_POPOVER_HEIGHT = 200;
function getLineTop(view: EditorView, wrapper: HTMLElement, lineNumber: number, position: 'top' | 'bottom'): number | undefined {
const lineCount = view.state.doc.lines;
if (lineNumber < 1 || lineNumber > lineCount) return undefined;
const line = view.state.doc.line(lineNumber);
const coords = view.coordsAtPos(line.from);
if (!coords) return undefined;
const wrapperRect = wrapper.getBoundingClientRect();
if (position === 'bottom') {
return coords.bottom - wrapperRect.top;
}
return coords.top - wrapperRect.top;
}
function shouldFlipUp(view: EditorView, endLine: number, scrollContainer: HTMLElement | null): boolean {
const lineCount = view.state.doc.lines;
if (endLine < 1 || endLine > lineCount) return false;
const line = view.state.doc.line(endLine);
const coords = view.coordsAtPos(line.from);
if (!coords) return false;
const viewportBottom = scrollContainer
? scrollContainer.getBoundingClientRect().bottom
: window.innerHeight;
return (coords.bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom;
}
function computePosition(
view: EditorView,
wrapper: HTMLElement,
scrollContainer: HTMLElement | null,
range: { start: number; end: number },
): CommentPos | undefined {
const flipUp = shouldFlipUp(view, range.end, scrollContainer);
const top = flipUp
? getLineTop(view, wrapper, range.start, 'top')
: getLineTop(view, wrapper, range.end, 'bottom');
if (top === undefined) return undefined;
return { top, flipUp };
}
type FloatingCommentsProps = {
editorView: EditorView | null;
wrapperRef: React.RefObject<HTMLElement | null>;
fileDrafts: InlineCommentDraft[];
editingDraftId: string | null;
commentText: string;
lineSelection: SelectedLineRange | null;
isDragging: boolean;
fileLabel: string;
onSaveComment: (text: string, range?: SelectedLineRange) => void;
onCancelComment: () => void;
onEditDraft: (draft: InlineCommentDraft) => void;
onDeleteDraft: (draft: InlineCommentDraft) => void;
};
export function useFloatingComments({
editorView,
wrapperRef,
fileDrafts,
editingDraftId,
commentText,
lineSelection,
isDragging,
fileLabel,
onSaveComment,
onCancelComment,
onEditDraft,
onDeleteDraft,
}: FloatingCommentsProps): React.ReactNode {
const [positions, setPositions] = React.useState<Record<string, CommentPos | undefined>>({});
const updatePositions = React.useCallback(() => {
const view = editorView;
const wrapper = wrapperRef.current;
if (!view || !wrapper) return;
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
const next: Record<string, CommentPos | undefined> = {};
for (const d of fileDrafts) {
next[d.id] = computePosition(view, wrapper, scrollContainer, {
start: d.startLine,
end: d.endLine,
});
}
if (lineSelection && !editingDraftId && !isDragging) {
next['__new__'] = computePosition(view, wrapper, scrollContainer, {
start: lineSelection.start,
end: lineSelection.end,
});
}
setPositions(next);
}, [editorView, wrapperRef, fileDrafts, editingDraftId, lineSelection, isDragging]);
React.useEffect(() => {
requestAnimationFrame(updatePositions);
}, [updatePositions]);
// Also update on scroll
React.useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
if (!scrollContainer) return;
const onScroll = () => requestAnimationFrame(updatePositions);
scrollContainer.addEventListener('scroll', onScroll, { passive: true });
return () => scrollContainer.removeEventListener('scroll', onScroll);
}, [wrapperRef, updatePositions]);
const popoverStyle = (flipUp: boolean): React.CSSProperties => flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
return (
<>
{fileDrafts.map((d) => {
const pos = positions[d.id];
if (!pos) return null;
if (d.id === editingDraftId) {
return (
<div
key={`edit-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={popoverStyle(pos.flipUp)}>
<InlineCommentInput
initialText={commentText}
fileLabel={fileLabel}
lineRange={{ start: d.startLine, end: d.endLine }}
isEditing={true}
onSave={onSaveComment}
onCancel={onCancelComment}
/>
</div>
</div>
);
}
return (
<div
key={`saved-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
>
<InlineCommentCard
draft={d}
onEdit={() => onEditDraft(d)}
onDelete={() => onDeleteDraft(d)}
/>
</div>
);
})}
{lineSelection && !editingDraftId && !isDragging && positions['__new__'] && (
<div
key="new-comment"
style={{ position: 'absolute', right: 24, top: positions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={popoverStyle(positions['__new__'].flipUp)}>
<InlineCommentInput
initialText={commentText}
fileLabel={fileLabel}
lineRange={lineSelection}
isEditing={false}
onSave={onSaveComment}
onCancel={onCancelComment}
/>
</div>
</div>
)}
</>
);
}
@@ -15,9 +15,10 @@ interface BottomTerminalDockProps {
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 [isFullscreen, setIsFullscreen] = React.useState(false);
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);
@@ -32,7 +33,6 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
React.useEffect(() => {
if (!isOpen) {
setIsFullscreen(false);
setFullscreenHeight(null);
setIsResizing(false);
}
@@ -118,14 +118,14 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
if (!isOpen) return;
if (isFullscreen) {
setIsFullscreen(false);
setBottomTerminalExpanded(false);
const restoreHeight = Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, previousHeightRef.current));
setBottomTerminalHeight(restoreHeight);
return;
}
previousHeightRef.current = standardHeight;
setIsFullscreen(true);
setBottomTerminalExpanded(true);
};
return (
@@ -0,0 +1,229 @@
import React from 'react';
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { DiffView, FilesView } from '@/components/views';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore';
const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
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;
};
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 toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
const isOpen = Boolean(panelState?.isOpen && panelState?.mode);
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 panelRef = React.useRef<HTMLElement | null>(null);
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]);
React.useEffect(() => {
if (!isResizing || !directoryKey) {
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]);
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
if (!isOpen || isExpanded || !directoryKey) {
return;
}
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
event.preventDefault();
}, [directoryKey, isExpanded, isOpen, width]);
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]);
const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null));
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : 'Panel';
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : (panelState?.targetPath ?? null);
const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory);
const content = panelState?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
: panelState?.mode === 'file'
? <FilesView mode="editor-only" />
: null;
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}
</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>
);
if (!isOpen) {
return null;
}
return (
<aside
ref={panelRef}
data-context-panel="true"
tabIndex={-1}
className={cn(
'flex min-h-0 flex-col overflow-hidden border-l border-border bg-background',
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={isExpanded
? undefined
: {
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
}}
>
{!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}
role="separator"
aria-orientation="vertical"
aria-label="Resize context panel"
/>
)}
{header}
<div className="min-h-0 flex-1 overflow-hidden">{content}</div>
</aside>
);
};
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,8 @@ import { Header } from './Header';
import { BottomTerminalDock } from './BottomTerminalDock';
import { Sidebar } from './Sidebar';
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';
@@ -15,11 +17,31 @@ import { MultiRunLauncher } from '@/components/multirun';
import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
import { cn } from '@/lib/utils';
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
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;
@@ -42,8 +64,19 @@ export const MainLayout: React.FC = () => {
} = 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];
return Boolean(panelState?.isOpen && panelState?.mode);
});
const setSidebarOpen = useUIStore((state) => state.setSidebarOpen);
const rightSidebarAutoClosedRef = React.useRef(false);
const bottomTerminalAutoClosedRef = React.useRef(false);
const leftSidebarAutoClosedByContextRef = React.useRef(false);
useEdgeSwipe({ enabled: true });
@@ -90,6 +123,22 @@ export const MainLayout: React.FC = () => {
};
}, []);
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;
@@ -508,22 +557,25 @@ export const MainLayout: React.FC = () => {
<Header />
<div className="flex flex-1 overflow-hidden">
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
<SessionSidebar />
<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">
<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 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>
)}
</main>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
<ContextPanel />
</div>
<RightSidebar isOpen={isRightSidebarOpen} isMobile={isMobile}>
<ErrorBoundary><GitView mode="sidebar" /></ErrorBoundary>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
@@ -0,0 +1,179 @@
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 { cn } from '@/lib/utils';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
interface ProjectEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectName: string;
projectPath: string;
initialIcon?: string | null;
initialColor?: string | null;
onSave: (data: { label: string; icon: string | null; color: string | null }) => void;
}
export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
open,
onOpenChange,
projectName,
projectPath,
initialIcon = null,
initialColor = null,
onSave,
}) => {
const [name, setName] = React.useState(projectName);
const [icon, setIcon] = React.useState<string | null>(initialIcon);
const [color, setColor] = React.useState<string | null>(initialColor);
React.useEffect(() => {
if (open) {
setName(projectName);
setIcon(initialIcon);
setColor(initialColor);
}
}, [open, projectName, initialIcon, initialColor]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) return;
onSave({ label: trimmed, icon, color });
onOpenChange(false);
};
const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit project</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-1">
{/* Name */}
<div className="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">
{projectPath}
</p>
</div>
{/* Color */}
<div className="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="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon
</label>
<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>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,36 @@
import React from 'react';
import { RiFolder3Line, RiGitBranchLine } from '@remixicon/react';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
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);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
<div className="border-b border-border/40 bg-background 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>
<div className="min-h-0 flex-1 overflow-hidden">
{rightSidebarTab === 'git' ? <GitView mode="sidebar" /> : <SidebarFilesTree />}
</div>
</div>
);
};
@@ -9,7 +9,7 @@ import { UpdateDialog } from '../ui/UpdateDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export const SIDEBAR_CONTENT_WIDTH = 264;
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_MIN_WIDTH = 300;
const SIDEBAR_MAX_WIDTH = 500;
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
@@ -0,0 +1,898 @@
import React from 'react';
import {
RiCloseLine,
RiCodeLine,
RiDeleteBinLine,
RiEditLine,
RiFileAddLine,
RiFileCopyLine,
RiFileImageLine,
RiFileTextLine,
RiFolder3Fill,
RiFolderAddLine,
RiFolderOpenFill,
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 { cn } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
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/');
};
// --- File icons (matching FilesView) ---
const CODE_EXTENSIONS = new Set([
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts',
'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl', 'stylus',
'vue', 'svelte', 'astro',
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd',
'py', 'pyw', 'pyx', 'pxd', 'pxi',
'rb', 'erb', 'rake', 'gemspec',
'php', 'phtml', 'php3', 'php4', 'php5', 'phps',
'java', 'kt', 'kts', 'scala', 'sc', 'groovy', 'gradle',
'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hxx', 'hh', 'm', 'mm',
'cs', 'fs', 'fsx', 'fsi',
'go', 'rs', 'swift', 'dart', 'lua',
'pl', 'pm', 'pod', 'r', 'R', 'rmd', 'jl',
'hs', 'lhs', 'ex', 'exs', 'erl', 'hrl',
'clj', 'cljs', 'cljc', 'edn',
'lisp', 'cl', 'el', 'scm', 'ss', 'rkt',
'ml', 'mli', 're', 'rei', 'nim', 'zig', 'v', 'cr',
'sql', 'psql', 'plsql', 'graphql', 'gql', 'sol',
'asm', 's', 'S', 'mk', 'nix', 'tf', 'tfvars', 'pp', 'ansible',
]);
const DATA_EXTENSIONS = new Set([
'json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'geojson',
'yaml', 'yml', 'toml',
'xml', 'xsl', 'xslt', 'xsd', 'dtd', 'plist',
'ini', 'cfg', 'conf', 'config', 'env', 'properties',
'csv', 'tsv', 'lock',
]);
const IMAGE_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns',
'bmp', 'tiff', 'tif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
'heic', 'heif', 'avif', 'jxl',
]);
const DOCUMENT_EXTENSIONS = new Set([
'md', 'mdx', 'markdown', 'mdown', 'mkd',
'txt', 'text', 'rtf', 'doc', 'docx', 'odt', 'pdf',
'rst', 'adoc', 'asciidoc', 'org', 'tex', 'latex', 'bib',
]);
const getFileIcon = (extension?: string): React.ReactNode => {
const ext = extension?.toLowerCase();
if (ext && CODE_EXTENSIONS.has(ext)) {
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-info)]" />;
}
if (ext && DATA_EXTENSIONS.has(ext)) {
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-warning)]" />;
}
if (ext && IMAGE_EXTENSIONS.has(ext)) {
return <RiFileImageLine className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]" />;
}
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
}
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
};
// --- 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;
};
contextMenuPath: string | null;
setContextMenuPath: (path: string | null) => void;
onSelect: (node: FileNode) => void;
onToggle: (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,
onOpenDialog,
}) => {
const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete } = permissions;
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) return;
event?.preventDefault();
setContextMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, 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.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) && (
<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 navigator.clipboard.writeText(node.path);
toast.success('Path copied');
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</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 expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? 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);
// 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 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);
try {
const respectGitignore = !showGitignored;
let entries: Array<{ name: string; path: string; isDirectory: boolean }>;
if (runtime.isDesktop) {
const result = await files.listDirectory(normalizedDir, { respectGitignore });
entries = result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
}));
} else {
const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore });
entries = result.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
}));
}
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]);
// --- Fuzzy search scoring (matching FilesView) ---
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) return 0;
const c = candidate.toLowerCase();
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i += 1) {
const ch = q[i];
if (!ch || ch === ' ') continue;
const idx = c.indexOf(ch, lastIndex + 1);
if (idx === -1) return null;
const gap = idx - lastIndex - 1;
if (gap === 0) {
consecutive += 1;
} else {
consecutive = 0;
}
score += 10;
score += Math.max(0, 18 - idx);
score -= Math.max(0, gap);
if (idx === 0) {
score += 12;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
score += 10;
}
}
score += consecutive > 0 ? 12 : 0;
lastIndex = idx;
}
score += Math.max(0, 24 - Math.round(c.length / 3));
return score;
}, []);
React.useEffect(() => {
if (!currentDirectory) {
setSearchResults([]);
setSearching(false);
return;
}
const trimmedQuery = debouncedSearchQuery.trim();
if (!trimmedQuery) {
setSearchResults([]);
setSearching(false);
return;
}
const normalizedQueryLower = trimmedQuery.toLowerCase();
let cancelled = false;
setSearching(true);
searchFiles(currentDirectory, trimmedQuery, 150, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
})
.then((hits) => {
if (cancelled) return;
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
const ranked = filtered
.map((hit) => {
const label = hit.relativePath || hit.name || hit.path;
const score = fuzzyScore(normalizedQueryLower, label);
return score === null ? null : { hit, score, labelLength: label.length };
})
.filter(Boolean) as Array<{ hit: typeof hits[0]; score: number; labelLength: number }>;
ranked.sort((a, b) => (
b.score - a.score
|| a.labelLength - b.labelLength
|| a.hit.path.localeCompare(b.hit.path)
));
const mapped: FileNode[] = ranked.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, fuzzyScore, searchFiles, showHidden, showGitignored]);
// --- Git status helpers (matching FilesView) ---
const getFileStatus = React.useCallback((path: string): FileStatus | null => {
if (openPaths.includes(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;
}, [openPaths, 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((node: FileNode) => {
if (!root) return;
setSelectedPath(root, node.path);
addOpenPath(root, node.path);
openContextFile(root, node.path);
}, [addOpenPath, 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);
try {
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) throw new Error('Filename is required');
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (!files.writeFile) throw new Error('Write not supported');
const result = await files.writeFile(newPath, '');
if (result.success) {
toast.success('File created');
await refreshRoot();
}
} else if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) throw new Error('Folder name is required');
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
const result = await files.createDirectory(newPath);
if (result.success) {
toast.success('Folder created');
await refreshRoot();
}
} else if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) throw new Error('Name is required');
const oldPath = dialogData.path;
const parentDir = oldPath.split('/').slice(0, -1).join('/');
const prefix = parentDir ? `${parentDir}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (files.rename) {
const result = await files.rename(oldPath, newPath);
if (result.success) {
toast.success('Renamed successfully');
await refreshRoot();
if (root) {
removeOpenPathsByPrefix(root, oldPath);
}
if (selectedPath === oldPath || (selectedPath && selectedPath.startsWith(`${oldPath}/`))) {
setSelectedPath(root, null);
}
}
} else {
toast.error('Rename not supported');
}
} else if (activeDialog === 'delete') {
if (files.delete) {
const result = await files.delete(dialogData.path);
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);
}
}
} else {
toast.error('Delete not supported');
}
}
setActiveDialog(null);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Operation failed');
} finally {
setIsDialogSubmitting(false);
}
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
// --- Tree rendering (matching FilesView with indent guides) ---
const renderTree = React.useCallback((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-background" />
)}
</>
)}
<FileRow
node={node}
isExpanded={isExpanded}
isActive={isActive}
status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined}
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete }}
contextMenuPath={contextMenuPath}
setContextMenuPath={setContextMenuPath}
onSelect={handleOpenFile}
onToggle={toggleDirectory}
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>
);
});
}, [childrenByDir, expandedPaths, handleOpenFile, selectedPath, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, getFileStatus, getFolderBadge]);
const hasTree = Boolean(root && childrenByDir[root]);
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<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.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>
);
};
@@ -17,10 +17,17 @@ import {
RiLoader4Line,
RiPencilLine,
RiSearchLine,
RiSplitCellsHorizontal,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
import type { WorktreeMetadata } from '@/types/worktree';
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { sessionEvents } from '@/lib/sessionEvents';
import { useSessionStore } from '@/stores/useSessionStore';
export interface BranchPickerProject {
id: string;
@@ -38,13 +45,35 @@ interface BranchPickerDialogProps {
const displayProjectName = (project: BranchPickerProject): string =>
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
const normalizeBranchName = (value: string | null | undefined): string => {
return String(value || '')
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^heads\//, '')
.replace(/^remotes\//, '');
};
const normalizePath = (value: string | null | undefined): string => {
const raw = String(value || '').trim().replace(/\\/g, '/');
if (!raw) {
return '';
}
if (raw === '/') {
return '/';
}
return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
};
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
const sessions = useSessionStore((state) => state.sessions);
const [searchQuery, setSearchQuery] = React.useState('');
const [branches, setBranches] = React.useState<GitBranch | null>(null);
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
const [rootBranchName, setRootBranchName] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState<string | null>(null);
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
@@ -57,16 +86,19 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
setLoading(true);
setError(null);
try {
const [b, w] = await Promise.all([
const [b, w, rootBranch] = await Promise.all([
getGitBranches(project.path),
git.worktree.list(project.path),
getRootBranch(project.path).catch(() => null),
]);
setBranches(b);
setWorktrees(w);
setRootBranchName(rootBranch);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setBranches(null);
setWorktrees([]);
setRootBranchName(null);
} finally {
setLoading(false);
}
@@ -80,6 +112,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
setEditingBranch(null);
setEditValue('');
setRenamingBranchKey(null);
setCreatingWorktreeBranch(null);
return;
}
void refresh();
@@ -161,7 +194,105 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
}
}, [project, refresh, forceDeleteBranch]);
const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean));
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
if (!project) {
return;
}
setCreatingWorktreeBranch(branchName);
try {
const setupCommands = await getWorktreeSetupCommands({
id: project.id,
path: project.path,
});
await createWorktreeWithDefaults(
{
id: project.id,
path: project.path,
},
{
preferredName: branchName,
mode: 'existing',
existingBranch: branchName,
branchName,
worktreeName: branchName,
setupCommands,
}
);
await refresh();
toast.success('Worktree created', { description: branchName });
} catch (err) {
toast.error('Failed to create worktree', {
description: err instanceof Error ? err.message : 'Create worktree failed',
});
} finally {
setCreatingWorktreeBranch(null);
}
}, [project, refresh]);
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
if (!project || !worktree) {
return;
}
const normalizedWorktreePath = normalizePath(worktree.path);
const directSessions = sessions.filter((session) => {
const sessionPath = normalizePath(session.directory ?? null);
return Boolean(sessionPath) && sessionPath === normalizedWorktreePath;
});
const directSessionIds = new Set(directSessions.map((session) => session.id));
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
const subsessions = sessions.filter((session) => {
const parentID = (session as { parentID?: string | null }).parentID;
if (!parentID) {
return false;
}
return parentIds.has(parentID);
});
if (subsessions.length === 0) {
return [];
}
const subsessionIds = new Set(subsessions.map((session) => session.id));
return [...subsessions, ...findSubsessions(subsessionIds)];
};
const allSubsessions = findSubsessions(directSessionIds);
const seenIds = new Set<string>();
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
if (seenIds.has(session.id)) {
return false;
}
seenIds.add(session.id);
return true;
});
const normalizedBranch = normalizeBranchName(worktree.branch);
const worktreeMetadata: WorktreeMetadata = {
source: 'sdk',
name: worktree.name,
path: worktree.path,
projectDirectory: project.path,
branch: normalizedBranch,
label: normalizedBranch || worktree.name,
};
sessionEvents.requestDelete({
sessions: allSessions,
mode: 'worktree',
worktree: worktreeMetadata,
});
}, [project, sessions]);
const worktreeByBranch = new Map<string, GitWorktreeInfo>();
for (const worktree of worktrees) {
const branchName = normalizeBranchName(worktree.branch);
if (branchName && !worktreeByBranch.has(branchName)) {
worktreeByBranch.set(branchName, worktree);
}
}
const normalizedRootBranch = normalizeBranchName(rootBranchName);
const allBranches = branches?.all || [];
const filteredBranches = filterBranches(allBranches, searchQuery);
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
@@ -204,16 +335,34 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
) : (
localBranches.map((branchName) => {
const details = branches?.branches[branchName];
const normalizedBranchName = normalizeBranchName(branchName);
const isCurrent = Boolean(details?.current);
const isDeleting = deletingBranch === branchName;
const isRenaming = renamingBranchKey === branchName;
const hasAttachedWorktree = worktreeBranches.has(branchName);
const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null;
const hasAttachedWorktree = Boolean(attachedWorktree);
const isProjectRootBranch = Boolean(
normalizedBranchName &&
normalizedRootBranch &&
normalizedBranchName === normalizedRootBranch
);
const isEditing = editingBranch === branchName;
const isConfirming = confirmingDelete === branchName;
const isForceDelete = forceDeleteBranch === branchName;
const isCreatingWorktree = creatingWorktreeBranch === branchName;
const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing);
const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing);
const disableCreateWorktree = Boolean(
hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing
);
const disableDelete = Boolean(
isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
);
const disableRename = Boolean(
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
);
const disableWorktreeDelete = Boolean(
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree
);
return (
<div
@@ -258,7 +407,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
{isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
current
HEAD
</span>
)}
@@ -284,6 +433,27 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
{!isEditing && !isConfirming ? (
<div className="flex items-center gap-1 flex-shrink-0">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void handleCreateWorktreeForBranch(branchName)}
disabled={disableCreateWorktree}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Create worktree"
>
{isCreatingWorktree ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiSplitCellsHorizontal className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{hasAttachedWorktree ? 'Worktree already exists' : 'Create worktree'}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
@@ -297,7 +467,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
</button>
</TooltipTrigger>
<TooltipContent side="left">
{hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'}
{isProjectRootBranch ? 'Rename disabled for root branch' : 'Rename'}
</TooltipContent>
</Tooltip>
@@ -305,10 +475,16 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setConfirmingDelete(branchName)}
disabled={disableDelete}
onClick={() => {
if (hasAttachedWorktree) {
handleRemoveWorktree(attachedWorktree);
return;
}
setConfirmingDelete(branchName);
}}
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label="Delete"
aria-label={hasAttachedWorktree ? 'Delete worktree' : 'Delete'}
>
{isDeleting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -318,11 +494,15 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
</button>
</TooltipTrigger>
<TooltipContent side="left">
{isCurrent
? 'Delete (current branch)'
: hasAttachedWorktree
? 'Delete (remove worktree first)'
: 'Delete'}
{hasAttachedWorktree
? isProjectRootBranch
? 'Delete worktree (root branch protected)'
: 'Delete worktree'
: isCurrent
? 'Delete (current branch)'
: isProjectRootBranch
? 'Delete disabled for root branch'
: 'Delete'}
</TooltipContent>
</Tooltip>
</div>
@@ -354,7 +534,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
</div>
) : null}
{!isEditing && isConfirming ? (
{!isEditing && isConfirming && !hasAttachedWorktree ? (
<div className="flex items-center gap-1 flex-shrink-0">
<span className={cn(
'text-xs mr-1',
@@ -43,6 +43,7 @@ import {
RiFolderAddLine,
RiGitBranchLine,
RiGitPullRequestLine,
RiGitRepositoryLine,
RiStickyNoteLine,
RiLinkUnlinkM,
@@ -50,8 +51,10 @@ import {
RiMore2Line,
RiPencilAiLine,
RiPushpinLine,
RiShare2Line,
RiShieldLine,
RiUnpinLine,
} from '@remixicon/react';
import { sessionEvents } from '@/lib/sessionEvents';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
@@ -74,6 +77,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
import { BranchPickerDialog } from './BranchPickerDialog';
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
@@ -86,6 +90,7 @@ const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
const formatDateLabel = (value: string | number) => {
const targetDate = new Date(value);
@@ -146,6 +151,28 @@ const toFiniteNumber = (value: unknown): number | undefined => {
return undefined;
};
const getSessionCreatedAt = (session: Session): number => {
return toFiniteNumber(session.time?.created) ?? 0;
};
const getSessionUpdatedAt = (session: Session): number => {
return toFiniteNumber(session.time?.updated) ?? 0;
};
const compareSessionsByPinnedAndTime = (a: Session, b: Session, pinnedSessionIds: Set<string>): number => {
const aPinned = pinnedSessionIds.has(a.id);
const bPinned = pinnedSessionIds.has(b.id);
if (aPinned !== bPinned) {
return aPinned ? -1 : 1;
}
if (aPinned && bPinned) {
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
}
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
};
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
return transform;
@@ -525,6 +552,7 @@ interface SessionSidebarProps {
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
hideProjectSelector?: boolean;
showOnlyMainWorkspace?: boolean;
}
@@ -533,6 +561,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
hideProjectSelector = false,
showOnlyMainWorkspace = false,
}) => {
const [editingId, setEditingId] = React.useState<string | null>(null);
@@ -554,9 +583,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw) as string[];
return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []);
} catch {
return new Set();
}
});
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
@@ -722,10 +764,46 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
} catch { /* ignored */ }
}, [safeStorage]);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
React.useEffect(() => {
const existingSessionIds = new Set(sessions.map((session) => session.id));
setPinnedSessionIds((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (existingSessionIds.has(id)) {
next.add(id);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [sessions]);
React.useEffect(() => {
try {
safeStorage.setItem(SESSION_PINNED_STORAGE_KEY, JSON.stringify(Array.from(pinnedSessionIds)));
} catch {
// ignored
}
}, [pinnedSessionIds, safeStorage]);
const togglePinnedSession = React.useCallback((sessionId: string) => {
setPinnedSessionIds((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
return next;
});
}, []);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
React.useEffect(() => {
let cancelled = false;
const normalizedProjects = projects
@@ -778,9 +856,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session);
map.set(parentID, collection);
});
map.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
return map;
}, [sortedSessions]);
}, [sortedSessions, pinnedSessionIds]);
React.useEffect(() => {
const directories = new Set<string>();
@@ -1110,7 +1188,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projectIsRepo: boolean,
) => {
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
const childrenMap = new Map<string, Session[]>();
@@ -1123,7 +1201,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session);
childrenMap.set(parentID, collection);
});
childrenMap.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
// Build worktree lookup map
const worktreeByPath = new Map<string, WorktreeMetadata>();
@@ -1245,7 +1323,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return groups;
},
[homeDirectory, worktreeMetadata]
[homeDirectory, worktreeMetadata, pinnedSessionIds]
);
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
@@ -1390,16 +1468,25 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
[availableWorktreesByProject, getSessionsByDirectory, sessionsByDirectory, isVSCode],
);
// Keep last-known repo status to avoid UI jiggling during project switch
const lastRepoStatusRef = React.useRef(false);
if (activeProjectId && projectRepoStatus.has(activeProjectId)) {
lastRepoStatusRef.current = Boolean(projectRepoStatus.get(activeProjectId));
}
const projectSections = React.useMemo(() => {
return normalizedProjects.map((project) => {
const projectSessions = getSessionsForProject(project);
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
const isRepo = projectRepoStatus.has(project.id)
? Boolean(projectRepoStatus.get(project.id))
: lastRepoStatusRef.current;
const groups = buildGroupedSessions(
projectSessions,
project.normalizedPath,
worktreesForProject,
projectRootBranches.get(project.id) ?? null,
Boolean(projectRepoStatus.get(project.id)),
isRepo,
);
return {
project,
@@ -1429,11 +1516,26 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
: null),
[activeProjectForHeader],
);
const branchPickerProject = React.useMemo(() => {
if (!activeProjectForHeader) {
return null;
}
return {
id: activeProjectForHeader.id,
path: activeProjectForHeader.path,
normalizedPath: activeProjectForHeader.normalizedPath,
label: activeProjectForHeader.label,
};
}, [activeProjectForHeader]);
const activeProjectIsRepo = React.useMemo(
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
[activeProjectForHeader, projectRepoStatus],
);
// Only flip to false once the new project's status is actually resolved (present in map)
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id)
? activeProjectIsRepo
: lastRepoStatusRef.current;
const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
@@ -1690,6 +1792,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const isActive = currentSessionId === session.id;
const sessionTitle = session.title || 'Untitled Session';
const hasChildren = node.children.length > 0;
const isPinnedSession = pinnedSessionIds.has(session.id);
const isExpanded = expandedParents.has(session.id);
const needsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
const sessionSummary = session.summary as
@@ -1834,8 +1937,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
)}
>
{}
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
{showStatusMarker ? (
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
{showStatusMarker ? (
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
{isStreaming ? (
<GridLoader size="xs" className="text-primary" />
@@ -1856,6 +1959,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
)}
</span>
) : null}
{isPinnedSession ? (
<RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" />
) : null}
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
{sessionTitle}
</div>
@@ -1955,6 +2061,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<RiPencilAiLine className="mr-1 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
{isPinnedSession ? (
<RiUnpinLine className="mr-1 h-4 w-4" />
) : (
<RiPushpinLine className="mr-1 h-4 w-4" />
)}
{isPinnedSession ? 'Unpin session' : 'Pin session'}
</DropdownMenuItem>
{!session.share ? (
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
<RiShare2Line className="mr-1 h-4 w-4" />
@@ -2023,6 +2137,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
toggleParent,
handleSessionSelect,
handleSessionDoubleClick,
pinnedSessionIds,
togglePinnedSession,
handleShareSession,
handleCopyShareUrl,
handleUnshareSession,
@@ -2056,7 +2172,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
};
const allGroupSessions = collectGroupSessions(group.sessions);
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId));
const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatusRef.current;
const showBranchSubtitle = !group.isMain && isBranchDifferentFromLabel(group.branch, group.label);
const isActiveGroup = Boolean(
normalizedGroupDirectory
@@ -2136,12 +2254,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined}
>
{!hideGroupLabel ? (
<div className="min-w-0 flex items-center gap-1.5 px-0">
{isCollapsed ? (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex items-center gap-1.5 pl-1.5">
{!group.isMain || isGitProject ? (
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : null}
@@ -2155,6 +2268,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</span>
) : null}
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
</div>
) : <div />}
{group.directory ? (
@@ -2282,7 +2400,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
)}
>
{!hideDirectoryControls && (
<div className="select-none pl-3.5 pr-2 py-1.5 flex-shrink-0 border-b border-border/60">
<div className={cn('select-none pl-3.5 pr-2 flex-shrink-0 border-b border-border/60', hideProjectSelector ? 'py-1' : 'py-1.5')}>
{!hideProjectSelector && (
<div className="flex h-8 items-center justify-between gap-2">
<DropdownMenu
onOpenChange={(open) => {
@@ -2403,11 +2522,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<RiFolderAddLine className="h-4.5 w-4.5" />
</button>
</div>
)}
{reserveHeaderActionsSpace ? (
<div className="mt-1 h-8 pl-1">
<div className="mt-1 -ml-1 flex h-8 items-center">
{activeProjectForHeader ? (
<div className="inline-flex h-8 items-center gap-1.5 rounded-md pl-0 pr-1">
{activeProjectIsRepo ? (
<div className="flex h-full items-center gap-1.5 rounded-md pl-0 pr-1">
{stableActiveProjectIsRepo ? (
<>
<Tooltip>
<TooltipTrigger asChild>
@@ -2479,6 +2599,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</Tooltip>
</>
) : null}
{stableActiveProjectIsRepo && branchPickerProject ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIsBranchPickerOpen(true)}
className={headerActionButtonClass}
aria-label="Manage branches"
>
<RiGitRepositoryLine className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Manage branches</p></TooltipContent>
</Tooltip>
) : null}
{useMobileNotesPanel ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -2512,7 +2647,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<DropdownMenuContent align="start" className="w-[340px] p-0">
<ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader}
canCreateWorktree={activeProjectIsRepo}
canCreateWorktree={stableActiveProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)}
/>
</DropdownMenuContent>
@@ -2741,6 +2876,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}}
/>
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
/>
{useMobileNotesPanel ? (
<MobileOverlayPanel
open={projectNotesPanelOpen}
@@ -2749,7 +2890,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
>
<ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader}
canCreateWorktree={activeProjectIsRepo}
canCreateWorktree={stableActiveProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)}
className="p-0"
/>
@@ -1,5 +1,5 @@
import React from 'react';
import { RiDonutChartLine } from '@remixicon/react';
import { RiDonutChartFill, RiDonutChartLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
@@ -12,6 +12,10 @@ interface ContextUsageDisplayProps {
size?: 'default' | 'compact';
isMobile?: boolean;
hideIcon?: boolean;
showPercentIcon?: boolean;
className?: string;
valueClassName?: string;
percentIconClassName?: string;
}
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
@@ -22,6 +26,10 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
size = 'default',
isMobile = false,
hideIcon = false,
showPercentIcon = false,
className,
valueClassName,
percentIconClassName,
}) => {
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
@@ -53,13 +61,26 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
className={cn(
'app-region-no-drag flex items-center gap-1.5 text-muted-foreground/60 select-none',
size === 'compact' ? 'typography-micro' : 'typography-meta',
className,
)}
aria-label="Context usage"
onClick={isMobile ? () => setMobileTooltipOpen(true) : undefined}
>
{!isMobile && !hideIcon && <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />}
<span className={cn(getPercentageColor(percentage), 'font-medium')}>
{Math.min(percentage, 999).toFixed(1)}%
<span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}>
{showPercentIcon ? (
<>
<RiDonutChartFill
className={cn('h-3.5 w-3.5', percentIconClassName, getPercentageColor(percentage))}
aria-hidden="true"
/>
<span className="text-foreground">{Math.min(percentage, 999).toFixed(1)}%</span>
</>
) : (
<>
<span className={getPercentageColor(percentage)}>{Math.min(percentage, 999).toFixed(1)}</span>%
</>
)}
</span>
</div>
);
+3 -4
View File
@@ -15,7 +15,6 @@ import {
RiArrowUpSLine,
RiBrainAi3Line,
RiCloseCircleLine,
RiCodeLine,
RiCommandLine,
RiGitBranchLine,
RiLayoutLeftLine,
@@ -160,9 +159,9 @@ export const HelpDialog: React.FC = () => {
icon: RiPaletteLine,
},
{
keys: [`${mod} + 2`],
description: "Open Diff Panel",
icon: RiCodeLine,
keys: [`${mod} + 1...9`],
description: "Switch Project or Main Tab",
icon: RiLayoutLeftLine,
},
{
keys: [`${mod} + T`],
@@ -7,6 +7,7 @@ type OverlayScrollbarProps = {
hideDelayMs?: number;
className?: string;
disableHorizontal?: boolean;
observeMutations?: boolean;
};
type ThumbMetrics = {
@@ -20,6 +21,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
hideDelayMs = 1000,
className,
disableHorizontal = false,
observeMutations = true,
}) => {
const [visible, setVisible] = React.useState(false);
const [vertical, setVertical] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
@@ -102,7 +104,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
resizeObserver?.observe(container);
const mutationObserver =
typeof MutationObserver !== "undefined"
observeMutations && typeof MutationObserver !== "undefined"
? new MutationObserver(() => updateMetrics())
: null;
mutationObserver?.observe(container, { childList: true, subtree: true, characterData: true });
@@ -114,7 +116,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, [containerRef, handleScroll, scheduleHide, updateMetrics]);
}, [containerRef, handleScroll, observeMutations, scheduleHide, updateMetrics]);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>, axis: "vertical" | "horizontal") => {
const container = containerRef.current;
@@ -26,7 +26,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
(
{
orientation = "vertical",
offset = 72,
offset = 0,
size = 48,
isEnabled = true,
hideBottomShadow = false,
@@ -9,6 +9,7 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
outerClassName?: string;
scrollbarClassName?: string;
disableHorizontal?: boolean;
observeMutations?: boolean;
fillContainer?: boolean;
keyboardAvoid?: boolean;
/** Prevent scroll from propagating to parent when at boundaries */
@@ -26,6 +27,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
as: Component = "div",
scrollbarClassName,
disableHorizontal = false,
observeMutations = true,
fillContainer = true,
keyboardAvoid = false,
preventOverscroll = false,
@@ -64,6 +66,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
hideDelayMs={hideDelayMs}
className={scrollbarClassName}
disableHorizontal={disableHorizontal}
observeMutations={observeMutations}
/>
</div>
);
+57 -71
View File
@@ -31,86 +31,73 @@ export function AnimatedTabs<T extends string>({
size = 'default',
}: AnimatedTabsProps<T>) {
const containerRef = React.useRef<HTMLDivElement>(null);
const activeTabRef = React.useRef<HTMLButtonElement>(null);
const indicatorRef = React.useRef<HTMLDivElement>(null);
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
const [isReadyToAnimate, setIsReadyToAnimate] = React.useState(false);
const updateClipPath = React.useCallback(() => {
const updateIndicator = React.useCallback(() => {
const container = containerRef.current;
const activeTab = activeTabRef.current;
const indicator = indicatorRef.current;
const activeTab = tabRefs.current.get(value);
if (!container || !activeTab) return;
if (!container || !indicator || !activeTab) return;
const containerWidth = container.offsetWidth;
if (!containerWidth) return;
const containerRect = container.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
const { offsetLeft, offsetWidth } = activeTab;
const leftPercent = Math.max(0, Math.min(100, (offsetLeft / containerWidth) * 100));
const rightPercent = Math.max(0, Math.min(100, ((offsetLeft + offsetWidth) / containerWidth) * 100));
const left = tabRect.left - containerRect.left;
const width = tabRect.width;
container.style.clipPath = `inset(0 ${Number(100 - rightPercent).toFixed(2)}% 0 ${Number(leftPercent).toFixed(2)}% round 8px)`;
}, []);
indicator.style.transform = `translateX(${left}px)`;
indicator.style.width = `${width}px`;
}, [value]);
React.useLayoutEffect(() => {
updateClipPath();
updateIndicator();
if (!isReadyToAnimate) {
setIsReadyToAnimate(true);
}
}, [isReadyToAnimate, updateClipPath, value, tabs.length]);
}, [isReadyToAnimate, updateIndicator, value, tabs.length]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => updateClipPath());
const observer = new ResizeObserver(() => updateIndicator());
observer.observe(container);
return () => observer.disconnect();
}, [updateClipPath]);
}, [updateIndicator]);
const setTabRef = React.useCallback((el: HTMLButtonElement | null, tabValue: string) => {
if (el) {
tabRefs.current.set(tabValue, el);
} else {
tabRefs.current.delete(tabValue);
}
}, []);
return (
<div className={cn('relative isolate w-full', collapseLabelsOnNarrow && '@container/animated-tabs', className)}>
<div className={cn('relative w-full', collapseLabelsOnNarrow && '@container/animated-tabs', className)}>
<div
ref={containerRef}
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)]',
animate && isReadyToAnimate ? '[transition:clip-path_200ms_ease]' : null
)}
>
<div
className={cn(
'flex items-center gap-1 bg-interactive-selection text-interactive-selection-foreground',
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
)}
>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<div
key={tab.value}
className={cn(
'flex flex-1 items-center justify-center font-semibold',
size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm',
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25'
)}
>
{Icon ? <Icon className={cn(size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> : null}
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</div>
);
})}
</div>
</div>
<div
className={cn(
'relative z-20 flex items-center gap-1 bg-muted/20',
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
'relative flex items-center overflow-hidden bg-[var(--surface-muted)]/50',
size === 'sm'
? 'h-8 rounded-lg py-0.5 px-px gap-0.5'
: 'h-10 rounded-lg py-0.5 px-px gap-0.5'
)}
>
{/* Sliding indicator */}
<div
ref={indicatorRef}
className={cn(
'absolute top-0.5 bottom-0.5 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-sm',
animate && isReadyToAnimate ? 'transition-[transform,width] duration-200 ease-out' : null
)}
style={{ width: 0, transform: 'translateX(0)' }}
/>
{tabs.map((tab) => {
const isActive = value === tab.value;
const Icon = tab.icon;
@@ -118,17 +105,17 @@ export function AnimatedTabs<T extends string>({
return (
<button
key={tab.value}
ref={isActive ? activeTabRef : null}
ref={(el) => setTabRef(el, tab.value)}
type="button"
onClick={() => {
if (!isInteractive) return;
onValueChange(tab.value);
}}
className={cn(
'animated-tabs__button flex flex-1 items-center justify-center font-semibold transition-colors duration-150',
size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm',
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25',
isActive ? 'text-accent-foreground' : 'text-muted-foreground',
className={cn(
'animated-tabs__button relative z-10 flex flex-1 items-center justify-center font-medium transition-colors duration-150',
size === 'sm' ? 'h-6 rounded-lg px-2.5 text-sm' : 'h-7 rounded-lg px-3 text-sm',
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.5' : 'gap-1.5',
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
)}
aria-pressed={isActive}
@@ -136,18 +123,17 @@ export function AnimatedTabs<T extends string>({
aria-disabled={!isInteractive}
tabIndex={isInteractive ? 0 : -1}
>
{Icon ? (
<Icon
className={cn(
size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4',
isActive ? 'text-accent-foreground' : 'text-muted-foreground'
)}
/>
) : null}
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
{Icon ? (
<Icon
className={cn(
size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}
/>
) : null}
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</button>
);
})}
@@ -21,18 +21,19 @@ const GridLoader: React.FC<GridLoaderProps> = ({ className, size = 'md' }) => {
const config = sizeConfig[size];
return (
<div
className={cn('grid grid-cols-3', config.container, className)}
<span
className={cn('grid grid-cols-3 place-items-center', config.container, className)}
style={{ width: '11px', height: '11px' }}
aria-label="Loading"
>
{Array.from({ length: 9 }, (_, i) => (
<div
<span
key={i}
className={cn('rounded-full bg-current animate-grid-pulse', config.dot)}
className={cn('shrink-0 rounded-full bg-current animate-grid-pulse', config.dot)}
style={{ animationDelay: `${getPulseDelayMs(i)}ms` }}
/>
))}
</div>
</span>
);
};
+440 -170
View File
@@ -16,7 +16,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
@@ -44,7 +44,15 @@ type FileEntry = GitStatus['files'][number] & {
isNew: boolean;
};
type DiffData = { original: string; modified: string };
type DiffData = { original: string; modified: string; isBinary?: boolean };
const BinaryDiffPlaceholder = React.memo(() => {
return (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2">
<div className="typography-meta text-muted-foreground">Content of this file cannot be viewed.</div>
</div>
);
});
type DiffTabViewMode = 'single' | 'stacked';
@@ -426,7 +434,7 @@ interface InlineDiffViewerProps {
wrapLines: boolean;
}
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
filePath,
diff,
renderSideBySide,
@@ -437,6 +445,10 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
[filePath]
);
if (diff.isBinary) {
return <BinaryDiffPlaceholder />;
}
if (isImageFile(filePath)) {
return (
<InlineImageDiffViewer
@@ -471,7 +483,7 @@ interface SingleDiffViewerProps {
wrapLines: boolean;
}
const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
filePath,
diff,
isVisible,
@@ -483,6 +495,10 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
[filePath]
);
if (diff.isBinary) {
return <BinaryDiffPlaceholder />;
}
// Don't render if not visible (memory optimization)
if (!isVisible) {
return null;
@@ -514,45 +530,6 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
);
});
interface DiffViewerEntryProps {
directory: string;
filePath: string;
isVisible: boolean;
renderSideBySide: boolean;
wrapLines: boolean;
}
const DiffViewerEntry = React.memo<DiffViewerEntryProps>(({
directory,
filePath,
isVisible,
renderSideBySide,
wrapLines,
}) => {
const cachedDiff = useGitStore(
React.useCallback((state) => {
return state.directories.get(directory)?.diffCache.get(filePath) ?? null;
}, [directory, filePath])
);
const diffData = React.useMemo(() => {
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified };
}, [cachedDiff]);
if (!diffData) return null;
return (
<SingleDiffViewer
filePath={filePath}
diff={diffData}
isVisible={isVisible}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
);
});
interface MultiFileDiffEntryProps {
directory: string;
file: FileEntry;
@@ -564,6 +541,8 @@ interface MultiFileDiffEntryProps {
registerSectionRef: (path: string, node: HTMLDivElement | null) => void;
/** Start collapsed to reduce memory with many files */
defaultCollapsed?: boolean;
expandRequestPath?: string | null;
expandRequestNonce?: number;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -576,6 +555,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
onSelect,
registerSectionRef,
defaultCollapsed = false,
expandRequestPath = null,
expandRequestNonce = 0,
}) => {
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
@@ -597,9 +578,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const descriptor = React.useMemo(() => describeChange(file), [file]);
const renderSideBySide = layout === 'side-by-side';
const diffData = React.useMemo(() => {
const diffData = React.useMemo<DiffData | null>(() => {
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified };
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
}, [cachedDiff]);
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
@@ -642,6 +623,15 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
return () => observer.disconnect();
}, [hasBeenVisible, isExpanded, scrollRootRef]);
React.useEffect(() => {
if (expandRequestNonce <= 0 || expandRequestPath !== file.path) {
return;
}
setIsExpanded(true);
setHasBeenVisible(true);
}, [expandRequestNonce, expandRequestPath, file.path]);
React.useEffect(() => {
if (!isExpanded || !hasBeenVisible) return;
if (!directory || diffData) {
@@ -673,6 +663,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setDiff(directory, file.path, {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
setIsLoading(false);
} catch (error) {
@@ -691,108 +682,116 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
};
}, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
handleSelect();
}, [handleOpenChange, handleSelect, isExpanded]);
return (
<div ref={setSectionRef} className="scroll-mt-4">
<Collapsible
open={isExpanded}
onOpenChange={handleOpenChange}
className="group/collapsible"
>
<div className="sticky top-0 z-10 bg-background">
<CollapsibleTrigger
onClick={handleSelect}
className={cn(
'relative flex w-full items-center gap-2 px-3 py-1.5 transition-colors rounded-t-xl border border-border/60 overflow-hidden',
'bg-background hover:bg-background',
isExpanded ? 'rounded-b-none' : 'rounded-b-xl',
isSelected
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<div className={cn(
'absolute inset-0 pointer-events-none transition-colors',
isSelected ? 'bg-interactive-selection' : 'group-hover:bg-interactive-hover'
)} />
<div className="relative flex min-w-0 flex-1 items-center gap-2">
<span className="flex size-5 items-center justify-center opacity-70 group-hover:opacity-100 transition-opacity">
{isExpanded ? (
<RiArrowDownSLine className="size-4" />
) : (
<RiArrowRightSLine className="size-4" />
)}
</span>
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
>
{descriptor.code}
</span>
<span
className="min-w-0 flex-1 truncate typography-ui-label"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
</div>
<div className="relative flex items-center gap-2">
{formatDiffTotals(file.insertions, file.deletions)}
<DiffViewToggle
mode={renderSideBySide ? 'side-by-side' : 'unified'}
onModeChange={(mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
setDiffFileLayout(file.path, nextLayout);
}}
className="opacity-70"
/>
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="relative border border-t-0 border-border/60 bg-background rounded-b-xl overflow-hidden">
{diffLoadError ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<div className="typography-ui-label font-semibold text-foreground">
Failed to load diff
</div>
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
{diffLoadError}
</div>
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
>
Retry
</button>
</div>
) : null}
{isLoading && !diffData && !diffLoadError ? (
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
</div>
) : null}
{isExpanded && diffData ? (
<InlineDiffViewer
filePath={file.path}
diff={diffData}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
) : null}
<div className="sticky top-0 z-10 bg-background">
<button
type="button"
onClick={handleToggle}
className={cn(
'group/header relative flex w-full items-center gap-2 px-3 py-1.5 rounded-t-xl border border-border/60 overflow-hidden',
'bg-background',
isExpanded ? 'rounded-b-none' : 'rounded-b-xl',
'text-muted-foreground hover:text-foreground',
isSelected ? 'ring-1 ring-inset ring-[var(--interactive-selection)]' : null
)}
>
<div className="absolute inset-0 pointer-events-none group-hover/header:bg-interactive-hover" />
<div className="relative flex min-w-0 flex-1 items-center gap-2">
<span className="flex size-5 items-center justify-center opacity-70 group-hover/header:opacity-100">
{isExpanded ? (
<RiArrowDownSLine className="size-4" />
) : (
<RiArrowRightSLine className="size-4" />
)}
</span>
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
>
{descriptor.code}
</span>
<span
className="min-w-0 flex-1 truncate typography-ui-label"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
</div>
</CollapsibleContent>
</Collapsible>
<div className="relative flex items-center gap-2">
{formatDiffTotals(file.insertions, file.deletions)}
<DiffViewToggle
mode={renderSideBySide ? 'side-by-side' : 'unified'}
onModeChange={(mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
setDiffFileLayout(file.path, nextLayout);
}}
className="opacity-70"
/>
</div>
</button>
</div>
{isExpanded && (
<div className="relative border border-t-0 border-border/60 bg-background rounded-b-xl overflow-hidden">
{diffLoadError ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<div className="typography-ui-label font-semibold text-foreground">
Failed to load diff
</div>
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
{diffLoadError}
</div>
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
>
Retry
</button>
</div>
) : null}
{isLoading && !diffData && !diffLoadError ? (
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
</div>
) : null}
{diffData ? (
<InlineDiffViewer
filePath={file.path}
diff={diffData}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
) : null}
</div>
)}
</div>
);
});
export const DiffView: React.FC = () => {
interface DiffViewProps {
hideStackedFileSidebar?: boolean;
stackedDefaultCollapsedAll?: boolean;
hideFileSelector?: boolean;
pinSelectedFileHeaderToTopOnNavigate?: boolean;
}
export const DiffView: React.FC<DiffViewProps> = ({
hideStackedFileSidebar = false,
stackedDefaultCollapsedAll = false,
hideFileSelector = false,
pinSelectedFileHeaderToTopOnNavigate = false,
}) => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { screenWidth, isMobile } = useDeviceInfo();
@@ -803,6 +802,9 @@ export const DiffView: React.FC = () => {
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0);
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const lastDiffRequestRef = React.useRef<string | null>(null);
@@ -821,10 +823,108 @@ export const DiffView: React.FC = () => {
const isStackedView = diffViewMode === 'stacked';
const isMobileLayout = isMobile || screenWidth <= 768;
const showFileSidebar = !isMobileLayout && screenWidth >= 1024;
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
const diffScrollRef = React.useRef<HTMLElement | null>(null);
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
const pendingScrollTargetRef = React.useRef<string | null>(null);
const pendingScrollFrameRef = React.useRef<number | null>(null);
const shouldPinAfterAlignRef = React.useRef(false);
React.useEffect(() => {
if (!pinSelectedFileHeaderToTopOnNavigate || !isStackedView || !pinnedStackedTarget) {
return;
}
const scrollRoot = diffScrollRef.current;
if (!scrollRoot) {
return;
}
let rafId: number | null = null;
let cancelled = false;
let stableFrames = 0;
const stopAt = Date.now() + 1200;
let ignoreNextScrollEvents = 0;
const stop = () => {
if (cancelled) {
return;
}
cancelled = true;
setPinnedStackedTarget(null);
};
const cancelOnUserInput = () => {
stop();
};
const cancelOnScroll = () => {
if (ignoreNextScrollEvents > 0) {
ignoreNextScrollEvents -= 1;
return;
}
stop();
};
window.addEventListener('wheel', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('touchstart', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('pointerdown', cancelOnUserInput, { capture: true });
window.addEventListener('keydown', cancelOnUserInput, { capture: true });
scrollRoot.addEventListener('scroll', cancelOnScroll, { passive: true });
const tick = () => {
if (cancelled || Date.now() > stopAt) {
stop();
return;
}
const currentScrollRoot = diffScrollRef.current;
const node = fileSectionRefs.current.get(pinnedStackedTarget);
if (!currentScrollRoot || !node) {
stop();
return;
}
const rootRect = currentScrollRoot.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const delta = nodeRect.top - rootRect.top;
if (Math.abs(delta) <= 1) {
stableFrames += 1;
if (stableFrames >= 2) {
stop();
return;
}
} else {
stableFrames = 0;
const maxTop = Math.max(0, currentScrollRoot.scrollHeight - currentScrollRoot.clientHeight);
const nextTop = Math.min(maxTop, Math.max(0, currentScrollRoot.scrollTop + delta));
if (Math.abs(nextTop - currentScrollRoot.scrollTop) <= 0.5) {
stop();
return;
}
ignoreNextScrollEvents += 1;
currentScrollRoot.scrollTop = nextTop;
}
rafId = window.requestAnimationFrame(tick);
};
rafId = window.requestAnimationFrame(tick);
return () => {
cancelled = true;
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
window.removeEventListener('wheel', cancelOnUserInput, true);
window.removeEventListener('touchstart', cancelOnUserInput, true);
window.removeEventListener('pointerdown', cancelOnUserInput, true);
window.removeEventListener('keydown', cancelOnUserInput, true);
scrollRoot.removeEventListener('scroll', cancelOnScroll);
};
}, [isStackedView, pinSelectedFileHeaderToTopOnNavigate, pinnedStackedTarget]);
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
@@ -887,7 +987,10 @@ export const DiffView: React.FC = () => {
setSelectedFile(pendingDiffFile);
setPendingDiffFile(null);
if (isStackedView) {
shouldPinAfterAlignRef.current = true;
pendingScrollTargetRef.current = pendingDiffFile;
setStackedExpandTarget(pendingDiffFile);
setStackedExpandRequestNonce((nonce) => nonce + 1);
}
}
}, [isStackedView, pendingDiffFile, setPendingDiffFile]);
@@ -899,22 +1002,6 @@ export const DiffView: React.FC = () => {
}
}, [changedFiles, selectedFile, pendingDiffFile]);
React.useEffect(() => {
if (!isStackedView) {
pendingScrollTargetRef.current = null;
return;
}
const target = pendingScrollTargetRef.current;
if (!target) return;
const node = fileSectionRefs.current.get(target);
if (!node) return;
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
pendingScrollTargetRef.current = null;
}, [changedFiles, isStackedView]);
// Clear selection if file no longer exists
React.useEffect(() => {
if (selectedFile && changedFiles.length > 0) {
@@ -934,29 +1021,202 @@ export const DiffView: React.FC = () => {
}
}, []);
const scrollToFile = React.useCallback((path: string, behavior: ScrollBehavior = 'smooth') => {
type ScrollToFileResult = {
ok: boolean;
aligned: boolean;
didMove: boolean;
atScrollLimit: boolean;
delta: number;
};
const scrollToFile = React.useCallback((path: string): ScrollToFileResult => {
const node = fileSectionRefs.current.get(path);
if (!node) return false;
node.scrollIntoView({ behavior, block: 'start' });
return true;
const scrollRoot = diffScrollRef.current;
if (!node || !scrollRoot) {
return { ok: false, aligned: false, didMove: false, atScrollLimit: false, delta: 0 };
}
const rootRect = scrollRoot.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const delta = nodeRect.top - rootRect.top;
const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
const desiredTop = scrollRoot.scrollTop + delta;
const nextTop = Math.min(maxTop, Math.max(0, desiredTop));
const didMove = Math.abs(nextTop - scrollRoot.scrollTop) > 0.5;
scrollRoot.scrollTop = nextTop;
const aligned = Math.abs(delta) <= 1;
const atScrollLimit = nextTop <= 0.5 || nextTop >= maxTop - 0.5;
return { ok: true, aligned, didMove, atScrollLimit, delta };
}, []);
React.useEffect(() => {
if (!isStackedView) {
pendingScrollTargetRef.current = null;
shouldPinAfterAlignRef.current = false;
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
return;
}
const target = pendingScrollTargetRef.current;
if (!target) return;
let attempts = 0;
const maxAttempts = 120;
let cancelled = false;
let ignoreNextScrollEvents = 0;
let didRemoveListeners = false;
let stallFrames = 0;
const stopAt = Date.now() + 2000;
const removeListeners = () => {
if (didRemoveListeners) {
return;
}
didRemoveListeners = true;
window.removeEventListener('wheel', cancelOnUserInput, true);
window.removeEventListener('touchstart', cancelOnUserInput, true);
window.removeEventListener('pointerdown', cancelOnUserInput, true);
window.removeEventListener('keydown', cancelOnUserInput, true);
scrollRoot?.removeEventListener('scroll', cancelOnScroll);
};
const cancelPending = () => {
if (cancelled) {
return;
}
cancelled = true;
removeListeners();
pendingScrollTargetRef.current = null;
shouldPinAfterAlignRef.current = false;
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
};
const cancelOnUserInput = () => {
cancelPending();
};
const cancelOnScroll = () => {
if (ignoreNextScrollEvents > 0) {
ignoreNextScrollEvents -= 1;
return;
}
cancelPending();
};
const scrollRoot = diffScrollRef.current;
window.addEventListener('wheel', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('touchstart', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('pointerdown', cancelOnUserInput, { capture: true });
window.addEventListener('keydown', cancelOnUserInput, { capture: true });
scrollRoot?.addEventListener('scroll', cancelOnScroll, { passive: true });
const tryAlign = () => {
if (Date.now() > stopAt) {
cancelPending();
pendingScrollFrameRef.current = null;
return;
}
if (cancelled) {
pendingScrollFrameRef.current = null;
return;
}
const currentTarget = pendingScrollTargetRef.current;
if (!currentTarget) {
cancelPending();
pendingScrollFrameRef.current = null;
return;
}
ignoreNextScrollEvents += 1;
const result = scrollToFile(currentTarget);
if (!result.ok) {
ignoreNextScrollEvents = Math.max(0, ignoreNextScrollEvents - 1);
attempts += 1;
if (attempts < maxAttempts) {
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
} else {
cancelPending();
pendingScrollFrameRef.current = null;
}
return;
}
if (!result.aligned) {
attempts += 1;
if (!result.didMove) {
stallFrames += 1;
// If we're clamped (e.g. target is near bottom) give layout a few frames to settle
// (diff expansion / highlight can change scrollHeight), but don't fight user input.
if (stallFrames < 6 && (result.atScrollLimit || Math.abs(result.delta) > 1)) {
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
return;
}
} else {
stallFrames = 0;
if (attempts < maxAttempts) {
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
return;
}
}
}
if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) {
setPinnedStackedTarget(currentTarget);
}
cancelPending();
};
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
return () => {
cancelled = true;
removeListeners();
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
};
}, [isStackedView, pinSelectedFileHeaderToTopOnNavigate, scrollToFile, selectedFile, stackedExpandRequestNonce]);
const handleSelectFile = React.useCallback((value: string) => {
setSelectedFile(value);
}, []);
const handleSelectFileAndScroll = React.useCallback((value: string) => {
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
pendingScrollTargetRef.current = null;
setSelectedFile(value);
if (isStackedView && !scrollToFile(value)) {
pendingScrollTargetRef.current = value;
if (!isStackedView) {
shouldPinAfterAlignRef.current = false;
return;
}
shouldPinAfterAlignRef.current = true;
pendingScrollTargetRef.current = value;
scrollToFile(value);
}, [isStackedView, scrollToFile]);
const handleDiffViewModeChange = React.useCallback((mode: DiffTabViewMode) => {
setDiffViewMode(mode);
if (mode === 'stacked' && selectedFile && !scrollToFile(selectedFile, 'auto')) {
pendingScrollTargetRef.current = selectedFile;
if (mode === 'stacked' && selectedFile) {
const result = scrollToFile(selectedFile);
if (!result.aligned) {
pendingScrollTargetRef.current = selectedFile;
}
}
}, [scrollToFile, selectedFile, setDiffViewMode]);
@@ -976,13 +1236,18 @@ export const DiffView: React.FC = () => {
}, [changedFiles, isStackedView, selectedFileEntry, setDiffFileLayout]);
const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
const showFileSelector = !isStackedView || !showFileSidebar;
const showFileSelector = !hideFileSelector && (!isStackedView || !showFileSidebar);
const selectedCachedDiff = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory || !selectedFile) return null;
return state.directories.get(effectiveDirectory)?.diffCache.get(selectedFile) ?? null;
}, [effectiveDirectory, selectedFile]));
const selectedDiffData = React.useMemo<DiffData | null>(() => {
if (!selectedCachedDiff) return null;
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
}, [selectedCachedDiff]);
const hasCurrentDiff = !!selectedCachedDiff;
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
@@ -1024,6 +1289,7 @@ export const DiffView: React.FC = () => {
setDiff(effectiveDirectory, selectedFile, {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
} catch (error) {
if (cancelled) return;
@@ -1043,13 +1309,13 @@ export const DiffView: React.FC = () => {
// Render only the selected diff viewer to prevent memory bloat with many files
const renderSelectedDiffViewer = () => {
if (!effectiveDirectory || !selectedFile) return null;
if (!effectiveDirectory || !selectedFile || !selectedDiffData) return null;
return (
<DiffViewerEntry
<SingleDiffViewer
key={selectedFile}
directory={effectiveDirectory}
filePath={selectedFile}
diff={selectedDiffData}
isVisible={true}
renderSideBySide={renderSideBySide}
wrapLines={diffWrapLines}
@@ -1082,6 +1348,8 @@ export const DiffView: React.FC = () => {
outerClassName="flex-1 min-h-0 h-full"
className="pr-2"
disableHorizontal
observeMutations={false}
preventOverscroll
data-diff-virtual-root
data-diff-virtual-content
>
@@ -1097,7 +1365,9 @@ export const DiffView: React.FC = () => {
isSelected={file.path === selectedFile}
onSelect={handleSelectFile}
registerSectionRef={registerSectionRef}
defaultCollapsed={index >= defaultExpandedCount}
defaultCollapsed={stackedDefaultCollapsedAll ? true : index >= defaultExpandedCount}
expandRequestPath={stackedExpandTarget}
expandRequestNonce={stackedExpandRequestNonce}
/>
))}
</div>
+232 -294
View File
@@ -2,7 +2,6 @@ import React from 'react';
import {
RiArrowLeftSLine,
RiArrowRightSLine,
RiArrowDownSLine,
RiClipboardLine,
RiCloseLine,
@@ -39,7 +38,7 @@ import {
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
@@ -66,7 +65,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useGitStatus } from '@/stores/useGitStore';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
import { useFloatingComments } from '@/components/comments/useFloatingComments';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
@@ -154,52 +153,6 @@ const getAncestorPaths = (filePath: string, root: string): string[] => {
return ancestors;
};
type BreadcrumbSegment = { label: string; path: string };
const parseBreadcrumbs = (relativePath: string, root: string): BreadcrumbSegment[] => {
const parts = relativePath.split('/');
const segments: BreadcrumbSegment[] = [];
let currentPath = root;
for (const part of parts) {
if (!part) continue;
currentPath = currentPath ? `${currentPath}/${part}` : part;
segments.push({ label: part, path: currentPath });
}
return segments;
};
const FileBreadcrumbs: React.FC<{
path: string;
root: string;
onNavigate: (dirPath: string) => void;
}> = ({ path, root, onNavigate }) => {
const segments = React.useMemo(() => parseBreadcrumbs(path, root), [path, root]);
return (
<div className="flex items-center gap-1 overflow-x-auto whitespace-nowrap min-w-0 flex-1 hide-scrollbar">
{segments.map((seg, i) => (
<React.Fragment key={seg.path}>
{i > 0 && <RiArrowRightSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />}
<button
type="button"
onClick={() => i < segments.length - 1 && onNavigate(seg.path)}
className={cn(
"typography-meta transition-colors",
i === segments.length - 1
? "text-foreground font-medium cursor-default"
: "text-muted-foreground hover:text-foreground hover:underline cursor-pointer"
)}
disabled={i === segments.length - 1}
>
{seg.label}
</button>
</React.Fragment>
))}
</div>
);
};
const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']);
type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted';
@@ -375,7 +328,6 @@ interface FileRowProps {
node: FileNode;
isExpanded: boolean;
isActive: boolean;
isLoading: boolean;
isMobile: boolean;
status?: FileStatus | null;
badge?: { modified: number; added: number } | null;
@@ -396,7 +348,6 @@ const FileRow: React.FC<FileRowProps> = ({
node,
isExpanded,
isActive,
isLoading,
isMobile,
status,
badge,
@@ -446,9 +397,7 @@ const FileRow: React.FC<FileRowProps> = ({
)}
>
{isDir ? (
isLoading ? (
<RiLoader4Line className="h-4 w-4 flex-shrink-0 animate-spin" />
) : isExpanded ? (
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" />
@@ -537,7 +486,11 @@ const FileRow: React.FC<FileRowProps> = ({
);
};
export const FilesView: React.FC = () => {
interface FilesViewProps {
mode?: 'full' | 'editor-only';
}
export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const { files, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
@@ -587,6 +540,30 @@ export const FilesView: React.FC = () => {
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
// Editor tabs horizontal scroll fades
const editorTabsScrollRef = React.useRef<HTMLDivElement>(null);
const [editorTabsOverflow, setEditorTabsOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false });
const updateEditorTabsOverflow = React.useCallback(() => {
const el = editorTabsScrollRef.current;
if (!el) return;
setEditorTabsOverflow({
left: el.scrollLeft > 2,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2,
});
}, []);
React.useEffect(() => {
const el = editorTabsScrollRef.current;
if (!el) return;
updateEditorTabsOverflow();
el.addEventListener('scroll', updateEditorTabsOverflow, { passive: true });
const ro = new ResizeObserver(updateEditorTabsOverflow);
ro.observe(el);
return () => {
el.removeEventListener('scroll', updateEditorTabsOverflow);
ro.disconnect();
};
}, [updateEditorTabsOverflow, openFiles.length]);
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
@@ -612,6 +589,7 @@ export const FilesView: React.FC = () => {
const copiedContentTimeoutRef = React.useRef<number | null>(null);
const copiedPathTimeoutRef = React.useRef<number | null>(null);
const editorViewRef = React.useRef<EditorView | null>(null);
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
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);
@@ -1542,17 +1520,6 @@ export const FilesView: React.FC = () => {
}
}, [loadDirectory, root, toggleExpandedPath]);
const handleBreadcrumbNavigate = React.useCallback((dirPath: string) => {
if (!root) return;
if (searchQuery.trim().length > 0) {
setSearchQuery('');
}
if (isMobile) {
setShowMobilePageContent(false);
}
void ensurePathVisible(dirPath, true);
}, [ensurePathVisible, isMobile, root, searchQuery]);
const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => {
const nodes = childrenByDir[dirPath] ?? [];
@@ -1560,7 +1527,6 @@ export const FilesView: React.FC = () => {
const isDir = node.type === 'directory';
const isExpanded = isDir && expandedPaths.includes(node.path);
const isActive = selectedFile?.path === node.path;
const isLoading = isDir && inFlightDirsRef.current.has(node.path);
const isLast = index === nodes.length - 1;
return (
@@ -1577,7 +1543,6 @@ export const FilesView: React.FC = () => {
node={node}
isExpanded={isExpanded}
isActive={isActive}
isLoading={isLoading}
isMobile={isMobile}
status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined}
@@ -1798,9 +1763,9 @@ export const FilesView: React.FC = () => {
}
}}
autoFocus
/>
</div>
)}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
@@ -1820,71 +1785,30 @@ export const FilesView: React.FC = () => {
</Dialog>
);
const blockWidgets = React.useMemo(() => {
const filesFileDrafts = React.useMemo(() => {
if (!selectedFile) return [];
const sessionKey = currentSessionId ?? 'draft';
const sessionDrafts = allDrafts[sessionKey] ?? [];
// Filter drafts for current file
const fileDrafts = sessionDrafts.filter(
(d) => d.source === 'file' && d.fileLabel === selectedFile.path
);
return sessionDrafts.filter((d) => d.source === 'file' && d.fileLabel === selectedFile.path);
}, [selectedFile, currentSessionId, allDrafts]);
const widgets: BlockWidgetDef[] = [];
// Add cards for existing drafts
fileDrafts.forEach((draft) => {
const isEditing = editingDraftId === draft.id;
if (isEditing) {
widgets.push({
afterLine: draft.endLine,
id: `edit-${draft.id}`,
content: (
<InlineCommentInput
initialText={draft.text}
lineRange={{ start: draft.startLine, end: draft.endLine }}
onSave={(text) => handleSaveComment(text, { start: draft.startLine, end: draft.endLine })}
onCancel={() => setEditingDraftId(null)}
isEditing={true}
/>
),
});
} else {
widgets.push({
afterLine: draft.endLine,
id: `card-${draft.id}`,
content: (
<InlineCommentCard
draft={draft}
onEdit={() => {
setEditingDraftId(draft.id);
setLineSelection(null);
}}
onDelete={() => removeDraft(sessionKey, draft.id)}
/>
),
});
}
});
// Add input for new comment
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'files-new-comment-input',
content: (
<InlineCommentInput
lineRange={lineSelection}
onSave={(text) => handleSaveComment(text)}
onCancel={() => setLineSelection(null)}
/>
),
});
}
return widgets;
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft, isDragging]);
const floatingComments = useFloatingComments({
editorView: editorViewRef.current,
wrapperRef: editorWrapperRef,
fileDrafts: filesFileDrafts,
editingDraftId,
commentText: '',
lineSelection,
isDragging,
fileLabel: selectedFile?.path ?? '',
onSaveComment: handleSaveComment,
onCancelComment: () => setLineSelection(null),
onEditDraft: (draft) => {
setEditingDraftId(draft.id);
setLineSelection(null);
},
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
});
const fileViewer = (
<div
@@ -1916,19 +1840,20 @@ export const FilesView: React.FC = () => {
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
{isMobile && showMobilePageContent && (
<button
type="button"
onClick={() => setShowMobilePageContent(false)}
aria-label="Back"
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
)}
<div className="flex flex-col border-b border-border/40 flex-shrink-0">
{/* Row 1: Tabs */}
<div className="flex min-w-0 items-center px-3 py-1.5">
{isMobile && showMobilePageContent && (
<button
type="button"
onClick={() => setShowMobilePageContent(false)}
aria-label="Back"
className="inline-flex h-7 w-7 flex-shrink-0 items-center justify-center mr-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
)}
<div className="min-w-0 flex-1">
{isMobile ? (
selectedFile ? (
<DropdownMenu>
@@ -1993,8 +1918,18 @@ export const FilesView: React.FC = () => {
)
) : (
openFiles.length > 0 ? (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
<div className="relative min-w-0 flex-1">
{editorTabsOverflow.left && (
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-6 z-10 bg-gradient-to-r from-background to-transparent" />
)}
{editorTabsOverflow.right && (
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-6 z-10 bg-gradient-to-l from-background to-transparent" />
)}
<div
ref={editorTabsScrollRef}
className="flex min-w-0 items-center gap-1 overflow-x-auto scrollbar-none"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{openFiles.map((file) => {
const isActive = selectedFile?.path === file.path;
return (
@@ -2037,13 +1972,6 @@ export const FilesView: React.FC = () => {
);
})}
</div>
{selectedFile && (
<FileBreadcrumbs
path={displaySelectedPath}
root={root}
onNavigate={handleBreadcrumbNavigate}
/>
)}
</div>
) : (
<div className="typography-ui-label font-medium truncate">Select a file</div>
@@ -2051,149 +1979,152 @@ export const FilesView: React.FC = () => {
)}
</div>
<div className="flex items-center gap-1">
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
disabled={!isDirty || isSaving}
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
title={`Save (${getModifierLabel()}+S)`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
{isSaving ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiSave3Line className="h-4 w-4" />
)}
</Button>
)}
{canEdit && selectedFile && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{selectedFile && !isSelectedImage && (
<>
{/* Row 2: Actions (right-aligned) */}
{selectedFile && (
<div className="flex items-center justify-end gap-1 px-3 pb-1.5">
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
onClick={() => void saveDraft()}
disabled={!isDirty || isSaving}
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
title={`Save (${getModifierLabel()}+S)`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
<RiTextWrap className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
</>
)}
{(canCopy || canCopyPath || (selectedFile && isMarkdownFile(selectedFile.path))) && (canEdit || (selectedFile && !isSelectedImage)) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{selectedFile && isMarkdownFile(selectedFile.path) && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(fileContent);
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(displaySelectedPath);
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
{selectedFile && !isMobile && (
<>
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-5 w-5 p-0"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<RiFullscreenExitLine className="h-4 w-4" />
{isSaving ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiFullscreenLine className="h-4 w-4" />
<RiSave3Line className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
)}
{canEdit && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{!isSelectedImage && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
>
<RiTextWrap className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
</>
)}
{(canCopy || canCopyPath || isMarkdownFile(selectedFile.path)) && (canEdit || !isSelectedImage) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{isMarkdownFile(selectedFile.path) && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(fileContent);
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(displaySelectedPath);
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
{!isMobile && mode === 'full' && (
<>
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-5 w-5 p-0"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<RiFullscreenExitLine className="h-4 w-4" />
) : (
<RiFullscreenLine className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 min-w-0 relative">
@@ -2237,7 +2168,8 @@ export const FilesView: React.FC = () => {
</div>
) : (
<div
className="h-full"
className="relative h-full"
ref={editorWrapperRef}
data-keyboard-avoid="none"
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
>
@@ -2260,7 +2192,6 @@ export const FilesView: React.FC = () => {
enableSearch
searchOpen={isSearchOpen}
onSearchOpenChange={setIsSearchOpen}
blockWidgets={blockWidgets}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
@@ -2332,6 +2263,7 @@ export const FilesView: React.FC = () => {
},
}}
/>
{floatingComments}
</div>
)}
</ScrollableOverlay>
@@ -2438,7 +2370,7 @@ export const FilesView: React.FC = () => {
);
// Fullscreen file viewer overlay
const fullscreenViewer = isFullscreen && selectedFile && (
const fullscreenViewer = mode === 'full' && isFullscreen && selectedFile && (
<div className="absolute inset-0 z-50 flex flex-col bg-background">
{/* Fullscreen header */}
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-4 py-2 flex-shrink-0">
@@ -2648,10 +2580,16 @@ export const FilesView: React.FC = () => {
) : (
treePanel
)
) : mode === 'editor-only' ? (
<div className="flex flex-1 min-h-0 min-w-0 overflow-hidden">
<div className="flex-1 min-h-0 min-w-0 overflow-hidden bg-background">
{fileViewer}
</div>
</div>
) : (
<div className="flex flex-1 min-h-0 min-w-0 gap-3 px-3 pb-3 pt-2">
{screenWidth >= 700 && (
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
{screenWidth >= 700 && (
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
{treePanel}
</div>
)}
+11 -3
View File
@@ -1640,7 +1640,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
isWorktreeMode={!!worktreeMetadata}
isSidebarMode={isSidebarMode}
onOpenHistory={() => setIsHistoryDialogOpen(true)}
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
onOpenBranchPicker={!isSidebarMode && branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
/>
{/* In-progress operation banner */}
@@ -1662,10 +1662,11 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 flex flex-col">
<div className={cn('min-w-0 min-h-0 h-full bg-muted/10 flex flex-col', isSidebarMode && 'border-t border-border/40')}>
<div className="px-3 py-3">
<div className="px-3 py-1.5">
<AnimatedTabs<ActionTab>
value={actionTab}
onValueChange={setActionTab}
size="sm"
collapseLabelsOnSmall
collapseLabelsOnNarrow={isSidebarMode}
tabs={[
@@ -1700,7 +1701,13 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onViewDiff={(path) => {
if (isSidebarMode && currentDirectory) {
useUIStore.getState().openContextDiff(currentDirectory, path);
return;
}
useUIStore.getState().navigateToDiff(path);
}}
onRevertFile={handleRevertFile}
/>
@@ -1793,6 +1800,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
onGeneratedDescription={scrollActionPanelToBottom}
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
// createPortal no longer needed — comments float absolutely outside shadow DOM
import {
FileDiff as PierreFileDiff,
VirtualizedFileDiff,
@@ -45,22 +45,11 @@ const WEBKIT_SCROLL_FIX_CSS = `
font-size: var(--text-code);
}
:host, pre, [data-diffs], [data-code] {
transform: translateZ(0);
-webkit-transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
pre, [data-code] {
font-family: var(--font-mono);
font-size: var(--text-code);
}
[data-code] {
-webkit-overflow-scrolling: touch;
}
/* Mobile touch selection support */
[data-line-number] {
touch-action: manipulation;
@@ -72,36 +61,14 @@ const WEBKIT_SCROLL_FIX_CSS = `
pre[data-interactive-line-numbers] [data-line-number] {
touch-action: manipulation;
}
/* Reduce hunk separator height */
// [data-separator-content] {
// height: 24px !important;
// }
// [data-expand-button] {
// height: 24px !important;
// width: 24px !important;
// }
// [data-separator-multi-button] {
// row-gap: 0 !important;
// }
// [data-expand-up] {
// height: 12px !important;
// min-height: 12px !important;
// max-height: 12px !important;
// margin: 0 !important;
// margin-top: 3px !important;
// padding: 0 !important;
// border-radius: 4px 4px 0 0 !important;
// }
// [data-expand-down] {
// height: 12px !important;
// min-height: 12px !important;
// max-height: 12px !important;
// margin: 0 !important;
// margin-top: -3px !important;
// padding: 0 !important;
// border-radius: 0 0 4px 4px !important;
// }
`;
/* Match OpenCode hunk separator sizing */
[data-diff-header],
[data-diff] {
[data-separator] {
height: 24px !important;
}
}
`;
// Fast cache key - use length + samples instead of full hash
function fnv1a32(input: string): string {
@@ -259,7 +226,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
const selectionRef = useRef<SelectedLineRange | null>(null);
const editingDraftIdRef = useRef<string | null>(null);
// Use a ref to track if we're currently applying a selection programmatically
// to avoid loop with onLineSelected callback
const isApplyingSelectionRef = useRef(false);
@@ -314,33 +280,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return '';
}, []);
// Robust target resolver that checks shadow root, light DOM, and container
const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
if (!id || !diffContainerRef.current) return null;
const diffsContainer = diffContainerRef.current.querySelector('diffs-container');
if (!diffsContainer) return null;
// Try shadow root first
const shadowTarget = diffsContainer.shadowRoot?.querySelector(`[data-annotation-id="${id}"]`);
if (shadowTarget) return shadowTarget as HTMLElement;
// Try light DOM (slotted content)
const lightTarget = diffsContainer.querySelector(`[data-annotation-id="${id}"]`);
if (lightTarget) return lightTarget as HTMLElement;
// Try container directly
const containerTarget = diffContainerRef.current.querySelector(`[data-annotation-id="${id}"]`);
if (containerTarget) return containerTarget as HTMLElement;
return null;
}, []);
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
const div = document.createElement('div');
// Ensure full width and proper spacing
div.className = 'w-full my-2';
// Invisible — comments are rendered as floating elements outside shadow DOM
div.style.display = 'none';
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta);
@@ -348,6 +292,98 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return div;
}, [getAnnotationId]);
// Compute floating comment positions by finding target lines in Pierre's shadow DOM
const findLineElement = useCallback((root: ShadowRoot, line: number, side?: string) => {
const nodes = Array.from(
root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)
).filter((n): n is HTMLElement => n instanceof HTMLElement);
if (nodes.length === 0) return undefined;
if (!side) return nodes[0];
const match = nodes.find((n) => {
const lineType = n.closest('[data-line-type]')?.getAttribute('data-line-type') ?? n.getAttribute('data-line-type');
if (side === 'deletions') return lineType === 'change-deletion';
return lineType !== 'change-deletion';
});
return match ?? nodes[0];
}, []);
const getAnchorPositions = useCallback((wrapper: HTMLElement, root: ShadowRoot, range: { start: number; end: number; side?: string }) => {
const wrapperRect = wrapper.getBoundingClientRect();
const first = findLineElement(root, range.start, range.side);
const last = findLineElement(root, range.end, range.side);
// Bottom of last line (for below placement)
const lastEl = last ?? first;
const bottomTop = lastEl
? lastEl.getBoundingClientRect().top - wrapperRect.top + lastEl.getBoundingClientRect().height
: undefined;
// Top of first line (for above placement)
const firstEl = first ?? last;
const aboveTop = firstEl
? firstEl.getBoundingClientRect().top - wrapperRect.top
: undefined;
return { bottomTop, aboveTop };
}, [findLineElement]);
const [commentPositions, setCommentPositions] = useState<Record<string, { top: number; flipUp: boolean } | undefined>>({});
type CommentPos = { top: number; flipUp: boolean };
const COMMENT_POPOVER_HEIGHT = 200; // approximate height of comment popover
const updateCommentPositions = useCallback(() => {
const wrapper = diffRootRef.current;
if (!wrapper) return;
const host = wrapper.querySelector('diffs-container') ?? diffContainerRef.current?.querySelector('diffs-container');
const shadow = (host as HTMLElement | null)?.shadowRoot;
if (!shadow) return;
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
const viewportBottom = scrollContainer
? scrollContainer.getBoundingClientRect().bottom
: window.innerHeight;
const computePos = (range: { start: number; end: number; side?: string }): CommentPos | undefined => {
const anchors = getAnchorPositions(wrapper, shadow, range);
if (anchors.bottomTop === undefined) return undefined;
// Check if placing below last line would overflow viewport
const lastEl = findLineElement(shadow, range.end, range.side) ?? findLineElement(shadow, range.start, range.side);
const flipUp = lastEl
? (lastEl.getBoundingClientRect().bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom
: false;
return {
top: flipUp ? (anchors.aboveTop ?? anchors.bottomTop) : anchors.bottomTop,
flipUp,
};
};
const next: Record<string, CommentPos | undefined> = {};
const sessionKey = getSessionKey();
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
const fileLabel = fileName || 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
for (const d of fileDrafts) {
const side = d.side === 'original' ? 'deletions' : 'additions';
next[d.id] = computePos({ start: d.startLine, end: d.endLine, side });
}
if (selection && !editingDraftId) {
const side = selection.side ?? 'additions';
next['__new__'] = computePos({ start: selection.start, end: selection.end, side });
}
setCommentPositions(next);
}, [allDrafts, editingDraftId, fileName, findLineElement, getAnchorPositions, getSessionKey, selection]);
const updateCommentPositionsRef = useRef(updateCommentPositions);
useEffect(() => {
updateCommentPositionsRef.current = updateCommentPositions;
}, [updateCommentPositions]);
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
// Use provided range override or fall back to current selection
@@ -431,7 +467,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
const sharedVirtualizerRef = useRef<SharedVirtualizer | null>(null);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const workerPool = useWorkerPool();
const workerPool = useWorkerPool(renderSideBySide ? 'split' : 'unified');
const lightResolvedTheme = useMemo(() => getResolvedShikiTheme(lightTheme), [lightTheme]);
const darkResolvedTheme = useMemo(() => getResolvedShikiTheme(darkTheme), [darkTheme]);
@@ -519,7 +555,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
themeType: isDark ? ('dark' as const) : ('light' as const),
diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const),
diffIndicators: 'none' as const,
hunkSeparators: 'line-info' as const,
hunkSeparators: 'line-info-basic' as const,
// Perf: disable intra-line diff (word-level) globally.
lineDiffType: 'none' as const,
maxLineDiffLength: 1000,
@@ -631,8 +667,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
containerWrapper: container,
});
// Force update to render portals into new DOM elements created by Pierre
requestAnimationFrame(() => forceUpdate());
// Update floating comment positions after Pierre renders
requestAnimationFrame(() => {
forceUpdate();
updateCommentPositionsRef.current();
});
return () => {
instance.cleanUp();
@@ -660,8 +699,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
void err;
}
forceUpdate();
updateCommentPositions();
});
}, [lineAnnotations]);
}, [lineAnnotations, updateCommentPositions]);
useEffect(() => {
const instance = diffInstanceRef.current;
@@ -748,6 +788,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
};
}, [diffThemeKey, fileName, handleSelectionChange]);
useEffect(() => {
requestAnimationFrame(updateCommentPositions);
}, [selection, editingDraftId, allDrafts, updateCommentPositions]);
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
useEffect(() => {
const container = diffContainerRef.current;
@@ -772,13 +816,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
);
if (hasAnnotationChanges) {
// Debounce with RAF to batch multiple mutations
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
forceUpdate();
rafId = null;
});
}
// Debounce with RAF to batch multiple mutations
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
forceUpdate();
rafId = null;
});
}
});
// Observe both shadow root and light DOM
@@ -802,73 +846,87 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return null;
}
// Render portals for inline comments with robust target resolution
const portals = lineAnnotations.map((ann) => {
const meta = (ann as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta);
// Use robust resolver that checks shadow, light DOM, and container
const target = resolveAnnotationTarget(id);
// If target not found, skip rendering (will retry on next update cycle)
if (!target) {
return null;
}
// Floating comment elements positioned absolutely over the diff
const sessionKey = getSessionKey();
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
const fileLabel = fileName || 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
if (meta.type === 'saved') {
return createPortal(
<InlineCommentCard
key={id}
draft={meta.draft}
onEdit={() => {
const side = meta.draft.side === 'original' ? 'deletions' : 'additions';
applySelection({
start: meta.draft.startLine,
end: meta.draft.endLine,
side,
});
setCommentText(meta.draft.text);
setEditingDraftId(meta.draft.id);
}}
onDelete={() => removeDraft(meta.draft.sessionKey, meta.draft.id)}
/>,
target,
id
);
} else if (meta.type === 'edit') {
return createPortal(
<InlineCommentInput
key={id}
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={{
start: meta.draft.startLine,
end: meta.draft.endLine,
side: meta.draft.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>,
target,
id
);
} else {
return createPortal(
<InlineCommentInput
key={id}
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined}
isEditing={false}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>,
target,
id
);
}
});
const floatingComments = (
<>
{fileDrafts.map((d) => {
const pos = commentPositions[d.id];
if (!pos) return null;
const popoverStyle: React.CSSProperties = pos.flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
if (d.id === editingDraftId) {
return (
<div
key={`edit-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={popoverStyle}>
<InlineCommentInput
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={{
start: d.startLine,
end: d.endLine,
side: d.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
</div>
</div>
);
}
return (
<div
key={`saved-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
>
<InlineCommentCard
draft={d}
onEdit={() => {
const side = d.side === 'original' ? 'deletions' : 'additions';
applySelection({ start: d.startLine, end: d.endLine, side });
setCommentText(d.text);
setEditingDraftId(d.id);
}}
onDelete={() => removeDraft(d.sessionKey, d.id)}
/>
</div>
);
})}
{selection && !editingDraftId && commentPositions['__new__'] && (
<div
key="new-comment"
style={{ position: 'absolute', right: 24, top: commentPositions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={commentPositions['__new__'].flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
}>
<InlineCommentInput
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined}
isEditing={false}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
</div>
</div>
)}
</>
);
if (layout === 'fill') {
return (
@@ -882,7 +940,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
>
<div ref={diffRootRef} className="size-full relative">
<div ref={diffContainerRef} className="size-full" />
{portals}
{floatingComments}
</div>
</ScrollableOverlay>
</div>
@@ -895,7 +953,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
<div className={cn("relative", "w-full")}>
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
<div ref={diffContainerRef} className="w-full" />
{portals}
{floatingComments}
</div>
</div>
);
+27 -78
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { useFloatingComments } from '@/components/comments/useFloatingComments';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
@@ -130,6 +130,8 @@ export const PlanView: React.FC = () => {
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = React.useState('');
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
const editorViewRef = React.useRef<EditorView | null>(null);
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
@@ -377,88 +379,33 @@ export const PlanView: React.FC = () => {
};
}, []);
const blockWidgets = React.useMemo(() => {
if (mdViewMode === 'preview') return [];
const planFileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
const planFileDrafts = React.useMemo(() => {
const sessionKey = getSessionKey();
if (!sessionKey) return [];
const sessionDrafts = allDrafts[sessionKey] ?? [];
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === fileLabel);
return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel);
}, [getSessionKey, allDrafts, planFileLabel]);
const widgets: BlockWidgetDef[] = [];
// Add saved drafts
fileDrafts.forEach((draft) => {
if (draft.id === editingDraftId) {
// Always show edit input (even on mobile)
widgets.push({
afterLine: draft.endLine,
id: `edit-${draft.id}`,
content: (
<InlineCommentInput
fileLabel={fileLabel}
lineRange={{ start: draft.startLine, end: draft.endLine }}
initialText={commentText}
onSave={handleSaveComment}
onCancel={handleCancelComment}
isEditing={true}
/>
),
});
} else {
// Show saved cards on all devices
widgets.push({
afterLine: draft.endLine,
id: `draft-${draft.id}`,
content: (
<InlineCommentCard
draft={draft}
onEdit={() => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
}}
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
/>
),
});
}
});
// Add new comment input if selecting AND not editing an existing draft
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'plan-new-comment-input',
content: (
<InlineCommentInput
fileLabel={fileLabel}
lineRange={lineSelection}
initialText={commentText} // Usually empty for new, unless restored?
onSave={handleSaveComment}
onCancel={handleCancelComment}
isEditing={false}
/>
),
});
}
return widgets;
}, [
mdViewMode,
getSessionKey,
allDrafts,
displayPath,
const floatingComments = useFloatingComments({
editorView: editorViewRef.current,
wrapperRef: editorWrapperRef,
fileDrafts: planFileDrafts,
editingDraftId,
lineSelection,
commentText,
handleSaveComment,
handleCancelComment,
removeDraft,
lineSelection,
isDragging,
]);
fileLabel: planFileLabel,
onSaveComment: handleSaveComment,
onCancelComment: handleCancelComment,
onEditDraft: (draft) => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
},
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
});
return (
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
@@ -564,7 +511,7 @@ export const PlanView: React.FC = () => {
</ErrorBoundary>
</div>
) : (
<div className="relative h-full">
<div className="relative h-full" ref={editorWrapperRef}>
<CodeMirrorEditor
value={content}
onChange={() => {
@@ -573,13 +520,14 @@ export const PlanView: React.FC = () => {
readOnly={true}
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
extensions={editorExtensions}
onViewReady={(view) => { editorViewRef.current = view; }}
onViewDestroy={() => { editorViewRef.current = null; }}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
blockWidgets={blockWidgets}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view, line, event) => {
@@ -633,6 +581,7 @@ export const PlanView: React.FC = () => {
},
}}
/>
{floatingComments}
</div>
)}
</div>
@@ -31,10 +31,10 @@ export const PreviewToggleButton: React.FC<PreviewToggleButtonProps> = ({
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
size="sm"
onClick={onToggle}
aria-label={ariaLabel}
className="size-8"
className="h-5 w-5 p-0"
>
{isPreview ? (
<RiEyeLine className="size-4" aria-hidden="true" />
@@ -125,13 +125,33 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
>
{descriptor.code}
</span>
<span
className="flex-1 min-w-0 truncate typography-ui-label text-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
{(() => {
const lastSlash = file.path.lastIndexOf('/');
if (lastSlash === -1) {
return (
<span
className="flex-1 min-w-0 truncate typography-ui-label text-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
);
}
const dir = file.path.slice(0, lastSlash);
const name = file.path.slice(lastSlash);
return (
<span className="flex-1 min-w-0 flex items-baseline overflow-hidden" title={file.path}>
<span
className="min-w-0 truncate typography-ui-label text-muted-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
>
{dir}
</span>
<span className="flex-shrink-0 typography-ui-label"><span className="text-muted-foreground">/</span><span className="text-foreground">{name.slice(1)}</span></span>
</span>
);
})()}
<span className="shrink-0 typography-micro">
<span style={{ color: 'var(--status-success)' }}>+{insertions}</span>
<span className="text-muted-foreground mx-0.5">/</span>
@@ -158,6 +158,61 @@ type PullRequestDraftSnapshot = {
draft: boolean;
additionalContext: string;
targetBaseBranch?: string;
selectedRemoteName?: string;
};
const getTrackingRemoteName = (trackingBranch: string | null | undefined): string => {
const normalized = String(trackingBranch || '').trim();
if (!normalized) {
return '';
}
const slashIndex = normalized.indexOf('/');
if (slashIndex <= 0) {
return '';
}
return normalized.slice(0, slashIndex).trim();
};
const pickInitialPrRemote = (
remotes: GitRemote[],
options: { selectedRemoteName?: string; trackingBranch?: string }
): GitRemote | null => {
if (remotes.length === 0) {
return null;
}
const selectedRemoteName = String(options.selectedRemoteName || '').trim();
if (selectedRemoteName) {
const fromSnapshot = remotes.find((remote) => remote.name === selectedRemoteName);
if (fromSnapshot) {
return fromSnapshot;
}
}
const trackingRemoteName = getTrackingRemoteName(options.trackingBranch);
if (trackingRemoteName) {
const maybeUpstream =
trackingRemoteName === 'origin'
? remotes.find((remote) => remote.name === 'upstream')
: null;
if (maybeUpstream) {
return maybeUpstream;
}
const fromTracking = remotes.find((remote) => remote.name === trackingRemoteName);
if (fromTracking) {
return fromTracking;
}
}
const originRemote = remotes.find((remote) => remote.name === 'origin');
if (originRemote) {
return originRemote;
}
return remotes[0] ?? null;
};
type TimelineCommentItem = {
@@ -213,11 +268,12 @@ export const PullRequestSection: React.FC<{
directory: string;
branch: string;
baseBranch: string;
trackingBranch?: string;
remotes?: GitRemote[];
remoteBranches?: string[];
variant?: 'framed' | 'plain';
onGeneratedDescription?: () => void;
}> = ({ directory, branch, baseBranch, remotes = [], remoteBranches = [], variant = 'framed', onGeneratedDescription }) => {
}> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], variant = 'framed', onGeneratedDescription }) => {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -274,7 +330,12 @@ export const PullRequestSection: React.FC<{
const [isContextOpen, setIsContextOpen] = React.useState(false);
const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false);
const [selectedRemote, setSelectedRemote] = React.useState<GitRemote | null>(() => remotes[0] ?? null);
const [selectedRemote, setSelectedRemote] = React.useState<GitRemote | null>(() =>
pickInitialPrRemote(remotes, {
selectedRemoteName: initialSnapshot?.selectedRemoteName,
trackingBranch,
})
);
const availableBaseBranches = React.useMemo(() => {
const selectedRemoteName = selectedRemote?.name?.trim() || null;
@@ -305,10 +366,22 @@ export const PullRequestSection: React.FC<{
// Update selected remote when remotes change
React.useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
setSelectedRemote(remotes[0]);
if (remotes.length === 0) {
if (selectedRemote) {
setSelectedRemote(null);
}
return;
}
}, [remotes, selectedRemote]);
if (!selectedRemote || !remotes.some((remote) => remote.name === selectedRemote.name)) {
setSelectedRemote(
pickInitialPrRemote(remotes, {
selectedRemoteName: initialSnapshot?.selectedRemoteName,
trackingBranch,
})
);
}
}, [initialSnapshot?.selectedRemoteName, remotes, selectedRemote, trackingBranch]);
React.useEffect(() => {
const normalizedBase = normalizeBranchRef(baseBranch);
@@ -959,11 +1032,17 @@ export const PullRequestSection: React.FC<{
setBody(snapshot?.body ?? '');
setDraft(snapshot?.draft ?? false);
setTargetBaseBranch(snapshot?.targetBaseBranch ? normalizeBranchRef(snapshot.targetBaseBranch) : normalizeBranchRef(baseBranch));
setSelectedRemote(
pickInitialPrRemote(remotes, {
selectedRemoteName: snapshot?.selectedRemoteName,
trackingBranch,
})
);
setStatus(statusSnapshot);
setError(null);
setIsInitialStatusResolved(Boolean(statusSnapshot));
void refresh({ force: true, markInitialResolved: true });
}, [baseBranch, branch, refresh, snapshotKey]);
}, [baseBranch, branch, refresh, remotes, snapshotKey, trackingBranch]);
// Refetch when selected remote changes
React.useEffect(() => {
@@ -1033,8 +1112,9 @@ export const PullRequestSection: React.FC<{
draft,
additionalContext,
targetBaseBranch,
selectedRemoteName: selectedRemote?.name,
});
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, directory, branch]);
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]);
React.useEffect(() => {
if (!status) {
+75 -36
View File
@@ -1,6 +1,6 @@
import React, { useMemo, useEffect } from 'react';
import { WorkerPoolContextProvider, useWorkerPool } from '@pierre/diffs/react';
import type { SupportedLanguages } from '@pierre/diffs';
import { WorkerPoolManager } from '@pierre/diffs/worker';
import { useOptionalThemeSystem } from './useThemeSystem';
import { workerFactory } from '@/lib/diff/workerFactory';
@@ -23,30 +23,87 @@ interface DiffWorkerProviderProps {
children: React.ReactNode;
}
// Component that warms up the worker pool and precomputes diff ASTs
type WorkerPoolStyle = 'unified' | 'split';
const WORKER_POOL_CONFIG: Record<WorkerPoolStyle, { poolSize: number; totalASTLRUCacheSize: number; lineDiffType: 'none' | 'word-alt' }> = {
unified: {
poolSize: 1,
totalASTLRUCacheSize: 24,
lineDiffType: 'none',
},
split: {
poolSize: 2,
totalASTLRUCacheSize: 56,
lineDiffType: 'word-alt',
},
};
let unifiedWorkerPool: WorkerPoolManager | undefined;
let splitWorkerPool: WorkerPoolManager | undefined;
const createWorkerPool = (style: WorkerPoolStyle) => {
const config = WORKER_POOL_CONFIG[style];
const pool = new WorkerPoolManager(
{
workerFactory,
poolSize: config.poolSize,
totalASTLRUCacheSize: config.totalASTLRUCacheSize,
},
{
theme: {
light: 'pierre-light',
dark: 'pierre-dark',
},
langs: PRELOAD_LANGS,
lineDiffType: config.lineDiffType,
preferredHighlighter: 'shiki-wasm',
}
);
void pool.initialize();
return pool;
};
const getWorkerPool = (style: WorkerPoolStyle): WorkerPoolManager | undefined => {
if (typeof window === 'undefined') {
return undefined;
}
if (style === 'split') {
splitWorkerPool ??= createWorkerPool('split');
return splitWorkerPool;
}
unifiedWorkerPool ??= createWorkerPool('unified');
return unifiedWorkerPool;
};
const WorkerPoolWarmup: React.FC<{
children: React.ReactNode;
renderTheme: { light: string; dark: string };
}> = ({ children, renderTheme }) => {
const workerPool = useWorkerPool();
const unifiedPool = useWorkerPool('unified');
const splitPool = useWorkerPool('split');
useEffect(() => {
if (!workerPool) {
return;
if (unifiedPool) {
void unifiedPool.setRenderOptions({
theme: renderTheme,
lineDiffType: WORKER_POOL_CONFIG.unified.lineDiffType,
});
}
// Important: WorkerPoolContextProvider uses a singleton and does not react to
// prop changes. Update the worker pool render options explicitly.
// Force-disable intra-line diff globally (word-level/char-level).
void workerPool.setRenderOptions({ theme: renderTheme, lineDiffType: 'none' });
}, [renderTheme, workerPool]);
if (splitPool) {
void splitPool.setRenderOptions({
theme: renderTheme,
lineDiffType: WORKER_POOL_CONFIG.split.lineDiffType,
});
}
}, [renderTheme, splitPool, unifiedPool]);
return <>{children}</>;
};
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
@@ -64,15 +121,6 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
const highlighterOptions = useMemo(() => ({
theme: {
dark: darkTheme.metadata.id,
light: lightTheme.metadata.id,
},
themeType: isDark ? ('dark' as const) : ('light' as const),
langs: PRELOAD_LANGS,
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id]);
const renderTheme = useMemo(
() => ({
light: lightTheme.metadata.id,
@@ -82,22 +130,13 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
);
return (
<WorkerPoolContextProvider
poolOptions={{
workerFactory,
poolSize: 2,
totalASTLRUCacheSize: 50,
}}
highlighterOptions={highlighterOptions}
>
<WorkerPoolWarmup
renderTheme={renderTheme}
>
{children}
</WorkerPoolWarmup>
</WorkerPoolContextProvider>
<WorkerPoolWarmup renderTheme={renderTheme}>
{children}
</WorkerPoolWarmup>
);
};
// eslint-disable-next-line react-refresh/only-export-components
export { useWorkerPool };
export const useWorkerPool = (style: WorkerPoolStyle = 'unified'): WorkerPoolManager | undefined => {
return useMemo(() => getWorkerPool(style), [style]);
};
+44 -7
View File
@@ -640,19 +640,56 @@ export const useEventStream = () => {
case 'session.status':
{
const sessionId = readStringProp(props, ['sessionID', 'sessionId']);
const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record<string, unknown> : null;
const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null;
const statusInfo = statusObj ?? {};
const statusRaw = (props as { status?: unknown }).status;
const statusObj = (typeof statusRaw === 'object' && statusRaw !== null) ? statusRaw as Record<string, unknown> : null;
const statusType =
typeof statusRaw === 'string'
? statusRaw
: typeof statusObj?.type === 'string'
? statusObj.type
: typeof statusObj?.status === 'string'
? statusObj.status
: typeof (props as { type?: unknown }).type === 'string'
? ((props as { type: string }).type)
: typeof (props as { phase?: unknown }).phase === 'string'
? ((props as { phase: string }).phase)
: typeof (props as { state?: unknown }).state === 'string'
? ((props as { state: string }).state)
: null;
const statusInfo = statusObj ?? ({} as Record<string, unknown>);
const metadata = (props as { metadata?: unknown }).metadata;
const metadataObj = (typeof metadata === 'object' && metadata !== null) ? metadata as Record<string, unknown> : null;
if (sessionId && statusType) {
if (statusType === 'busy') {
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
} else if (statusType === 'retry') {
updateSessionStatus(sessionId, {
type: 'retry',
attempt: typeof statusInfo.attempt === 'number' ? statusInfo.attempt : undefined,
message: typeof statusInfo.message === 'string' ? statusInfo.message : undefined,
next: typeof statusInfo.next === 'number' ? statusInfo.next : undefined,
attempt:
typeof statusInfo.attempt === 'number'
? statusInfo.attempt
: typeof (props as { attempt?: unknown }).attempt === 'number'
? (props as { attempt: number }).attempt
: typeof metadataObj?.attempt === 'number'
? metadataObj.attempt
: undefined,
message:
typeof statusInfo.message === 'string'
? statusInfo.message
: typeof (props as { message?: unknown }).message === 'string'
? (props as { message: string }).message
: typeof metadataObj?.message === 'string'
? metadataObj.message
: undefined,
next:
typeof statusInfo.next === 'number'
? statusInfo.next
: typeof (props as { next?: unknown }).next === 'number'
? (props as { next: number }).next
: typeof metadataObj?.next === 'number'
? metadataObj.next
: undefined,
}, 'sse:session.status');
} else {
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status');
+50 -14
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { opencodeClient } from '@/lib/opencode/client';
interface SessionState {
status: 'idle' | 'busy' | 'retry';
@@ -70,20 +71,27 @@ export function useServerSessionStatus() {
lastSyncAtRef.current = now;
try {
const snapshotResponse = await fetch('/api/sessions/snapshot', {
method: 'GET',
cache: 'no-store',
headers: { Accept: 'application/json' },
});
const [snapshotResult, upstreamStatusResult] = await Promise.allSettled([
fetch('/api/sessions/snapshot', {
method: 'GET',
cache: 'no-store',
headers: { Accept: 'application/json' },
}).then(async (r) => {
if (!r.ok) {
throw new Error(String(r.status));
}
return (await r.json()) as ServerSnapshotResponse;
}),
opencodeClient.getGlobalSessionStatus(),
]);
if (!snapshotResponse.ok) {
console.warn('[useServerSessionStatus] Failed to fetch session snapshot:', snapshotResponse.status);
return;
}
const snapshotData: ServerSnapshotResponse | null =
snapshotResult.status === 'fulfilled' ? snapshotResult.value : null;
const statusSessions = snapshotData?.statusSessions ?? {};
const attentionSessions = snapshotData?.attentionSessions ?? {};
const snapshotData: ServerSnapshotResponse = await snapshotResponse.json();
const statusSessions = snapshotData.statusSessions ?? {};
const attentionSessions = snapshotData.attentionSessions ?? {};
const upstreamStatuses =
upstreamStatusResult.status === 'fulfilled' ? (upstreamStatusResult.value ?? {}) : {};
// Update the session store with server state
const currentStatuses = useSessionStore.getState().sessionStatus || new Map();
@@ -117,10 +125,37 @@ export function useServerSessionStatus() {
}
}
// Overlay OpenCode's own session status endpoint.
// This is the source-of-truth for retry message payload and works even when
// OpenChamber server-side tracking misses transient updates.
for (const [sessionId, upstream] of Object.entries(upstreamStatuses)) {
const existing = (newStatuses ?? currentStatuses).get(sessionId);
const hasChanged =
!existing ||
existing.type !== upstream.type ||
existing.attempt !== upstream.attempt ||
existing.message !== upstream.message ||
existing.next !== upstream.next;
if (hasChanged) {
ensureStatusesMap().set(sessionId, {
type: upstream.type,
confirmedAt: Date.now(),
attempt: upstream.attempt,
message: upstream.message,
next: upstream.next,
});
}
}
// Check for sessions that are no longer in server state (treat as idle)
const activeServerStatusIds = new Set(Object.keys(statusSessions));
const activeUpstreamIds = new Set(Object.keys(upstreamStatuses));
for (const [sessionId, currentStatus] of (newStatuses ?? currentStatuses)) {
if ((currentStatus.type === 'busy' || currentStatus.type === 'retry') &&
!statusSessions[sessionId]) {
!activeServerStatusIds.has(sessionId) &&
!activeUpstreamIds.has(sessionId)) {
// Session was busy but not in server state anymore -> mark as idle
ensureStatusesMap().set(sessionId, {
type: 'idle',
@@ -179,8 +214,9 @@ export function useServerSessionStatus() {
if (process.env.NODE_ENV === 'development') {
console.debug('[useServerSessionStatus] Updated session statuses from server:', {
statusCount: Object.keys(statusSessions).length,
upstreamCount: Object.keys(upstreamStatuses).length,
attentionCount: Object.keys(attentionSessions).length,
serverTime: snapshotData.serverTime,
serverTime: snapshotData?.serverTime,
});
}
} catch (error) {
+22 -21
View File
@@ -564,33 +564,25 @@ html:not(.dark) .chat-scroll {
/* Pierre diff viewer styling */
.pierre-diff-wrapper {
--diffs-font-family: var(--font-mono, 'IBM Plex Mono', monospace);
--diffs-font-size: var(--text-code, 13px);
--diffs-line-height: 1.5;
--diffs-font-size: var(--text-code);
--diffs-line-height: 24px;
--diffs-tab-size: 2;
--diffs-header-font-family: var(--font-sans, 'IBM Plex Sans', sans-serif);
--diffs-min-number-column-width: 4ch;
--diffs-gap-inline: 0;
--diffs-gap-block: 0;
/* Prevent browser scroll anchoring jumps during async highlight/virtualization */
overflow-anchor: none;
}
/* Prevent scroll anchoring in stacked diff scroll container */
[data-diff-virtual-root] {
overflow-anchor: none;
}
/* Desktop: slightly smaller diff code text */
:root:not(.mobile-pointer) .pierre-diff-wrapper {
--diffs-font-size: 0.8125rem;
}
/* WebKit scroll rendering optimizations */
/* Note: avoid will-change and contain as they break resize behavior */
.pierre-diff-wrapper,
.pierre-diff-wrapper diffs-container {
/* Force GPU compositing layer */
transform: translateZ(0);
-webkit-transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
/* WebKit touch scroll optimization */
-webkit-overflow-scrolling: touch;
}
.pierre-diff-wrapper diffs-container {
display: block;
@@ -686,6 +678,17 @@ html:not(.dark) .chat-scroll {
}
}
/* Utility: hide scrollbars without affecting layout */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* Git right-sidebar header/actions collapse to icon-only when narrow. */
@container git-header (max-width: 26rem) {
.git-header-label {
@@ -952,11 +955,9 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
@keyframes grid-pulse {
0%, 100% {
opacity: 0.2;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
+3
View File
@@ -143,6 +143,7 @@ export interface GitFileDiffResponse {
original: string;
modified: string;
path: string;
isBinary?: boolean;
}
export interface GetGitFileDiffOptions {
@@ -486,6 +487,8 @@ export interface ProjectEntry {
id: string;
path: string;
label?: string;
icon?: string | null;
color?: string | null;
addedAt?: number;
lastOpenedAt?: number;
sidebarCollapsed?: boolean;
+35 -12
View File
@@ -464,6 +464,7 @@ class OpencodeService {
'application/toml',
'application/x-sh',
'application/x-shellscript',
'application/octet-stream',
];
return textBasedTypes.includes(lowerMime);
@@ -683,21 +684,43 @@ class OpencodeService {
throw new Error('Message must have at least one part (text or file)');
}
// Use SDK session.prompt() method
// DON'T send messageID - let server generate it (fixes Claude empty response issue)
await this.client.session.prompt({
sessionID: params.id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
// messageID intentionally omitted - server will generate
model: {
providerID: params.providerID,
modelID: params.modelID
// Use async prompt endpoint so the client doesn't block waiting
// for model work (SSE will deliver output/status).
// This avoids 504s from proxy timeouts on long-running turns.
const base = this.baseUrl.replace(/\/+$/, '');
const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
if (this.currentDirectory) {
url.searchParams.set('directory', this.currentDirectory);
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
agent: params.agent,
variant: params.variant,
parts
body: JSON.stringify({
model: {
providerID: params.providerID,
modelID: params.modelID,
},
agent: params.agent,
variant: params.variant,
parts,
}),
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
// ignore
}
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
throw new Error(`Failed to send message (${response.status})${suffix}`);
}
// Return temporary ID for optimistic UI
// Real messageID will come from server via SSE events
return tempMessageId;
+64
View File
@@ -0,0 +1,64 @@
import {
RiCodeBoxLine,
RiTerminalBoxLine,
RiRocketLine,
RiFlaskLine,
RiGamepadLine,
RiBriefcaseLine,
RiHomeLine,
RiGlobalLine,
RiLeafLine,
RiShieldLine,
RiPaletteLine,
RiServerLine,
RiSmartphoneLine,
RiDatabase2Line,
RiLightbulbLine,
RiMusicLine,
RiCameraLine,
RiBookOpenLine,
RiHeartLine,
type RemixiconComponentType,
} from '@remixicon/react';
export const PROJECT_ICONS: Array<{ key: string; Icon: RemixiconComponentType; label: string }> = [
{ key: 'code', Icon: RiCodeBoxLine, label: 'Code' },
{ key: 'terminal', Icon: RiTerminalBoxLine, label: 'Terminal' },
{ key: 'rocket', Icon: RiRocketLine, label: 'Rocket' },
{ key: 'flask', Icon: RiFlaskLine, label: 'Lab' },
{ key: 'gamepad', Icon: RiGamepadLine, label: 'Game' },
{ key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
{ key: 'home', Icon: RiHomeLine, label: 'Home' },
{ key: 'globe', Icon: RiGlobalLine, label: 'Web' },
{ key: 'leaf', Icon: RiLeafLine, label: 'Nature' },
{ key: 'shield', Icon: RiShieldLine, label: 'Security' },
{ key: 'palette', Icon: RiPaletteLine, label: 'Design' },
{ key: 'server', Icon: RiServerLine, label: 'Server' },
{ key: 'phone', Icon: RiSmartphoneLine, label: 'Mobile' },
{ key: 'database', Icon: RiDatabase2Line, label: 'Data' },
{ key: 'lightbulb', Icon: RiLightbulbLine, label: 'Idea' },
{ key: 'music', Icon: RiMusicLine, label: 'Music' },
{ key: 'camera', Icon: RiCameraLine, label: 'Media' },
{ key: 'book', Icon: RiBookOpenLine, label: 'Docs' },
{ key: 'heart', Icon: RiHeartLine, label: 'Favorite' },
];
export const PROJECT_ICON_MAP: Record<string, RemixiconComponentType> = Object.fromEntries(
PROJECT_ICONS.map((i) => [i.key, i.Icon])
);
export const PROJECT_COLORS: Array<{ key: string; label: string; cssVar: string }> = [
{ key: 'keyword', label: 'Purple', cssVar: 'var(--syntax-keyword)' },
{ key: 'string', label: 'Green', cssVar: 'var(--syntax-string)' },
{ key: 'number', label: 'Pink', cssVar: 'var(--syntax-number)' },
{ key: 'type', label: 'Gold', cssVar: 'var(--syntax-type)' },
{ key: 'constant', label: 'Cyan', cssVar: 'var(--syntax-constant)' },
{ key: 'comment', label: 'Muted', cssVar: 'var(--syntax-comment)' },
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
{ key: 'primary', label: 'Blue', cssVar: 'var(--primary)' },
{ key: 'success', label: 'Green', cssVar: 'var(--status-success)' },
];
export const PROJECT_COLOR_MAP: Record<string, string> = Object.fromEntries(
PROJECT_COLORS.map((c) => [c.key, c.cssVar])
);
+1 -1
View File
@@ -1,6 +1,6 @@
export const SEMANTIC_TYPOGRAPHY = {
markdown: '0.9375rem',
code: '0.9063rem',
code: '0.8125rem',
uiHeader: '0.9375rem',
uiLabel: '0.8750rem',
meta: '0.875rem',
+10 -8
View File
@@ -25,7 +25,7 @@ interface DirectoryGitState {
branches: GitBranch | null;
log: GitLogResponse | null;
identity: GitIdentitySummary | null;
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>;
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
lastStatusFetch: number;
lastStatusChange: number;
lastLogFetch: number;
@@ -55,8 +55,8 @@ interface GitStore {
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean }) => Promise<void>;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string }) => void;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
clearDiffCache: (directory: string) => void;
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
@@ -72,6 +72,7 @@ interface GitFileDiffResponse {
original: string;
modified: string;
path: string;
isBinary?: boolean;
}
interface GitAPI {
@@ -98,10 +99,10 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
// LRU eviction helper for diff cache
const evictDiffCacheIfNeeded = (
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>,
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
maxEntries: number = DIFF_CACHE_MAX_ENTRIES,
maxTotalSize: number = DIFF_CACHE_MAX_TOTAL_SIZE_BYTES
): Map<string, { original: string; modified: string; fetchedAt: number }> => {
): Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }> => {
// Calculate total size
let totalSize = 0;
for (const entry of diffCache.values()) {
@@ -117,7 +118,7 @@ const evictDiffCacheIfNeeded = (
const entries = Array.from(diffCache.entries())
.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number }>();
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>();
let newTotalSize = 0;
// Keep entries from newest to oldest until limits are reached
@@ -441,6 +442,7 @@ export const useGitStore = create<GitStore>()(
// Pre-fetch all diffs so they're ready when user opens Diff tab
void get().fetchAllDiffs(directory, git);
},
getDiff: (directory, filePath) => {
@@ -481,7 +483,7 @@ export const useGitStore = create<GitStore>()(
if (limitedFilesToFetch.length === 0) return;
let nextIndex = 0;
const results: Array<{ path: string; diff: { original: string; modified: string } }> = [];
const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = [];
const takeNext = () => {
const current = nextIndex;
@@ -497,7 +499,7 @@ export const useGitStore = create<GitStore>()(
const response = await Promise.race([fetchPromise, timeoutPromise]);
return {
path: filePath,
diff: { original: response.original ?? '', modified: response.modified ?? '' },
diff: { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary },
};
};
+23 -1
View File
@@ -23,6 +23,7 @@ interface ProjectsStore {
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
renameProject: (id: string, label: string) => void;
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null }) => void;
reorderProjects: (fromIndex: number, toIndex: number) => void;
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
@@ -72,7 +73,8 @@ const deriveProjectLabel = (path: string): string => {
return 'Root';
}
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1] || normalized;
const raw = segments[segments.length - 1] || normalized;
return raw.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
const createProjectId = (): string => {
@@ -377,6 +379,26 @@ export const useProjectsStore = create<ProjectsStore>()(
persistProjects(nextProjects, activeProjectId);
},
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null }) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
const nextProjects = projects.map((project) => {
if (project.id !== id) return project;
const updated = { ...project };
if (meta.label !== undefined) {
const trimmed = meta.label.trim();
if (trimmed) updated.label = trimmed;
}
if (meta.icon !== undefined) updated.icon = meta.icon;
if (meta.color !== undefined) updated.color = meta.color;
return updated;
});
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
reorderProjects: (fromIndex: number, toIndex: number) => {
if (vscodeWorkspace) {
return;
+238 -75
View File
@@ -5,6 +5,17 @@ import { getSafeStorage } from './utils/safeStorage';
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
export type RightSidebarTab = 'git' | 'files';
export type ContextPanelMode = 'diff' | 'file';
type ContextPanelDirectoryState = {
isOpen: boolean;
expanded: boolean;
mode: ContextPanelMode | null;
targetPath: string | null;
width: number;
touchedAt: number;
};
export type MainTabGuard = (nextTab: MainTab) => boolean;
export type EventStreamStatus =
@@ -51,6 +62,71 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
);
};
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const LEFT_SIDEBAR_MIN_WIDTH = 300;
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
const normalizeDirectoryPath = (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 clampContextPanelWidth = (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 touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanelDirectoryState => {
if (prev) {
return { ...prev, touchedAt: Date.now() };
}
return {
isOpen: false,
expanded: false,
mode: null,
targetPath: null,
width: CONTEXT_PANEL_DEFAULT_WIDTH,
touchedAt: Date.now(),
};
};
const clampContextPanelRoots = (
byDirectory: Record<string, ContextPanelDirectoryState>,
maxRoots: number
): Record<string, ContextPanelDirectoryState> => {
const entries = Object.entries(byDirectory);
if (entries.length <= maxRoots) {
return byDirectory;
}
entries.sort((a, b) => (b[1]?.touchedAt ?? 0) - (a[1]?.touchedAt ?? 0));
const next: Record<string, ContextPanelDirectoryState> = {};
for (const [directory, state] of entries.slice(0, maxRoots)) {
next[directory] = state;
}
return next;
};
interface UIStore {
theme: 'light' | 'dark' | 'system';
@@ -62,7 +138,10 @@ interface UIStore {
isRightSidebarOpen: boolean;
rightSidebarWidth: number;
hasManuallyResizedRightSidebar: boolean;
rightSidebarTab: RightSidebarTab;
contextPanelByDirectory: Record<string, ContextPanelDirectoryState>;
isBottomTerminalOpen: boolean;
isBottomTerminalExpanded: boolean;
bottomTerminalHeight: number;
hasManuallyResizedBottomTerminal: boolean;
isSessionSwitcherOpen: boolean;
@@ -142,8 +221,15 @@ interface UIStore {
toggleRightSidebar: () => void;
setRightSidebarOpen: (open: boolean) => void;
setRightSidebarWidth: (width: number) => void;
setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextDiff: (directory: string, filePath: string) => void;
openContextFile: (directory: string, filePath: string) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, width: number) => void;
toggleBottomTerminal: () => void;
setBottomTerminalOpen: (open: boolean) => void;
setBottomTerminalExpanded: (expanded: boolean) => void;
setBottomTerminalHeight: (height: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
@@ -221,12 +307,15 @@ export const useUIStore = create<UIStore>()(
isMultiRunLauncherOpen: false,
multiRunLauncherPrefillPrompt: '',
isSidebarOpen: true,
sidebarWidth: 264,
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
hasManuallyResizedLeftSidebar: false,
isRightSidebarOpen: false,
rightSidebarWidth: 420,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
hasManuallyResizedRightSidebar: false,
rightSidebarTab: 'git',
contextPanelByDirectory: {},
isBottomTerminalOpen: false,
isBottomTerminalExpanded: false,
bottomTerminalHeight: 300,
hasManuallyResizedBottomTerminal: false,
isSessionSwitcherOpen: false,
@@ -303,12 +392,10 @@ export const useUIStore = create<UIStore>()(
set((state) => {
const newOpen = !state.isSidebarOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
if (newOpen && !state.hasManuallyResizedLeftSidebar) {
return {
isSidebarOpen: newOpen,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
};
}
return { isSidebarOpen: newOpen };
@@ -316,14 +403,11 @@ export const useUIStore = create<UIStore>()(
},
setSidebarOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
set((state) => {
if (open && !state.hasManuallyResizedLeftSidebar) {
return {
isSidebarOpen: open,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
};
}
return { isSidebarOpen: open };
@@ -338,12 +422,10 @@ export const useUIStore = create<UIStore>()(
set((state) => {
const newOpen = !state.isRightSidebarOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
if (newOpen && !state.hasManuallyResizedRightSidebar) {
return {
isRightSidebarOpen: newOpen,
rightSidebarWidth: proportionalWidth,
hasManuallyResizedRightSidebar: false,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return { isRightSidebarOpen: newOpen };
@@ -351,13 +433,11 @@ export const useUIStore = create<UIStore>()(
},
setRightSidebarOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
set((state) => {
if (open && !state.hasManuallyResizedRightSidebar) {
return {
isRightSidebarOpen: open,
rightSidebarWidth: proportionalWidth,
hasManuallyResizedRightSidebar: false,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return { isRightSidebarOpen: open };
@@ -368,6 +448,125 @@ export const useUIStore = create<UIStore>()(
set({ rightSidebarWidth: width, hasManuallyResizedRightSidebar: true });
},
setRightSidebarTab: (tab) => {
set({ rightSidebarTab: tab });
},
openContextDiff: (directory, filePath) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
isOpen: true,
mode: 'diff' as const,
targetPath: normalizedFilePath,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
get().setPendingDiffFile(normalizedFilePath);
},
openContextFile: (directory, filePath) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
isOpen: true,
mode: 'file' as const,
targetPath: normalizedFilePath,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
closeContextPanel: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
if (!prev || !prev.isOpen) {
return state;
}
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...touchContextPanelState(prev),
isOpen: false,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
toggleContextPanelExpanded: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
expanded: !current.expanded,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
setContextPanelWidth: (directory, width) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
width: clampContextPanelWidth(width),
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
toggleBottomTerminal: () => {
set((state) => {
const newOpen = !state.isBottomTerminalOpen;
@@ -400,6 +599,10 @@ export const useUIStore = create<UIStore>()(
});
},
setBottomTerminalExpanded: (expanded) => {
set({ isBottomTerminalExpanded: expanded });
},
setBottomTerminalHeight: (height) => {
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
},
@@ -420,36 +623,7 @@ export const useUIStore = create<UIStore>()(
if (guard && !guard(tab)) {
return;
}
const state = get();
const currentTab = state.activeMainTab;
const fullscreenTabs: MainTab[] = ['files', 'diff'];
const isEnteringFullscreen = fullscreenTabs.includes(tab) && !fullscreenTabs.includes(currentTab);
const isLeavingFullscreen = !fullscreenTabs.includes(tab) && fullscreenTabs.includes(currentTab);
if (isEnteringFullscreen) {
// Save current sidebar state and close it
set({
activeMainTab: tab,
sidebarOpenBeforeFullscreenTab: state.isSidebarOpen,
isSidebarOpen: false,
});
} else if (isLeavingFullscreen) {
// Restore sidebar state if it was open before
const shouldRestore = state.sidebarOpenBeforeFullscreenTab === true;
set({
activeMainTab: tab,
sidebarOpenBeforeFullscreenTab: null,
...(shouldRestore && typeof window !== 'undefined'
? {
isSidebarOpen: true,
sidebarWidth: Math.floor(window.innerWidth * 0.2),
hasManuallyResizedLeftSidebar: false,
}
: {}),
});
} else {
set({ activeMainTab: tab });
}
set({ activeMainTab: tab });
},
setPendingDiffFile: (filePath) => {
@@ -461,21 +635,7 @@ export const useUIStore = create<UIStore>()(
if (guard && !guard('diff')) {
return;
}
const state = get();
const currentTab = state.activeMainTab;
const fullscreenTabs: MainTab[] = ['files', 'diff'];
const isEnteringFullscreen = !fullscreenTabs.includes(currentTab);
if (isEnteringFullscreen) {
set({
pendingDiffFile: filePath,
activeMainTab: 'diff',
sidebarOpenBeforeFullscreenTab: state.isSidebarOpen,
isSidebarOpen: false,
});
} else {
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
}
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
@@ -770,14 +930,6 @@ export const useUIStore = create<UIStore>()(
set((state) => {
const updates: Partial<UIStore> = {};
if (state.isSidebarOpen && !state.hasManuallyResizedLeftSidebar) {
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
}
if (state.isRightSidebarOpen && !state.hasManuallyResizedRightSidebar) {
updates.rightSidebarWidth = Math.floor(window.innerWidth * 0.28);
}
if (state.isBottomTerminalOpen && !state.hasManuallyResizedBottomTerminal) {
updates.bottomTerminalHeight = Math.floor(window.innerHeight * 0.32);
}
@@ -865,7 +1017,7 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()),
version: 3,
version: 4,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState;
@@ -905,6 +1057,14 @@ export const useUIStore = create<UIStore>()(
delete state.memoryLimitActiveSession;
}
if (typeof state.rightSidebarTab !== 'string' || (state.rightSidebarTab !== 'git' && state.rightSidebarTab !== 'files')) {
state.rightSidebarTab = 'git';
}
if (!state.contextPanelByDirectory || typeof state.contextPanelByDirectory !== 'object') {
state.contextPanelByDirectory = {};
}
return state;
},
partialize: (state) => ({
@@ -913,7 +1073,10 @@ export const useUIStore = create<UIStore>()(
sidebarWidth: state.sidebarWidth,
isRightSidebarOpen: state.isRightSidebarOpen,
rightSidebarWidth: state.rightSidebarWidth,
rightSidebarTab: state.rightSidebarTab,
contextPanelByDirectory: state.contextPanelByDirectory,
isBottomTerminalOpen: state.isBottomTerminalOpen,
isBottomTerminalExpanded: state.isBottomTerminalExpanded,
bottomTerminalHeight: state.bottomTerminalHeight,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,
+1 -1
View File
@@ -18,7 +18,7 @@
/* Semantic typography defaults (must match SEMANTIC_TYPOGRAPHY) */
--text-markdown: 0.9375rem;
--text-code: 0.9063rem;
--text-code: 0.8125rem;
--text-ui-header: 0.9375rem;
--text-ui-label: 0.8750rem;
--text-meta: 0.875rem;