diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index f03f0557..85554d85 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -204,11 +204,11 @@ const ChatMessage: React.FC = ({ 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 = ({ const variants = model?.variants; return Boolean(variants && Object.keys(variants).length > 0); }, [isUser, modelID, providerID, providers]); - + const displayAgentName = useStickyDisplayValue(agentName); const displayProviderIDValue = useStickyDisplayValue(providerID ?? undefined); const displayModelName = useStickyDisplayValue(modelName); @@ -532,36 +532,36 @@ const ChatMessage: React.FC = ({ 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 = ({ 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 = ({ 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]); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index e8808f58..9bc1a7c6 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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 = ({ onMessageContentChange('permission'); }, [permissions, questions, onMessageContentChange]); - const displayMessages = React.useMemo(() => { + const baseDisplayMessages = React.useMemo(() => { const seenIds = new Set(); return messages .filter((message) => { @@ -238,6 +240,101 @@ const MessageList: React.FC = ({ }); }, [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), + 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(); diff --git a/packages/ui/src/components/comments/useFloatingComments.tsx b/packages/ui/src/components/comments/useFloatingComments.tsx new file mode 100644 index 00000000..ebf6ff6c --- /dev/null +++ b/packages/ui/src/components/comments/useFloatingComments.tsx @@ -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; + 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>({}); + + 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 = {}; + + 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 ( +
+
+ +
+
+ ); + } + + return ( +
+ onEditDraft(d)} + onDelete={() => onDeleteDraft(d)} + /> +
+ ); + })} + + {lineSelection && !editingDraftId && !isDragging && positions['__new__'] && ( +
+
+ +
+
+ )} + + ); +} diff --git a/packages/ui/src/components/layout/BottomTerminalDock.tsx b/packages/ui/src/components/layout/BottomTerminalDock.tsx index e1c13ffa..6751155a 100644 --- a/packages/ui/src/components/layout/BottomTerminalDock.tsx +++ b/packages/ui/src/components/layout/BottomTerminalDock.tsx @@ -15,9 +15,10 @@ interface BottomTerminalDockProps { export const BottomTerminalDock: React.FC = ({ 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(null); const [isResizing, setIsResizing] = React.useState(false); const dockRef = React.useRef(null); @@ -32,7 +33,6 @@ export const BottomTerminalDock: React.FC = ({ isOpen, React.useEffect(() => { if (!isOpen) { - setIsFullscreen(false); setFullscreenHeight(null); setIsResizing(false); } @@ -118,14 +118,14 @@ export const BottomTerminalDock: React.FC = ({ 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 ( diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx new file mode 100644 index 00000000..9b56594b --- /dev/null +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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(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) => { + 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' + ? + : panelState?.mode === 'file' + ? + : null; + + const header = ( +
+
+ {panelTitle} + {pathLabel ? {pathLabel} : null} +
+ + +
+ ); + + if (!isOpen) { + return null; + } + + return ( + + ); +}; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index bdff17a7..0a67fd38 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -4,6 +4,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { toast } from '@/components/ui'; import { DropdownMenu, DropdownMenuContent, @@ -14,7 +15,7 @@ import { } from '@/components/ui/dropdown-menu'; import { AnimatedTabs } from '@/components/ui/animated-tabs'; -import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiFolderAddLine, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiMore2Fill, RiPencilLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; @@ -22,11 +23,12 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { useDeviceInfo } from '@/lib/device'; -import { cn, hasModifier } from '@/lib/utils'; +import { cn, hasModifier, formatDirectoryName } from '@/lib/utils'; import { useDiffFileCount } from '@/components/views/DiffView'; import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; @@ -49,10 +51,37 @@ import { import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'; import type { UsageWindow } from '@/types'; import type { GitHubAuthStatus } from '@/lib/api/types'; +import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; -import { isDesktopShell } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; +import { sessionEvents } from '@/lib/sessionEvents'; import { desktopHostsGet } from '@/lib/desktopHosts'; +import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; +import { GridLoader } from '@/components/ui/grid-loader'; +import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta'; + +const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); + +const getAttentionDiamondDelay = (index: number): string => { + return index === 4 ? '0ms' : '130ms'; +}; + +const isSameContextUsage = ( + a: SessionContextUsage | null, + b: SessionContextUsage | null, +): boolean => { + if (a === b) return true; + if (!a || !b) return false; + + return a.totalTokens === b.totalTokens + && a.percentage === b.percentage + && a.contextLimit === b.contextLimit + && (a.outputLimit ?? 0) === (b.outputLimit ?? 0) + && (a.normalizedOutput ?? 0) === (b.normalizedOutput ?? 0) + && a.thresholdLimit === b.thresholdLimit + && (a.lastMessageId ?? '') === (b.lastMessageId ?? ''); +}; const formatTime = (timestamp: number | null) => { if (!timestamp) return '-'; @@ -121,7 +150,18 @@ export const Header: React.FC = () => { const getContextUsage = useSessionStore((state) => state.getContextUsage); const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionMessages = useSessionStore((state) => { + if (!currentSessionId) { + return undefined; + } + return state.messages.get(currentSessionId); + }); const sessions = useSessionStore((state) => state.sessions); + const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); + const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); + const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); + const sessionStatus = useSessionStore((state) => state.sessionStatus); + const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); const quotaResults = useQuotaStore((state) => state.results); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); @@ -131,6 +171,13 @@ export const Header: React.FC = () => { const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const setActiveProject = useProjectsStore((state) => state.setActiveProject); + const reorderProjects = useProjectsStore((state) => state.reorderProjects); + const addProject = useProjectsStore((state) => state.addProject); + const removeProject = useProjectsStore((state) => state.removeProject); + const { isMobile } = useDeviceInfo(); const diffFileCount = useDiffFileCount(); const updateAvailable = useUpdateStore((state) => state.available); @@ -193,6 +240,25 @@ export const Header: React.FC = () => { const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0); const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0); const contextUsage = getContextUsage(contextLimit, outputLimit); + const [stableDesktopContextUsage, setStableDesktopContextUsage] = React.useState(null); + const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessages !== undefined; + + useEffect(() => { + if (!currentSessionId) { + setStableDesktopContextUsage((prev) => (prev === null ? prev : null)); + return; + } + + if (contextUsage && contextUsage.totalTokens > 0) { + setStableDesktopContextUsage((prev) => (isSameContextUsage(prev, contextUsage) ? prev : contextUsage)); + return; + } + + if (isContextUsageResolvedForSession) { + setStableDesktopContextUsage((prev) => (prev === null ? prev : null)); + } + }, [contextUsage, currentSessionId, isContextUsageResolvedForSession]); + const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const githubAvatarUrl = githubAuthStatus?.connected ? githubAuthStatus.user?.avatarUrl : null; const githubLogin = githubAuthStatus?.connected ? githubAuthStatus.user?.login : null; @@ -211,6 +277,250 @@ export const Header: React.FC = () => { } }, [desktopServicesTab, isDesktopApp]); + // --- Project tabs state (desktop, non-vscode only) --- + const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); + const showProjectTabs = !isMobile && !isVSCode && projects.length > 0; + const showDesktopHeaderContextUsage = !isVSCode && activeMainTab === 'chat' && !!stableDesktopContextUsage && stableDesktopContextUsage.totalTokens > 0; + const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); + + const [editingProject, setEditingProject] = React.useState<{ id: string; name: string; path: string; icon?: string | null; color?: string | null } | null>(null); + const [projectTabMenuOpen, setProjectTabMenuOpen] = React.useState(null); + const projectTabsScrollRef = React.useRef(null); + const projectTabsContainerRef = React.useRef(null); + const projectTabIndicatorRef = React.useRef(null); + const projectTabRefs = React.useRef>(new Map()); + const [projectTabsReady, setProjectTabsReady] = React.useState(false); + const [projectTabsOverflow, setProjectTabsOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false }); + + // --- Pointer-based drag reorder state --- + const dragStateRef = React.useRef<{ + projectId: string; + startX: number; + startY: number; + pointerId: number; + active: boolean; + overlay: HTMLDivElement | null; + sourceRect: DOMRect | null; + // Immutable after drag activation — never re-read from DOM + tabWidths: Map; + layoutOriginX: number; // left edge of first tab + gap: number; + // Mutable virtual layout + virtualRects: Array<{ id: string; left: number; right: number; centerX: number; width: number }>; + currentOrder: string[]; + originalOrder: string[]; + scrollInterval: ReturnType | null; + lastClientX: number; + } | null>(null); + const [draggingProjectId, setDraggingProjectId] = React.useState(null); + const [dragCurrentOrder, setDragCurrentOrder] = React.useState(null); + + /** Lay out tabs left-to-right using fixed widths. Pure computation, no DOM reads. */ + const computeVirtualRects = React.useCallback( + (order: string[], widths: Map, originX: number, gap: number) => { + let x = originX; + return order.map((id) => { + const w = widths.get(id) ?? 0; + const rect = { id, left: x, right: x + w, centerX: x + w / 2, width: w }; + x += w + gap; + return rect; + }); + }, + [] + ); + + const formatProjectTabLabel = React.useCallback((project: { label?: string; path: string }): string => { + return project.label?.trim() + || formatDirectoryName(project.path, homeDirectory) + || project.path; + }, [homeDirectory]); + + const updateProjectTabsOverflow = React.useCallback(() => { + const el = projectTabsScrollRef.current; + if (!el) return; + setProjectTabsOverflow({ + left: el.scrollLeft > 2, + right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2, + }); + }, []); + + const updateProjectTabIndicator = React.useCallback(() => { + const container = projectTabsContainerRef.current; + const indicator = projectTabIndicatorRef.current; + if (!container || !indicator || !activeProjectId) return; + // Hide indicator when the active tab itself is being dragged + if (draggingProjectId === activeProjectId) { + indicator.style.opacity = '0'; + return; + } + const activeTab = projectTabRefs.current.get(activeProjectId); + if (!activeTab) { + indicator.style.opacity = '0'; + return; + } + const containerRect = container.getBoundingClientRect(); + const tabRect = activeTab.getBoundingClientRect(); + const indicatorX = Math.round(tabRect.left - containerRect.left); + const indicatorWidth = Math.round(tabRect.width); + indicator.style.transform = `translateX(${indicatorX}px)`; + indicator.style.width = `${indicatorWidth}px`; + indicator.style.opacity = '1'; + }, [activeProjectId, draggingProjectId]); + + // Track metadata that affects tab width (label, icon) to re-measure indicator + const projectTabMeta = React.useMemo( + () => projects.map((p) => `${p.id}:${p.label ?? ''}:${p.icon ?? ''}`).join('|'), + [projects] + ); + + const projectTabSessionIndicators = React.useMemo(() => { + const result = new Map(); + if (!showProjectTabs || projects.length === 0) { + return result; + } + + for (const project of projects) { + const projectRoot = normalize(project.path); + if (!projectRoot) { + result.set(project.id, { hasStreaming: false, hasNeedsAttention: false }); + continue; + } + + const dirs: string[] = [projectRoot]; + const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; + for (const meta of worktrees) { + const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; + if (typeof p === 'string' && p.trim()) { + const normalized = normalize(p); + if (normalized && normalized !== projectRoot) { + dirs.push(normalized); + } + } + } + + const seen = new Set(); + let hasStreaming = false; + let hasNeedsAttention = false; + + for (const dir of dirs) { + const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir); + for (const session of list) { + if (!session?.id || seen.has(session.id)) { + continue; + } + seen.add(session.id); + + const statusType = sessionStatus?.get(session.id)?.type ?? 'idle'; + if (statusType === 'busy' || statusType === 'retry') { + hasStreaming = true; + } + + if (session.id !== currentSessionId && sessionAttentionStates.get(session.id)?.needsAttention === true) { + hasNeedsAttention = true; + } + + if (hasStreaming && hasNeedsAttention) { + break; + } + } + if (hasStreaming && hasNeedsAttention) { + break; + } + } + + result.set(project.id, { hasStreaming, hasNeedsAttention }); + } + + return result; + }, [availableWorktreesByProject, currentSessionId, getSessionsByDirectory, projects, sessionAttentionStates, sessionStatus, sessionsByDirectory, showProjectTabs]); + + React.useLayoutEffect(() => { + if (!showProjectTabs) return; + updateProjectTabIndicator(); + if (!projectTabsReady) { + setProjectTabsReady(true); + } + }, [showProjectTabs, updateProjectTabIndicator, projectTabsReady, activeProjectId, projectTabMeta]); + + React.useEffect(() => { + if (!showProjectTabs) return; + const ro = new ResizeObserver(() => updateProjectTabIndicator()); + const container = projectTabsContainerRef.current; + if (container) ro.observe(container); + // Also observe the active tab element for size changes + if (activeProjectId) { + const activeTab = projectTabRefs.current.get(activeProjectId); + if (activeTab) ro.observe(activeTab); + } + return () => ro.disconnect(); + }, [showProjectTabs, updateProjectTabIndicator, activeProjectId]); + + React.useEffect(() => { + const el = projectTabsScrollRef.current; + if (!el || !showProjectTabs) return; + updateProjectTabsOverflow(); + el.addEventListener('scroll', updateProjectTabsOverflow, { passive: true }); + const ro = new ResizeObserver(updateProjectTabsOverflow); + ro.observe(el); + return () => { + el.removeEventListener('scroll', updateProjectTabsOverflow); + ro.disconnect(); + }; + }, [showProjectTabs, updateProjectTabsOverflow, projects.length]); + + const handleAddProject = React.useCallback(() => { + if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) { + sessionEvents.requestDirectoryDialog(); + return; + } + import('@/lib/desktop') + .then(({ requestDirectoryAccess }) => requestDirectoryAccess('')) + .then((result) => { + if (result.success && result.path) { + const added = addProject(result.path, { id: result.projectId }); + if (!added) { + toast.error('Failed to add project', { + description: 'Please select a valid directory.', + }); + } + } else if (result.error && result.error !== 'Directory selection cancelled') { + toast.error('Failed to select directory', { + description: result.error, + }); + } + }) + .catch((error) => { + console.error('Failed to select directory:', error); + toast.error('Failed to select directory'); + }); + }, [addProject, tauriIpcAvailable]); + + const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta); + + const handleOpenProjectEdit = React.useCallback((projectId: string) => { + const project = projects.find((p) => p.id === projectId); + if (!project) return; + setEditingProject({ + id: project.id, + name: formatProjectTabLabel(project), + path: project.path, + icon: project.icon, + color: project.color, + }); + setProjectTabMenuOpen(null); + }, [projects, formatProjectTabLabel]); + + const handleSaveProjectEdit = React.useCallback((data: { label: string; icon: string | null; color: string | null }) => { + if (!editingProject) return; + updateProjectMeta(editingProject.id, data); + setEditingProject(null); + }, [editingProject, updateProjectMeta]); + + const handleCloseProject = React.useCallback((projectId: string) => { + removeProject(projectId); + setProjectTabMenuOpen(null); + }, [removeProject]); + const refreshCurrentInstanceLabel = React.useCallback(async () => { if (typeof window === 'undefined' || !isDesktopApp) { return; @@ -640,24 +950,13 @@ export const Header: React.FC = () => { }, [updateHeaderHeight, isMobile, macosHeaderSizeClass]); const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) { + const target = e.target as HTMLElement; + if (target.closest('.app-region-no-drag')) { return; } - if (e.button !== 0) { + if (target.closest('button, a, input, select, textarea')) { return; } - if (isDesktopApp) { - try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const window = getCurrentWindow(); - await window.startDragging(); - } catch (error) { - console.error('Failed to start window dragging:', error); - } - } - }, [isDesktopApp]); - - const handleActiveTabDragStart = React.useCallback(async (e: React.MouseEvent) => { if (e.button !== 0) { return; } @@ -681,34 +980,33 @@ export const Header: React.FC = () => { base.push({ id: 'plan', label: 'Plan', icon: RiFileTextLine }); } - base.push( - { - id: 'diff', - label: 'Diff', - icon: 'diff', - badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined, - }, - { id: 'files', label: 'Files', icon: RiFolder6Line }, - ); - if (isMobile) { - base.push({ - id: 'terminal', - label: 'Terminal', - icon: RiTerminalBoxLine, - }, { - id: 'git', - label: 'Git', - icon: RiGitBranchLine, - showDot: diffFileCount > 0, - }); + base.push( + { + id: 'diff', + label: 'Diff', + icon: 'diff', + }, + { id: 'files', label: 'Files', icon: RiFolder6Line }, + { + id: 'terminal', + label: 'Terminal', + icon: RiTerminalBoxLine, + }, + { + id: 'git', + label: 'Git', + icon: RiGitBranchLine, + showDot: diffFileCount > 0, + }, + ); } return base; }, [diffFileCount, isMobile, showPlanTab]); useEffect(() => { - if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal')) { + if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files')) { setActiveMainTab('chat'); } }, [activeMainTab, isMobile, setActiveMainTab]); @@ -736,6 +1034,17 @@ export const Header: React.FC = () => { const handleKeyDown = (e: KeyboardEvent) => { if (hasModifier(e) && !e.shiftKey && !e.altKey) { const num = parseInt(e.key, 10); + if (showProjectTabs) { + if (num >= 1 && num <= projects.length) { + e.preventDefault(); + const targetProject = projects[num - 1]; + if (targetProject && targetProject.id !== activeProjectId) { + setActiveProject(targetProject.id); + } + } + return; + } + if (num >= 1 && num <= tabs.length) { e.preventDefault(); setActiveMainTab(tabs[num - 1].id); @@ -744,14 +1053,13 @@ export const Header: React.FC = () => { }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [tabs, setActiveMainTab]); + }, [tabs, setActiveMainTab, showProjectTabs, projects, activeProjectId, setActiveProject]); const renderTab = (tab: TabConfig) => { const isActive = activeMainTab === tab.id; const isDiffTab = tab.icon === 'diff'; const Icon = isDiffTab ? null : (tab.icon as RemixiconComponentType); const isChatTab = tab.id === 'chat'; - const showContextTooltip = isChatTab && !isMobile && contextUsage && contextUsage.totalTokens > 0; const renderIcon = (iconSize: number) => { if (isDiffTab) { @@ -760,25 +1068,18 @@ export const Header: React.FC = () => { return Icon ? : null; }; - const formatTokens = (tokens: number) => { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`; - return tokens.toFixed(1).replace(/\.0$/, ''); - }; - const tabButton = ( ); - if (showContextTooltip) { - const safeOutputLimit = typeof contextUsage.outputLimit === 'number' ? Math.max(contextUsage.outputLimit, 0) : 0; - return ( - - - {tabButton} - - -
-

Used tokens: {formatTokens(contextUsage.totalTokens)}

-

Context limit: {formatTokens(contextUsage.contextLimit)}

-

Output limit: {formatTokens(safeOutputLimit)}

-
-
-
- ); - } - return {tabButton}; }; + // --- Pointer-based drag reorder handlers --- + const DRAG_DEAD_ZONE = 5; + + const cleanupDrag = React.useCallback(() => { + const ds = dragStateRef.current; + if (!ds) return; + if (ds.overlay && ds.overlay.parentNode) { + ds.overlay.parentNode.removeChild(ds.overlay); + } + if (ds.scrollInterval) { + clearInterval(ds.scrollInterval); + } + dragStateRef.current = null; + setDraggingProjectId(null); + setDragCurrentOrder(null); + }, []); + + // Cleanup overlay on unmount + Escape to cancel drag + React.useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && dragStateRef.current) { + // Reset order to original (don't commit) + cleanupDrag(); + } + }; + window.addEventListener('keydown', handleEscape); + return () => { + window.removeEventListener('keydown', handleEscape); + const ds = dragStateRef.current; + if (ds?.overlay?.parentNode) { + ds.overlay.parentNode.removeChild(ds.overlay); + } + if (ds?.scrollInterval) { + clearInterval(ds.scrollInterval); + } + dragStateRef.current = null; + }; + }, [cleanupDrag]); + + const commitDragOrder = React.useCallback(() => { + const ds = dragStateRef.current; + if (!ds) return; + const { originalOrder, currentOrder } = ds; + // Find the dragged project's positions in original vs current order + if (JSON.stringify(originalOrder) !== JSON.stringify(currentOrder)) { + const fromIndex = originalOrder.indexOf(ds.projectId); + const toIndex = currentOrder.indexOf(ds.projectId); + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + reorderProjects(fromIndex, toIndex); + } + } + }, [reorderProjects]); + + const handleProjectTabPointerDown = React.useCallback((e: React.PointerEvent, projectId: string) => { + // Don't start drag from buttons (dropdown trigger, etc.) + const target = e.target as HTMLElement; + if (target.closest('button')) return; + // Only primary button + if (e.button !== 0) return; + // Don't start if a menu is open + if (projectTabMenuOpen) return; + + const tabEl = projectTabRefs.current.get(projectId); + if (!tabEl) return; + + const currentProjectIds = projects.map((p) => p.id); + + dragStateRef.current = { + projectId, + startX: e.clientX, + startY: e.clientY, + pointerId: e.pointerId, + active: false, + overlay: null, + sourceRect: null, + tabWidths: new Map(), + layoutOriginX: 0, + gap: 2, + virtualRects: [], + currentOrder: [...currentProjectIds], + originalOrder: [...currentProjectIds], + scrollInterval: null, + lastClientX: e.clientX, + }; + + tabEl.setPointerCapture(e.pointerId); + }, [projectTabMenuOpen, projects]); + + const handleProjectTabPointerMove = React.useCallback((e: React.PointerEvent) => { + const ds = dragStateRef.current; + if (!ds) return; + + const dx = e.clientX - ds.startX; + const dy = e.clientY - ds.startY; + + if (!ds.active) { + // Check dead zone + if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return; + + // Activate drag + ds.active = true; + setDraggingProjectId(ds.projectId); + + const sourceEl = projectTabRefs.current.get(ds.projectId); + if (!sourceEl) { cleanupDrag(); return; } + + ds.sourceRect = sourceEl.getBoundingClientRect(); + + // Snapshot widths once — these never change for the duration of the drag + const widths = new Map(); + for (const id of ds.currentOrder) { + const el = projectTabRefs.current.get(id); + if (el) widths.set(id, el.getBoundingClientRect().width); + } + ds.tabWidths = widths; + + // Compute layout origin and gap from DOM (one-time read) + const firstEl = projectTabRefs.current.get(ds.currentOrder[0]); + ds.layoutOriginX = firstEl ? firstEl.getBoundingClientRect().left : ds.sourceRect.left; + if (ds.currentOrder.length >= 2) { + const a = projectTabRefs.current.get(ds.currentOrder[0]); + const b = projectTabRefs.current.get(ds.currentOrder[1]); + if (a && b) { + ds.gap = Math.max(0, b.getBoundingClientRect().left - a.getBoundingClientRect().right); + } + } + + // Build initial virtual rects + ds.virtualRects = computeVirtualRects(ds.currentOrder, ds.tabWidths, ds.layoutOriginX, ds.gap); + + // Create overlay clone + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + overlay.style.zIndex = '99999'; + overlay.style.pointerEvents = 'none'; + overlay.style.width = `${ds.sourceRect.width}px`; + overlay.style.height = `${ds.sourceRect.height}px`; + overlay.style.left = `${ds.sourceRect.left}px`; + overlay.style.top = `${ds.sourceRect.top}px`; + overlay.style.transition = 'box-shadow 150ms ease'; + overlay.style.boxShadow = '0 4px 16px rgba(0,0,0,0.18), 0 1px 4px rgba(0,0,0,0.10)'; + overlay.style.willChange = 'transform'; + overlay.style.cursor = 'grabbing'; + + // Clone visual content + overlay.innerHTML = sourceEl.innerHTML; + // Copy computed styles for visual fidelity + const computed = getComputedStyle(sourceEl); + overlay.style.borderRadius = computed.borderRadius; + overlay.style.display = computed.display; + overlay.style.alignItems = computed.alignItems; + overlay.style.gap = computed.gap; + overlay.style.padding = computed.padding; + overlay.style.fontSize = computed.fontSize; + overlay.style.fontWeight = computed.fontWeight; + overlay.style.fontFamily = computed.fontFamily; + overlay.style.color = computed.color; + overlay.style.backgroundColor = 'var(--surface-elevated)'; + overlay.style.border = '1px solid var(--interactive-border)'; + overlay.style.boxSizing = 'border-box'; + overlay.style.whiteSpace = 'nowrap'; + overlay.style.opacity = '1'; + const isDraggedActive = ds.projectId === activeProjectId; + if (isDraggedActive) { + overlay.style.outline = '1px solid var(--interactive-border)'; + overlay.style.outlineOffset = '-1px'; + } else { + overlay.style.outline = 'none'; + overlay.style.outlineOffset = '0'; + } + + document.body.appendChild(overlay); + ds.overlay = overlay; + + // Set current order in state for rendering + setDragCurrentOrder([...ds.currentOrder]); + + // Auto-scroll setup + const scrollEl = projectTabsScrollRef.current; + if (scrollEl) { + ds.scrollInterval = setInterval(() => { + const state = dragStateRef.current; + if (!state) return; + const scrollRect = scrollEl.getBoundingClientRect(); + const edgeZone = 40; + if (state.lastClientX < scrollRect.left + edgeZone && scrollEl.scrollLeft > 0) { + scrollEl.scrollLeft -= 4; + } else if (state.lastClientX > scrollRect.right - edgeZone && scrollEl.scrollLeft + scrollEl.clientWidth < scrollEl.scrollWidth) { + scrollEl.scrollLeft += 4; + } + }, 16); + } + } + + // Track cursor position for auto-scroll + ds.lastClientX = e.clientX; + + // Move overlay — clamped to the visible tabs container + if (ds.overlay && ds.sourceRect) { + let offsetX = e.clientX - ds.startX; + const scrollEl = projectTabsScrollRef.current; + if (scrollEl) { + const bounds = scrollEl.getBoundingClientRect(); + const overlayLeft = ds.sourceRect.left + offsetX; + const clampedLeft = Math.max(bounds.left, Math.min(overlayLeft, bounds.right - ds.sourceRect.width)); + offsetX = clampedLeft - ds.sourceRect.left; + } + ds.overlay.style.transform = `translate(${offsetX}px, 0px) scale(1.03)`; + } + + // Determine new order via virtual rects (no DOM reads — fully deterministic) + if (ds.virtualRects.length > 0) { + const cursorX = e.clientX; + const draggedIdx = ds.currentOrder.indexOf(ds.projectId); + if (draggedIdx === -1) return; + + // Hysteresis: require cursor to pass center ± margin to prevent borderline oscillation + const HYSTERESIS = 6; + let targetIdx = draggedIdx; + for (let i = 0; i < ds.virtualRects.length; i++) { + if (ds.currentOrder[i] === ds.projectId) continue; + const rect = ds.virtualRects[i]; + if (i < draggedIdx && cursorX < rect.centerX - HYSTERESIS) { + targetIdx = i; + break; + } + if (i > draggedIdx && cursorX > rect.centerX + HYSTERESIS) { + targetIdx = i; + } + } + + if (targetIdx !== draggedIdx) { + const newOrder = [...ds.currentOrder]; + const [moved] = newOrder.splice(draggedIdx, 1); + newOrder.splice(targetIdx, 0, moved); + ds.currentOrder = newOrder; + + // Recompute virtual rects from fixed widths — deterministic, no DOM + ds.virtualRects = computeVirtualRects(newOrder, ds.tabWidths, ds.layoutOriginX, ds.gap); + + setDragCurrentOrder([...newOrder]); + } + } + }, [activeProjectId, cleanupDrag, computeVirtualRects]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleProjectTabPointerUp = React.useCallback((_e: React.PointerEvent) => { + const ds = dragStateRef.current; + if (!ds) return; + + const tabEl = projectTabRefs.current.get(ds.projectId); + if (tabEl) { + try { tabEl.releasePointerCapture(ds.pointerId); } catch { /* ignore */ } + } + + if (ds.active) { + commitDragOrder(); + } else { + // It was a click, not a drag — activate the project + if (ds.projectId !== activeProjectId) { + setActiveProject(ds.projectId); + } + } + + cleanupDrag(); + }, [activeProjectId, cleanupDrag, commitDragOrder, setActiveProject]); + + const handleProjectTabPointerCancel = React.useCallback(() => { + cleanupDrag(); + }, [cleanupDrag]); + + // Determine the display order of projects (reordered during drag, normal otherwise) + const displayProjects = React.useMemo(() => { + if (!dragCurrentOrder) return projects; + // Map the order of IDs to actual project objects + const projectMap = new Map(projects.map((p) => [p.id, p])); + return dragCurrentOrder.map((id) => projectMap.get(id)).filter(Boolean) as typeof projects; + }, [dragCurrentOrder, projects]); + const renderDesktop = () => (
{ type="button" onClick={handleOpenSessionSwitcher} aria-label="Open sessions" - className={`${headerIconButtonClass} mr-2`} + className={`${headerIconButtonClass} mr-2 shrink-0`} > -
- {tabs.map((tab) => renderTab(tab))} -
+ {/* Project tabs */} + {showProjectTabs && ( +
+ + + + + Add project + +
+ {/* Left fade */} + {projectTabsOverflow.left && ( +
+ )} + {/* Right fade */} + {projectTabsOverflow.right && ( +
+ )} +
+
+ {/* Sliding indicator */} +
+ {displayProjects.map((project) => { + const isActive = project.id === activeProjectId; + const isDragged = draggingProjectId === project.id; + const sessionIndicator = projectTabSessionIndicators.get(project.id); + const showProjectStreaming = sessionIndicator?.hasStreaming === true; + const showProjectUnread = !showProjectStreaming + && project.id !== activeProjectId + && sessionIndicator?.hasNeedsAttention === true; + const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; + const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; -
+ const statusMarker = showProjectStreaming + ? ( + + ) + : showProjectUnread + ? ( + + {Array.from({ length: 9 }, (_, i) => ( + ATTENTION_DIAMOND_INDICES.has(i) ? ( + + ) : ( + + ) + ))} + + ) + : null; -
+ return ( +
{ + if (el) { projectTabRefs.current.set(project.id, el); } + else { projectTabRefs.current.delete(project.id); } + }} + role="tab" + tabIndex={0} + aria-selected={isActive} + onPointerDown={(e) => handleProjectTabPointerDown(e, project.id)} + onPointerMove={handleProjectTabPointerMove} + onPointerUp={handleProjectTabPointerUp} + onPointerCancel={handleProjectTabPointerCancel} + onContextMenu={(e) => { + e.preventDefault(); + setProjectTabMenuOpen(project.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (project.id !== activeProjectId) { + setActiveProject(project.id); + } + } + }} + className={cn( + 'relative z-10 flex h-8 shrink-0 items-center gap-1 rounded-lg pr-1 text-[0.9375rem] font-medium whitespace-nowrap group', + isDragged + ? 'opacity-30 scale-[0.97]' + : 'cursor-pointer', + statusMarker ? 'pl-[9px]' : 'pl-[7px]', + 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' + )} + style={{ touchAction: 'none' }} + title={project.path} + > + + {statusMarker && ( + + {statusMarker} + + )} + + {ProjectIcon && ( + + )} + {formatProjectTabLabel(project)} + setProjectTabMenuOpen(open ? project.id : null)} + > + + + + + handleOpenProjectEdit(project.id)} + className="gap-2" + > + + Edit project + + handleCloseProject(project.id)} + className="text-destructive focus:text-destructive gap-2" + > + + Close project + + + +
+ ); + })} +
+
+
+
+ )} + + {!showProjectTabs && ( +
+ {tabs.map((tab) => renderTab(tab))} +
+ )} + + {!showProjectTabs &&
} + +
+ {showDesktopHeaderContextUsage && stableDesktopContextUsage && ( + + )} { )} > - {isDesktopApp && {currentInstanceLabel}} + {isDesktopApp && {currentInstanceLabel}} @@ -948,7 +1693,7 @@ export const Header: React.FC = () => { onValueChange={handleDisplayModeChange} tabs={quotaDisplayTabs} size="sm" - className="w-[8.25rem]" + className="w-[10.5rem]" /> -

Git sidebar

+

Right sidebar

@@ -1291,7 +2036,11 @@ export const Header: React.FC = () => {
-
+
{tabs.map((tab) => { const isActive = activeMainTab === tab.id; const isDiffTab = tab.icon === 'diff'; @@ -1312,7 +2061,7 @@ export const Header: React.FC = () => { role="tab" className={cn( headerIconButtonClass, - 'relative', + 'relative rounded-lg', isActive && 'bg-interactive-selection text-interactive-selection-foreground' )} > @@ -1596,12 +2345,25 @@ export const Header: React.FC = () => { ); return ( -
- {isMobile ? renderMobile() : renderDesktop()} -
+ <> +
+ {isMobile ? renderMobile() : renderDesktop()} +
+ {editingProject && ( + { if (!open) setEditingProject(null); }} + projectName={editingProject.name} + projectPath={editingProject.path} + initialIcon={editingProject.icon} + initialColor={editingProject.color} + onSave={handleSaveProjectEdit} + /> + )} + ); }; diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 22ccc007..11bdf700 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -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 = () => {
- +
-
-
- -
- {secondaryView && ( -
- {secondaryView} +
+
+
+
- )} -
+ {secondaryView && ( +
+ {secondaryView} +
+ )} +
+ +
- +
diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx new file mode 100644 index 00000000..2f324066 --- /dev/null +++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx @@ -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 = ({ + open, + onOpenChange, + projectName, + projectPath, + initialIcon = null, + initialColor = null, + onSave, +}) => { + const [name, setName] = React.useState(projectName); + const [icon, setIcon] = React.useState(initialIcon); + const [color, setColor] = React.useState(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 ( + + + + Edit project + + +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="Project name" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } + }} + autoFocus + /> +

+ {projectPath} +

+
+ + {/* Color */} +
+ +
+ {/* No color option */} + + {PROJECT_COLORS.map((c) => ( +
+
+ + {/* Icon */} +
+ +
+ {/* No icon option */} + + {PROJECT_ICONS.map((i) => { + const IconComponent = i.Icon; + return ( + + ); + })} +
+
+
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx new file mode 100644 index 00000000..b43b539e --- /dev/null +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -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 ( +
+
+ + value={rightSidebarTab} + onValueChange={setRightSidebarTab} + size="sm" + collapseLabelsOnSmall + collapseLabelsOnNarrow + tabs={[ + { value: 'git', label: 'Git', icon: RiGitBranchLine }, + { value: 'files', label: 'Files', icon: RiFolder3Line }, + ]} + /> +
+ +
+ {rightSidebarTab === 'git' ? : } +
+
+ ); +}; diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 0c18ddf1..64a94d84 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -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'; diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx new file mode 100644 index 00000000..bdb1a006 --- /dev/null +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -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 ; + } + if (ext && DATA_EXTENSIONS.has(ext)) { + return ; + } + if (ext && IMAGE_EXTENSIONS.has(ext)) { + return ; + } + if (ext && DOCUMENT_EXTENSIONS.has(ext)) { + return ; + } + return ; +}; + +// --- 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 ; +}; + +// --- 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 = ({ + 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 ( +
+ + {(canRename || canCreateFile || canCreateFolder || canDelete) && ( +
+ setContextMenuPath(open ? node.path : null)} + > + + + + setContextMenuPath(null)}> + {canRename && ( + { e.stopPropagation(); onOpenDialog('rename', node); }}> + Rename + + )} + { + e.stopPropagation(); + void navigator.clipboard.writeText(node.path); + toast.success('Path copied'); + }}> + Copy Path + + {isDir && (canCreateFile || canCreateFolder) && ( + <> + + {canCreateFile && ( + { e.stopPropagation(); onOpenDialog('createFile', node); }}> + New File + + )} + {canCreateFolder && ( + { e.stopPropagation(); onOpenDialog('createFolder', node); }}> + New Folder + + )} + + )} + {canDelete && ( + <> + + { e.stopPropagation(); onOpenDialog('delete', node); }} + className="text-destructive focus:text-destructive" + > + Delete + + + )} + + +
+ )} +
+ ); +}; + +// --- 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(null); + const [searchResults, setSearchResults] = React.useState([]); + const [searching, setSearching] = React.useState(false); + + const [childrenByDir, setChildrenByDir] = React.useState>({}); + const loadedDirsRef = React.useRef>(new Set()); + const inFlightDirsRef = React.useRef>(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(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((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 ( +
  • + {depth > 0 && ( + <> + + {isLast && ( + + )} + + )} + + {isDir && isExpanded && ( +
      + {renderTree(node.path, depth + 1)} +
    + )} +
  • + ); + }); + }, [childrenByDir, expandedPaths, handleOpenFile, selectedPath, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, getFileStatus, getFolderBadge]); + + const hasTree = Boolean(root && childrenByDir[root]); + + return ( +
    +
    +
    + + setSearchQuery(event.target.value)} + placeholder="Search files..." + className="h-8 pl-8 pr-8 typography-meta" + /> + {searchQuery.trim().length > 0 ? ( + + ) : null} +
    + {canCreateFile && ( + + )} + {canCreateFolder && ( + + )} + +
    + + +
      + {searching ? ( +
    • + + Searching... +
    • + ) : searchResults.length > 0 ? ( + searchResults.map((node) => { + const isActive = selectedPath === node.path; + return ( +
    • + +
    • + ); + }) + ) : hasTree && root ? ( + renderTree(root, 0) + ) : ( +
    • Loading...
    • + )} +
    +
    + + {/* CRUD dialogs (matching FilesView) */} + !open && setActiveDialog(null)}> + + + + {activeDialog === 'createFile' && 'Create File'} + {activeDialog === 'createFolder' && 'Create Folder'} + {activeDialog === 'rename' && 'Rename'} + {activeDialog === 'delete' && 'Delete'} + + + {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.`} + + + + {activeDialog !== 'delete' && ( +
    + setDialogInputValue(e.target.value)} + placeholder={activeDialog === 'rename' ? 'New name' : 'Name'} + onKeyDown={(e) => { + if (e.key === 'Enter') { + void handleDialogSubmit(); + } + }} + autoFocus + /> +
    + )} + + + + + +
    +
    +
    + ); +}; diff --git a/packages/ui/src/components/session/BranchPickerDialog.tsx b/packages/ui/src/components/session/BranchPickerDialog.tsx index 2cf45404..f88def66 100644 --- a/packages/ui/src/components/session/BranchPickerDialog.tsx +++ b/packages/ui/src/components/session/BranchPickerDialog.tsx @@ -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(null); const [worktrees, setWorktrees] = React.useState([]); + const [rootBranchName, setRootBranchName] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState(null); const [deletingBranch, setDeletingBranch] = React.useState(null); const [confirmingDelete, setConfirmingDelete] = React.useState(null); const [forceDeleteBranch, setForceDeleteBranch] = React.useState(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): 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(); + 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(); + 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 (
    - current + HEAD )} @@ -284,6 +433,27 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker {!isEditing && !isConfirming ? (
    + + + + + + {hasAttachedWorktree ? 'Worktree already exists' : 'Create worktree'} + + + - {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'}
    @@ -354,7 +534,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
    ) : null} - {!isEditing && isConfirming ? ( + {!isEditing && isConfirming && !hasAttachedWorktree ? (
    { 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): 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 = ({ onSessionSelected, allowReselect = false, hideDirectoryControls = false, + hideProjectSelector = false, showOnlyMainWorkspace = false, }) => { const [editingId, setEditingId] = React.useState(null); @@ -554,9 +583,22 @@ export const SessionSidebar: React.FC = ({ const [hoveredProjectId, setHoveredProjectId] = React.useState(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>(new Set()); const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); + const [pinnedSessionIds, setPinnedSessionIds] = React.useState>(() => { + 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>(() => { try { const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); @@ -722,10 +764,46 @@ export const SessionSidebar: React.FC = ({ } 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(); + 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 = ({ 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(); @@ -1110,7 +1188,7 @@ export const SessionSidebar: React.FC = ({ 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(); @@ -1123,7 +1201,7 @@ export const SessionSidebar: React.FC = ({ 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(); @@ -1245,7 +1323,7 @@ export const SessionSidebar: React.FC = ({ return groups; }, - [homeDirectory, worktreeMetadata] + [homeDirectory, worktreeMetadata, pinnedSessionIds] ); const toggleGroupSessionLimit = React.useCallback((groupId: string) => { @@ -1390,16 +1468,25 @@ export const SessionSidebar: React.FC = ({ [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 = ({ : 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 = ({ 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 = ({ )} > {} -
    - {showStatusMarker ? ( +
    + {showStatusMarker ? ( {isStreaming ? ( @@ -1856,6 +1959,9 @@ export const SessionSidebar: React.FC = ({ )} ) : null} + {isPinnedSession ? ( + + ) : null}
    {sessionTitle}
    @@ -1955,6 +2061,14 @@ export const SessionSidebar: React.FC = ({ Rename + togglePinnedSession(session.id)} className="[&>svg]:mr-1"> + {isPinnedSession ? ( + + ) : ( + + )} + {isPinnedSession ? 'Unpin session' : 'Pin session'} + {!session.share ? ( handleShareSession(session)} className="[&>svg]:mr-1"> @@ -2023,6 +2137,8 @@ export const SessionSidebar: React.FC = ({ toggleParent, handleSessionSelect, handleSessionDoubleClick, + pinnedSessionIds, + togglePinnedSession, handleShareSession, handleCopyShareUrl, handleUnshareSession, @@ -2056,7 +2172,9 @@ export const SessionSidebar: React.FC = ({ }; 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 = ({ aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined} > {!hideGroupLabel ? ( -
    - {isCollapsed ? ( - - ) : ( - - )} +
    {!group.isMain || isGitProject ? ( ) : null} @@ -2155,6 +2268,11 @@ export const SessionSidebar: React.FC = ({ ) : null}
    + {isCollapsed ? ( + + ) : ( + + )}
    ) :
    } {group.directory ? ( @@ -2282,7 +2400,8 @@ export const SessionSidebar: React.FC = ({ )} > {!hideDirectoryControls && ( -
    +
    + {!hideProjectSelector && (
    { @@ -2403,11 +2522,12 @@ export const SessionSidebar: React.FC = ({
    + )} {reserveHeaderActionsSpace ? ( -
    +
    {activeProjectForHeader ? ( -
    - {activeProjectIsRepo ? ( +
    + {stableActiveProjectIsRepo ? ( <> @@ -2479,6 +2599,21 @@ export const SessionSidebar: React.FC = ({ ) : null} + {stableActiveProjectIsRepo && branchPickerProject ? ( + + + + +

    Manage branches

    +
    + ) : null} {useMobileNotesPanel ? ( @@ -2512,7 +2647,7 @@ export const SessionSidebar: React.FC = ({ setProjectNotesPanelOpen(false)} /> @@ -2741,6 +2876,12 @@ export const SessionSidebar: React.FC = ({ }} /> + + {useMobileNotesPanel ? ( = ({ > setProjectNotesPanelOpen(false)} className="p-0" /> diff --git a/packages/ui/src/components/ui/ContextUsageDisplay.tsx b/packages/ui/src/components/ui/ContextUsageDisplay.tsx index ddb3c75f..3bb801d3 100644 --- a/packages/ui/src/components/ui/ContextUsageDisplay.tsx +++ b/packages/ui/src/components/ui/ContextUsageDisplay.tsx @@ -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 = ({ @@ -22,6 +26,10 @@ export const ContextUsageDisplay: React.FC = ({ 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 = ({ 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 && } - - {Math.min(percentage, 999).toFixed(1)}% + + {showPercentIcon ? ( + <> +
    ); diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 2066caec..e4456872 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -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`], diff --git a/packages/ui/src/components/ui/OverlayScrollbar.tsx b/packages/ui/src/components/ui/OverlayScrollbar.tsx index 3ec0d1ee..0d98a0c5 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.tsx @@ -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 = ({ hideDelayMs = 1000, className, disableHorizontal = false, + observeMutations = true, }) => { const [visible, setVisible] = React.useState(false); const [vertical, setVertical] = React.useState({ length: 0, offset: 0 }); @@ -102,7 +104,7 @@ export const OverlayScrollbar: React.FC = ({ 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 = ({ 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, axis: "vertical" | "horizontal") => { const container = containerRef.current; diff --git a/packages/ui/src/components/ui/ScrollShadow.tsx b/packages/ui/src/components/ui/ScrollShadow.tsx index 7e875abc..1ce56f6c 100644 --- a/packages/ui/src/components/ui/ScrollShadow.tsx +++ b/packages/ui/src/components/ui/ScrollShadow.tsx @@ -26,7 +26,7 @@ export const ScrollShadow = React.forwardRef( ( { orientation = "vertical", - offset = 72, + offset = 0, size = 48, isEnabled = true, hideBottomShadow = false, diff --git a/packages/ui/src/components/ui/ScrollableOverlay.tsx b/packages/ui/src/components/ui/ScrollableOverlay.tsx index b54eddce..7383b357 100644 --- a/packages/ui/src/components/ui/ScrollableOverlay.tsx +++ b/packages/ui/src/components/ui/ScrollableOverlay.tsx @@ -9,6 +9,7 @@ type ScrollableOverlayProps = React.HTMLAttributes & { 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
    ); diff --git a/packages/ui/src/components/ui/animated-tabs.tsx b/packages/ui/src/components/ui/animated-tabs.tsx index a634010a..4963623c 100644 --- a/packages/ui/src/components/ui/animated-tabs.tsx +++ b/packages/ui/src/components/ui/animated-tabs.tsx @@ -31,86 +31,73 @@ export function AnimatedTabs({ size = 'default', }: AnimatedTabsProps) { const containerRef = React.useRef(null); - const activeTabRef = React.useRef(null); + const indicatorRef = React.useRef(null); + const tabRefs = React.useRef>(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 ( -
    +
    -
    - {tabs.map((tab) => { - const Icon = tab.icon; - return ( -
    - {Icon ? : null} - - {tab.label} - -
    - - ); - })} -
    -
    - -
    + {/* Sliding indicator */} +
    + {tabs.map((tab) => { const isActive = value === tab.value; const Icon = tab.icon; @@ -118,17 +105,17 @@ export function AnimatedTabs({ return ( ); })} diff --git a/packages/ui/src/components/ui/grid-loader.tsx b/packages/ui/src/components/ui/grid-loader.tsx index f6db881e..75e51a3d 100644 --- a/packages/ui/src/components/ui/grid-loader.tsx +++ b/packages/ui/src/components/ui/grid-loader.tsx @@ -21,18 +21,19 @@ const GridLoader: React.FC = ({ className, size = 'md' }) => { const config = sizeConfig[size]; return ( -
    {Array.from({ length: 9 }, (_, i) => ( -
    ))} -
    + ); }; diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 224f7f9a..162c8f74 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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 ( +
    +
    Content of this file cannot be viewed.
    +
    + ); +}); type DiffTabViewMode = 'single' | 'stacked'; @@ -426,7 +434,7 @@ interface InlineDiffViewerProps { wrapLines: boolean; } -const InlineDiffViewer = React.memo(({ +const InlineDiffViewer = React.memo(({ filePath, diff, renderSideBySide, @@ -437,6 +445,10 @@ const InlineDiffViewer = React.memo(({ [filePath] ); + if (diff.isBinary) { + return ; + } + if (isImageFile(filePath)) { return ( (({ +const SingleDiffViewer = React.memo(({ filePath, diff, isVisible, @@ -483,6 +495,10 @@ const SingleDiffViewer = React.memo(({ [filePath] ); + if (diff.isBinary) { + return ; + } + // Don't render if not visible (memory optimization) if (!isVisible) { return null; @@ -514,45 +530,6 @@ const SingleDiffViewer = React.memo(({ ); }); -interface DiffViewerEntryProps { - directory: string; - filePath: string; - isVisible: boolean; - renderSideBySide: boolean; - wrapLines: boolean; -} - -const DiffViewerEntry = React.memo(({ - 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 ( - - ); -}); - 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(({ @@ -576,6 +555,8 @@ const MultiFileDiffEntry = React.memo(({ onSelect, registerSectionRef, defaultCollapsed = false, + expandRequestPath = null, + expandRequestNonce = 0, }) => { const { git } = useRuntimeAPIs(); const cachedDiff = useGitStore( @@ -597,9 +578,9 @@ const MultiFileDiffEntry = React.memo(({ const descriptor = React.useMemo(() => describeChange(file), [file]); const renderSideBySide = layout === 'side-by-side'; - const diffData = React.useMemo(() => { + const diffData = React.useMemo(() => { 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(({ 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(({ 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(({ }; }, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]); + const handleToggle = React.useCallback(() => { + handleOpenChange(!isExpanded); + handleSelect(); + }, [handleOpenChange, handleSelect, isExpanded]); + return (
    - -
    - -
    -
    - - {isExpanded ? ( - - ) : ( - - )} - - - {descriptor.code} - - - {file.path} - -
    -
    - {formatDiffTotals(file.insertions, file.deletions)} - { - const nextLayout: 'inline' | 'side-by-side' = - mode === 'side-by-side' ? 'side-by-side' : 'inline'; - setDiffFileLayout(file.path, nextLayout); - }} - className="opacity-70" - /> -
    - -
    - -
    - {diffLoadError ? ( -
    -
    - Failed to load diff -
    -
    - {diffLoadError} -
    - -
    - ) : null} - {isLoading && !diffData && !diffLoadError ? ( -
    - - Loading diff… -
    - ) : null} - {isExpanded && diffData ? ( - - ) : null} +
    + +
    + {isExpanded && ( +
    + {diffLoadError ? ( +
    +
    + Failed to load diff +
    +
    + {diffLoadError} +
    + +
    + ) : null} + {isLoading && !diffData && !diffLoadError ? ( +
    + + Loading diff… +
    + ) : null} + {diffData ? ( + + ) : null} +
    + )}
    ); }); -export const DiffView: React.FC = () => { +interface DiffViewProps { + hideStackedFileSidebar?: boolean; + stackedDefaultCollapsedAll?: boolean; + hideFileSelector?: boolean; + pinSelectedFileHeaderToTopOnNavigate?: boolean; +} + +export const DiffView: React.FC = ({ + 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(null); + const [stackedExpandTarget, setStackedExpandTarget] = React.useState(null); + const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0); + const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState(null); const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); const [diffLoadError, setDiffLoadError] = React.useState(null); const lastDiffRequestRef = React.useRef(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(null); const fileSectionRefs = React.useRef(new Map()); const pendingScrollTargetRef = React.useRef(null); + const pendingScrollFrameRef = React.useRef(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(() => { + 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 ( - { 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} /> ))}
    diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 83849d1f..80146cfe 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -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 ( -
    - {segments.map((seg, i) => ( - - {i > 0 && } - - - ))} -
    - ); -}; - 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 = ({ node, isExpanded, isActive, - isLoading, isMobile, status, badge, @@ -446,9 +397,7 @@ const FileRow: React.FC = ({ )} > {isDir ? ( - isLoading ? ( - - ) : isExpanded ? ( + isExpanded ? ( ) : ( @@ -537,7 +486,11 @@ const FileRow: React.FC = ({ ); }; -export const FilesView: React.FC = () => { +interface FilesViewProps { + mode?: 'full' | 'editor-only'; +} + +export const FilesView: React.FC = ({ 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(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>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); @@ -612,6 +589,7 @@ export const FilesView: React.FC = () => { const copiedContentTimeoutRef = React.useRef(null); const copiedPathTimeoutRef = React.useRef(null); const editorViewRef = React.useRef(null); + const editorWrapperRef = React.useRef(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 - /> -
    - )} + /> +
    + )} - )} +
    + {/* Row 1: Tabs */} +
    + {isMobile && showMobilePageContent && ( + + )} -
    {isMobile ? ( selectedFile ? ( @@ -1993,8 +1918,18 @@ export const FilesView: React.FC = () => { ) ) : ( openFiles.length > 0 ? ( -
    -
    +
    + {editorTabsOverflow.left && ( +
    + )} + {editorTabsOverflow.right && ( +
    + )} +
    {openFiles.map((file) => { const isActive = selectedFile?.path === file.path; return ( @@ -2037,13 +1972,6 @@ export const FilesView: React.FC = () => { ); })}
    - {selectedFile && ( - - )}
    ) : (
    Select a file
    @@ -2051,149 +1979,152 @@ export const FilesView: React.FC = () => { )}
    -
    - {canEdit && ( - - )} - - {canEdit && selectedFile && !isSelectedImage && ( -
    + )}
    @@ -2237,7 +2168,8 @@ export const FilesView: React.FC = () => {
    ) : (
    @@ -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}
    )} @@ -2438,7 +2370,7 @@ export const FilesView: React.FC = () => { ); // Fullscreen file viewer overlay - const fullscreenViewer = isFullscreen && selectedFile && ( + const fullscreenViewer = mode === 'full' && isFullscreen && selectedFile && (
    {/* Fullscreen header */}
    @@ -2648,10 +2580,16 @@ export const FilesView: React.FC = () => { ) : ( treePanel ) + ) : mode === 'editor-only' ? ( +
    +
    + {fileViewer} +
    +
    ) : (
    - {screenWidth >= 700 && ( -
    + {screenWidth >= 700 && ( +
    {treePanel}
    )} diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index dcba8bd2..ed2aedc9 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1640,7 +1640,7 @@ export const GitView: React.FC = ({ 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 = ({ mode = 'full' }) => {
    -
    +
    value={actionTab} onValueChange={setActionTab} + size="sm" collapseLabelsOnSmall collapseLabelsOnNarrow={isSidebarMode} tabs={[ @@ -1700,7 +1701,13 @@ export const GitView: React.FC = ({ 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 = ({ mode = 'full' }) => { directory={pullRequestProps.directory} branch={pullRequestProps.branch} baseBranch={baseBranch} + trackingBranch={status?.tracking ?? undefined} remotes={remotes} remoteBranches={remoteBranches} onGeneratedDescription={scrollActionPanelToBottom} diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 58ba45ad..fa5a0ece 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -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 = ({ const [editingDraftId, setEditingDraftId] = useState(null); const selectionRef = useRef(null); const editingDraftIdRef = useRef(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 = ({ 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) => { 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).metadata; const id = getAnnotationId(meta); @@ -348,6 +292,98 @@ export const PierreDiffViewer: React.FC = ({ 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>({}); + 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 = {}; + 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 = ({ const diffInstanceRef = useRef | null>(null); const sharedVirtualizerRef = useRef(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 = ({ 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 = ({ 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 = ({ void err; } forceUpdate(); + updateCommentPositions(); }); - }, [lineAnnotations]); + }, [lineAnnotations, updateCommentPositions]); useEffect(() => { const instance = diffInstanceRef.current; @@ -748,6 +788,10 @@ export const PierreDiffViewer: React.FC = ({ }; }, [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 = ({ ); 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 = ({ return null; } - // Render portals for inline comments with robust target resolution - const portals = lineAnnotations.map((ann) => { - const meta = (ann as DiffLineAnnotation).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( - { - 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( - , - target, - id - ); - } else { - return createPortal( - , - 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 ( +
    +
    + +
    +
    + ); + } + + return ( +
    + { + 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)} + /> +
    + ); + })} + + {selection && !editingDraftId && commentPositions['__new__'] && ( +
    +
    + +
    +
    + )} + + ); if (layout === 'fill') { return ( @@ -882,7 +940,7 @@ export const PierreDiffViewer: React.FC = ({ >
    - {portals} + {floatingComments}
    @@ -895,7 +953,7 @@ export const PierreDiffViewer: React.FC = ({
    - {portals} + {floatingComments}
    ); diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 2b6f4f18..94156afa 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -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(null); const [commentText, setCommentText] = React.useState(''); const [editingDraftId, setEditingDraftId] = React.useState(null); + const editorViewRef = React.useRef(null); + const editorWrapperRef = React.useRef(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: ( - - ), - }); - } else { - // Show saved cards on all devices - widgets.push({ - afterLine: draft.endLine, - id: `draft-${draft.id}`, - content: ( - { - 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: ( - - ), - }); - } - - 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 (
    @@ -564,7 +511,7 @@ export const PlanView: React.FC = () => {
    ) : ( -
    +
    { @@ -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}
    )}
    diff --git a/packages/ui/src/components/views/PreviewToggleButton.tsx b/packages/ui/src/components/views/PreviewToggleButton.tsx index ce8387b8..57ba60be 100644 --- a/packages/ui/src/components/views/PreviewToggleButton.tsx +++ b/packages/ui/src/components/views/PreviewToggleButton.tsx @@ -31,10 +31,10 @@ export const PreviewToggleButton: React.FC = ({