diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 36df681b..f8473f81 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -703,13 +703,17 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo let previewAnnotation = 0; let review = 0; let terminal = 0; + let prComment = 0; + let prCheck = 0; for (const draft of drafts) { if (draft.source === 'preview-console') previewConsole += 1; else if (draft.source === 'preview-annotation') previewAnnotation += 1; else if (draft.source === 'terminal') terminal += 1; + else if (draft.source === 'pr-comment') prComment += 1; + else if (draft.source === 'pr-check') prCheck += 1; else review += 1; } - return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`; + return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`; }, [inlineDraftKey] ) @@ -717,11 +721,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; - const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); const terminalContextDrafts = terminalContextCount > 0 ? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal') : []; - const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => { + const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => { if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { @@ -735,7 +739,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { - if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') { + if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') { removeInlineCommentDraft(inlineDraftTarget, draft.id); } } @@ -2375,6 +2379,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo { return; } store.navigateToDiff(file.relativePath, openStagedDiff); - store.setRightSidebarOpen(false); }; const fileCount = gitChangedFiles.length; diff --git a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx index 370f4f58..09406bf2 100644 --- a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx +++ b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx @@ -62,7 +62,6 @@ export const TurnChangedFilesDropdown: React.FC = } store.navigateToDiff(relativePath, false, 'turn'); - store.setRightSidebarOpen(false); setIsExpanded(false); }; diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx index 920fa6e0..d731e2b0 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx @@ -18,12 +18,14 @@ export interface ComposerContextChipsProps { /** Terminal selections, which show their own label and line range. */ terminalDrafts: readonly InlineCommentDraft[]; reviewCount: number; + prCommentCount: number; + prCheckCount: number; previewConsoleCount: number; previewAnnotationCount: number; draftTarget: InlineCommentDraftTarget | null; onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void; onRemoveReviewDrafts: () => void; - onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation') => void; + onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void; colors: Theme['colors']; } @@ -34,6 +36,7 @@ function CountChip(props: { removeLabel: string; onRemove: () => void; colors: Theme['colors']; + icon?: React.ReactNode; }) { return (
+ {props.icon} {props.label} {props.count} @@ -66,6 +70,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { const { terminalDrafts, reviewCount, + prCommentCount, + prCheckCount, previewConsoleCount, previewAnnotationCount, draftTarget, @@ -113,6 +119,28 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { /> ) : null} + {prCommentCount > 0 ? ( + onRemovePreviewDrafts('pr-comment')} + colors={colors} + icon={} + /> + ) : null} + + {prCheckCount > 0 ? ( + onRemovePreviewDrafts('pr-check')} + colors={colors} + icon={} + /> + ) : null} + {previewConsoleCount > 0 ? ( = React.memo(({ const relativePath = getRelativePath(absolutePath, currentDirectory); if (store.isMobile) { store.navigateToDiff(relativePath); - store.setRightSidebarOpen(false); return; } store.openContextDiff(currentDirectory, relativePath); diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 0e775bab..386357b9 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -61,6 +61,7 @@ export const iconSpriteData = { "code-ai": ``, "code-box": ``, "code-sslash": ``, + "collapse-vertical": ``, "command": ``, "compass-3": ``, "computer": ``, @@ -102,6 +103,7 @@ export const iconSpriteData = { "file-text": ``, "file-transfer": ``, "file-video": ``, + "fingerprint": ``, "flashlight": ``, "flask": ``, "folder": ``, @@ -164,6 +166,7 @@ export const iconSpriteData = { "more": ``, "more-2": ``, "more-2-fill": ``, + "more-fill": ``, "music": ``, "node-tree": ``, "notification-3": ``, diff --git a/packages/ui/src/components/layout/BottomTerminalDock.tsx b/packages/ui/src/components/layout/BottomTerminalDock.tsx deleted file mode 100644 index 36d735ef..00000000 --- a/packages/ui/src/components/layout/BottomTerminalDock.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; -import { useUIStore } from '@/stores/useUIStore'; -import { useI18n } from '@/lib/i18n'; - -const BOTTOM_DOCK_MIN_HEIGHT = 180; -const BOTTOM_DOCK_MAX_HEIGHT = 640; -const BOTTOM_DOCK_COLLAPSE_THRESHOLD = 110; - -interface BottomTerminalDockProps { - isOpen: boolean; - isMobile: boolean; - children: React.ReactNode; -} - -export const BottomTerminalDock: React.FC = ({ isOpen, isMobile, children }) => { - const { t } = useI18n(); - 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 [fullscreenHeight, setFullscreenHeight] = React.useState(null); - const [isResizing, setIsResizing] = React.useState(false); - const dockRef = React.useRef(null); - const startYRef = React.useRef(0); - const startHeightRef = React.useRef(bottomTerminalHeight || 300); - - const standardHeight = React.useMemo( - () => Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300)), - [bottomTerminalHeight], - ); - - React.useEffect(() => { - if (!isOpen) { - setFullscreenHeight(null); - setIsResizing(false); - } - }, [isOpen]); - - React.useEffect(() => { - if (isMobile || !isOpen || !isFullscreen) { - return; - } - - const updateFullscreenHeight = () => { - const parentHeight = dockRef.current?.parentElement?.getBoundingClientRect().height; - if (!parentHeight || parentHeight <= 0) { - return; - } - const next = Math.max(0, Math.round(parentHeight)); - setFullscreenHeight((prev) => (prev === next ? prev : next)); - }; - - updateFullscreenHeight(); - - const parent = dockRef.current?.parentElement; - if (!parent) { - return; - } - - const observer = new ResizeObserver(updateFullscreenHeight); - observer.observe(parent); - - return () => { - observer.disconnect(); - }; - }, [isFullscreen, isMobile, isOpen]); - - React.useEffect(() => { - if (isMobile || !isResizing || isFullscreen) { - return; - } - - const handlePointerMove = (event: PointerEvent) => { - const delta = startYRef.current - event.clientY; - const nextHeight = Math.min( - BOTTOM_DOCK_MAX_HEIGHT, - Math.max(BOTTOM_DOCK_MIN_HEIGHT, startHeightRef.current + delta) - ); - setBottomTerminalHeight(nextHeight); - }; - - const handlePointerUp = () => { - setIsResizing(false); - const latestState = useUIStore.getState(); - if (latestState.bottomTerminalHeight <= BOTTOM_DOCK_COLLAPSE_THRESHOLD) { - setBottomTerminalOpen(false); - } - }; - - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerUp, { once: true }); - - return () => { - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerUp); - }; - }, [isFullscreen, isMobile, isResizing, setBottomTerminalHeight, setBottomTerminalOpen]); - - if (isMobile) { - return null; - } - - const appliedHeight = isOpen - ? (isFullscreen ? Math.max(0, fullscreenHeight ?? standardHeight) : standardHeight) - : 0; - const shouldApplyFullscreenLayout = isOpen && isFullscreen; - - const handlePointerDown = (event: React.PointerEvent) => { - if (!isOpen || isFullscreen) return; - setIsResizing(true); - startYRef.current = event.clientY; - startHeightRef.current = appliedHeight; - event.preventDefault(); - }; - - return ( -
- {isOpen && !isFullscreen && ( -
- )} - -
- {children} -
-
- ); -}; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 90334547..2fa239ef 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -5,7 +5,12 @@ import { Button } from '@/components/ui/button'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { DiffView } from '@/components/views/DiffView'; import { FilesView } from '@/components/views/FilesView'; +import { GitView } from '@/components/views/GitView'; +import { PullRequestView } from '@/components/views/PullRequestView'; +import { TerminalView } from '@/components/views/TerminalView'; import { PlanView } from '@/components/views/PlanView'; +import { ProjectContextPanel } from './RightSidebarTabs'; +import { SidebarFilesTree } from './SidebarFilesTree'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { openExternalUrl } from '@/lib/url'; import { copyTextToClipboard } from '@/lib/clipboard'; @@ -30,6 +35,7 @@ import { Icon } from "@/components/icon/Icon"; import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo"; import { invokeDesktopCommand } from '@/lib/desktopNative'; import { getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat'; +import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry'; import { type PreviewElementMetadata, isPreviewElementMetadata, @@ -44,6 +50,7 @@ import { const CONTEXT_PANEL_MIN_WIDTH = 380; const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; +const RESIZE_FOLLOW_INTERVAL_MS = 100; const CONTEXT_TAB_LABEL_MAX_CHARS = 24; type TranslateFn = ReturnType['t']; const EMPTY_SESSION_TITLE_MAP = new Map(); @@ -125,16 +132,6 @@ const getAvailablePanelWidth = (panel: HTMLElement | null): number | null => { return parentWidth; }; -const clampWidthToAvailableSpace = (width: number, panel: HTMLElement | null): number => { - const clampedWidth = clampWidth(width); - const availableWidth = getAvailablePanelWidth(panel); - if (availableWidth === null) { - return clampedWidth; - } - - return Math.min(clampedWidth, Math.max(1, availableWidth)); -}; - const getRelativePathLabel = (filePath: string | null, directory: string): string => { if (!filePath) { return ''; @@ -157,6 +154,10 @@ const getModeLabel = ( if (mode === 'plan') return t('contextPanel.mode.plan'); if (mode === 'preview') return t('contextPanel.mode.preview'); if (mode === 'browser') return t('contextPanel.mode.browser'); + if (mode === 'git') return t('layout.rightSidebar.git'); + if (mode === 'pr') return t('contextPanel.mode.pr'); + if (mode === 'notes') return t('contextRail.surface.notes'); + if (mode === 'terminal') return t('layout.mainTab.terminal'); return t('contextPanel.mode.context'); }; @@ -239,6 +240,22 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }): return ; } + if (tab.mode === 'git') { + return ; + } + + if (tab.mode === 'pr') { + return ; + } + + if (tab.mode === 'notes') { + return ; + } + + if (tab.mode === 'terminal') { + return ; + } + if (tab.mode === 'plan') { return ; } @@ -262,6 +279,130 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }): return undefined; }; +const EDITOR_TREE_MIN_WIDTH = 200; +const EDITOR_TREE_MAX_WIDTH = 480; + +// The editor surface's file-tree column: docked on the right, resizable from +// its left edge, and animated open/closed like the app sidebars. +const EditorTreeColumn: React.FC<{ visible: boolean }> = ({ visible }) => { + const { t } = useI18n(); + const width = useUIStore((state) => state.contextEditorTreeWidth); + const setWidth = useUIStore((state) => state.setContextEditorTreeWidth); + const [isResizing, setIsResizing] = React.useState(false); + const startXRef = React.useRef(0); + const startWidthRef = React.useRef(width); + const liveWidthRef = React.useRef(null); + const pointerIDRef = React.useRef(null); + const columnRef = React.useRef(null); + + const clampTreeWidth = React.useCallback((value: number) => { + return Math.min(EDITOR_TREE_MAX_WIDTH, Math.max(EDITOR_TREE_MIN_WIDTH, Math.round(value))); + }, []); + + const applyLiveTreeWidth = React.useCallback((nextWidth: number) => { + const column = columnRef.current; + if (!column) { + return; + } + column.style.width = `${nextWidth}px`; + column.style.setProperty('--oc-editor-tree-width', `${nextWidth}px`); + }, []); + + const handlePointerDown = (event: React.PointerEvent) => { + if (!visible) { + return; + } + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // ignore + } + pointerIDRef.current = event.pointerId; + setIsResizing(true); + startXRef.current = event.clientX; + startWidthRef.current = width; + liveWidthRef.current = width; + event.preventDefault(); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (!isResizing || pointerIDRef.current !== event.pointerId) { + return; + } + const delta = startXRef.current - event.clientX; + const nextWidth = clampTreeWidth(startWidthRef.current + delta); + if (liveWidthRef.current === nextWidth) { + return; + } + liveWidthRef.current = nextWidth; + applyLiveTreeWidth(nextWidth); + }; + + const handlePointerEnd = (event: React.PointerEvent) => { + if (pointerIDRef.current !== event.pointerId) { + return; + } + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // ignore + } + const finalWidth = clampTreeWidth(liveWidthRef.current ?? width); + pointerIDRef.current = null; + liveWidthRef.current = null; + setIsResizing(false); + setWidth(finalWidth); + }; + + const appliedWidth = visible ? width : 0; + + return ( +
+ {visible && ( +
+ )} +
+ +
+
+ ); +}; + const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => { if (!dedupeKey || !dedupeKey.startsWith('session:')) { return null; @@ -2091,6 +2232,8 @@ export const ContextPanel: React.FC = () => { const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs); const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath); const openContextPreview = useUIStore((state) => state.openContextPreview); + const contextEditorTreeVisible = useUIStore((state) => state.contextEditorTreeVisible); + const toggleContextEditorTree = useUIStore((state) => state.toggleContextEditorTree); const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions); const { themeMode, setThemeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem(); @@ -2098,7 +2241,13 @@ export const ContextPanel: React.FC = () => { const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null; const isOpen = Boolean(panelState?.isOpen && activeTab); const isExpanded = Boolean(isOpen && panelState?.expanded); - const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH); + const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState(null); + const activeModeForWidth = activeTab?.mode ?? null; + const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined; + const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5; + const widthFallbackBase = availablePanelAreaWidth + ?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2); + const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase)); const chatSessionIDs = React.useMemo(() => { const ids: string[] = []; for (const tab of tabs) { @@ -2111,7 +2260,6 @@ export const ContextPanel: React.FC = () => { const sessionTitleById = useSessionTitleMap(directoryKey || undefined, chatSessionIDs); const [isResizing, setIsResizing] = React.useState(false); - const [suppressWidthTransition, setSuppressWidthTransition] = React.useState(false); const startXRef = React.useRef(0); const startWidthRef = React.useRef(width); const resizingWidthRef = React.useRef(null); @@ -2120,41 +2268,23 @@ export const ContextPanel: React.FC = () => { const chatFrameRefs = React.useRef>(new Map()); const chatFrameSrcByTabIDRef = React.useRef>(new Map()); const wasOpenRef = React.useRef(false); - const previousIsOpenRef = React.useRef(isOpen); - const suppressWidthTransitionFrameRef = React.useRef(null); - - const suppressWidthTransitionForFrame = React.useCallback(() => { - setSuppressWidthTransition(true); - if (suppressWidthTransitionFrameRef.current !== null) { - window.cancelAnimationFrame(suppressWidthTransitionFrameRef.current); - } - suppressWidthTransitionFrameRef.current = window.requestAnimationFrame(() => { - suppressWidthTransitionFrameRef.current = null; - setSuppressWidthTransition(false); - }); - }, []); - - React.useEffect(() => () => { - if (suppressWidthTransitionFrameRef.current !== null) { - window.cancelAnimationFrame(suppressWidthTransitionFrameRef.current); - } - }, []); + // Tracks the panel area width so fraction-based surface defaults stay + // proportional as the window resizes; manual widths remain fixed px. React.useLayoutEffect(() => { - const wasOpen = previousIsOpenRef.current; - previousIsOpenRef.current = isOpen; - - if (!isOpen) { - setSuppressWidthTransition(false); + const parent = panelRef.current?.parentElement; + if (!parent || typeof ResizeObserver === 'undefined') { return; } - if (wasOpen) { - return; - } + const observer = new ResizeObserver(() => { + setAvailablePanelAreaWidth(parent.clientWidth || null); + }); + observer.observe(parent); + setAvailablePanelAreaWidth(parent.clientWidth || null); - suppressWidthTransitionForFrame(); - }, [isOpen, suppressWidthTransitionForFrame]); + return () => observer.disconnect(); + }, []); React.useEffect(() => { if (!isOpen || wasOpenRef.current) { @@ -2170,13 +2300,36 @@ export const ContextPanel: React.FC = () => { return () => window.cancelAnimationFrame(frame); }, [isOpen]); - const applyLiveWidth = React.useCallback((nextWidth: number) => { + // Deferred resize: reflowing the chat column and the active surface (xterm, + // editor, embedded chat iframes) on every drag frame is unavoidably janky, + // so during the drag only a ghost guide line follows the pointer and the + // real width is applied once on release (riding the width transition). + const resizeAvailableWidthRef = React.useRef(null); + // The panel content follows the guide line lazily: the real width is + // re-applied at most every RESIZE_FOLLOW_INTERVAL_MS and the standing + // 200ms width transition smooths each step, VS Code-style. + const resizeFollowTimerRef = React.useRef | null>(null); + + const applyFollowWidth = React.useCallback(() => { + resizeFollowTimerRef.current = null; const panel = panelRef.current; - if (!panel) { + const next = resizingWidthRef.current; + if (!panel || next === null) { return; } + panel.style.setProperty('--oc-context-panel-width', `${next}px`); + }, []); - panel.style.setProperty('--oc-context-panel-width', `${clampWidthToAvailableSpace(nextWidth, panel)}px`); + React.useEffect(() => () => { + if (resizeFollowTimerRef.current !== null) { + clearTimeout(resizeFollowTimerRef.current); + } + }, []); + + const clampWidthForDrag = React.useCallback((nextWidth: number) => { + const clamped = clampWidth(nextWidth); + const available = resizeAvailableWidthRef.current; + return available === null ? clamped : Math.min(clamped, Math.max(1, available)); }, []); const handleResizeStart = React.useCallback((event: React.PointerEvent) => { @@ -2184,59 +2337,86 @@ export const ContextPanel: React.FC = () => { return; } - try { - event.currentTarget.setPointerCapture(event.pointerId); - } catch { - // ignore; fallback listeners still handle drag - } - activeResizePointerIDRef.current = event.pointerId; setIsResizing(true); startXRef.current = event.clientX; startWidthRef.current = width; resizingWidthRef.current = width; - applyLiveWidth(width); + // Measure once per drag; no layout reads happen during pointermove. + resizeAvailableWidthRef.current = getAvailablePanelWidth(panelRef.current); + document.documentElement.style.cursor = 'col-resize'; event.preventDefault(); - }, [applyLiveWidth, directoryKey, isExpanded, isOpen, width]); + }, [directoryKey, isExpanded, isOpen, width]); - const handleResizeMove = React.useCallback((event: React.PointerEvent) => { - if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) { - return; + const finishResize = React.useCallback(() => { + // Apply the final width once, letting the regular 200ms width transition + // carry the panel to the release position. + const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width); + resizingWidthRef.current = null; + resizeAvailableWidthRef.current = null; + if (resizeFollowTimerRef.current !== null) { + clearTimeout(resizeFollowTimerRef.current); + resizeFollowTimerRef.current = null; } - - const delta = startXRef.current - event.clientX; - const nextWidth = clampWidthToAvailableSpace(startWidthRef.current + delta, panelRef.current); - if (resizingWidthRef.current === nextWidth) { - return; + document.documentElement.style.cursor = ''; + if (directoryKey && activeModeForWidth) { + setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth); } - - resizingWidthRef.current = nextWidth; - applyLiveWidth(nextWidth); - }, [applyLiveWidth, isResizing]); - - const handleResizeEnd = React.useCallback((event: React.PointerEvent) => { - if (activeResizePointerIDRef.current !== event.pointerId || !directoryKey) { - return; - } - - try { - event.currentTarget.releasePointerCapture(event.pointerId); - } catch { - // ignore - } - - const finalWidth = clampWidthToAvailableSpace(resizingWidthRef.current ?? width, panelRef.current); - suppressWidthTransitionForFrame(); - applyLiveWidth(finalWidth); - resizingWidthRef.current = finalWidth; - setContextPanelWidth(directoryKey, finalWidth); setIsResizing(false); activeResizePointerIDRef.current = null; - }, [applyLiveWidth, directoryKey, setContextPanelWidth, suppressWidthTransitionForFrame, width]); + }, [activeModeForWidth, clampWidthForDrag, directoryKey, setContextPanelWidth, width]); + + // Window-level drag listeners: tracking the pointer via the 3px handle and + // pointer capture is unreliable (capture can fail over iframes and a missed + // pointerup leaves the drag stuck), so while resizing the whole window + // tracks the pointer and any release/cancel/blur ends the drag. + React.useEffect(() => { + if (!isResizing) { + return; + } + + const handleMove = (event: PointerEvent) => { + if (activeResizePointerIDRef.current !== event.pointerId) { + return; + } + const delta = startXRef.current - event.clientX; + const nextWidth = clampWidthForDrag(startWidthRef.current + delta); + if (resizingWidthRef.current === nextWidth) { + return; + } + resizingWidthRef.current = nextWidth; + if (resizeFollowTimerRef.current === null) { + resizeFollowTimerRef.current = setTimeout(applyFollowWidth, RESIZE_FOLLOW_INTERVAL_MS); + } + }; + + const handleUp = (event: PointerEvent) => { + if (activeResizePointerIDRef.current !== event.pointerId) { + return; + } + finishResize(); + }; + + const handleWindowBlur = () => { + finishResize(); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + window.addEventListener('pointercancel', handleUp); + window.addEventListener('blur', handleWindowBlur); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + window.removeEventListener('pointercancel', handleUp); + window.removeEventListener('blur', handleWindowBlur); + }; + }, [applyFollowWidth, clampWidthForDrag, finishResize, isResizing]); React.useEffect(() => { if (!isResizing) { resizingWidthRef.current = null; + document.documentElement.style.cursor = ''; } }, [isResizing]); @@ -2490,7 +2670,16 @@ export const ContextPanel: React.FC = () => { postEmbeddedVisibilityToChats(); }, [darkThemeId, lightThemeId, postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]); - const tabItems = React.useMemo(() => tabs.map((tab) => { + // The rail switches between surfaces (modes); the in-panel strip only lists + // instances of the active multi-instance surface (open files, split chats, + // preview targets). + const isMultiInstanceMode = activeTab?.mode === 'file' || activeTab?.mode === 'chat' || activeTab?.mode === 'preview'; + const activeModeTabs = React.useMemo( + () => (activeTab ? tabs.filter((tab) => tab.mode === activeTab.mode) : []), + [activeTab, tabs], + ); + + const tabItems = React.useMemo(() => activeModeTabs.map((tab) => { const rawLabel = getTabLabel(tab, sessionTitleById, t); const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS); const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory); @@ -2501,10 +2690,16 @@ export const ContextPanel: React.FC = () => { title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel, closeLabel: t('contextPanel.tab.closeTabAria', { label }), }; - }), [effectiveDirectory, sessionTitleById, t, tabs]); + }), [activeModeTabs, effectiveDirectory, sessionTitleById, t]); const activeNonChatContent = activeTab?.mode === 'context' ? + : activeTab?.mode === 'git' + ? + : activeTab?.mode === 'pr' + ? + : activeTab?.mode === 'notes' + ? : activeTab?.mode === 'plan' ? : activeTab?.mode === 'preview' @@ -2529,41 +2724,72 @@ export const ContextPanel: React.FC = () => { () => tabs.filter((tab) => tab.mode === 'diff'), [tabs], ); + const hasTerminalTab = React.useMemo( + () => tabs.some((tab) => tab.mode === 'terminal'), + [tabs], + ); const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane; const hasFileTabs = React.useMemo( () => tabs.some((tab) => tab.mode === 'file'), [tabs], ); + const hasOpenEditorFile = React.useMemo( + () => tabs.some((tab) => tab.mode === 'file' && tab.targetPath), + [tabs], + ); const isFileTabActive = activeTab?.mode === 'file'; const header = ( -
- { - if (!directoryKey) { - return; - } - setActiveContextPanelTab(directoryKey, tabID); - }} - onClose={(tabID) => { - if (!directoryKey) { - return; - } - closeContextPanelTab(directoryKey, tabID); - }} - onReorder={(activeTabID, overTabID) => { - if (!directoryKey) { - return; - } - reorderContextPanelTabs(directoryKey, activeTabID, overTabID); - }} - layoutMode="scrollable" - variant="default" - /> +
+ {isMultiInstanceMode ? ( + { + if (!directoryKey) { + return; + } + setActiveContextPanelTab(directoryKey, tabID); + }} + onClose={(tabID) => { + if (!directoryKey) { + return; + } + closeContextPanelTab(directoryKey, tabID); + }} + onReorder={(activeTabID, overTabID) => { + if (!directoryKey) { + return; + } + reorderContextPanelTabs(directoryKey, activeTabID, overTabID); + }} + layoutMode="scrollable" + variant="default" + /> + ) : ( +
+ {activeTab ? getTabIcon(activeTab) : null} + + {activeTab ? getModeLabel(activeTab.mode, t) : null} + +
+ )}
+ {isFileTabActive ? ( + + ) : null}
); + // width/min/max stay interpolable across open/close (no instant min/max + // jumps) so the 200ms width transition matches the sidebars. const panelStyle: React.CSSProperties = !isOpen ? { - ['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`, + ['--oc-context-panel-width' as string]: `${width}px`, width: 0, - minWidth: 0, - maxWidth: 0, - opacity: 0, - overflow: 'hidden', - visibility: 'hidden', + maxWidth: '100%', + overflowX: 'clip', } : isExpanded ? { - ['--oc-context-panel-width' as string]: '100%', - width: '100%', - minWidth: '100%', + // px, not '100%': px↔% width changes do not interpolate, which + // would make the expand/collapse width snap instead of animating. + ['--oc-context-panel-width' as string]: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%', + width: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%', maxWidth: '100%', } : { width: 'min(var(--oc-context-panel-width), 100%)', - minWidth: `min(${CONTEXT_PANEL_MIN_WIDTH}px, 100%)`, maxWidth: '100%', - ['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`, + overflowX: 'clip', + ['--oc-context-panel-width' as string]: `${width}px`, }; return ( @@ -2622,16 +2848,29 @@ export const ContextPanel: React.FC = () => { inert={!isOpen || undefined} className={cn( 'flex min-h-0 flex-col overflow-hidden bg-background', - !isExpanded && 'border-l border-border/40', + // Right-anchored while expanded: `inset-0` would teleport the left + // edge instantly (position does not transition), so only the width + // animates and the panel grows leftwards from its docked position. isExpanded - ? 'absolute inset-0 z-20 min-w-0' + ? 'absolute inset-y-0 right-0 z-20 min-w-0' : 'relative h-full flex-shrink-0', !isOpen && 'pointer-events-none', - isResizing || !isOpen || suppressWidthTransition ? 'transition-none' : 'transition-[width] duration-200 ease-in-out' + 'will-change-[width] motion-reduce:transition-none', + 'transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]' )} onKeyDownCapture={handlePanelKeyDownCapture} style={panelStyle} > + {/* Painted divider instead of border-l: a real border eats 1px of the + content box only while collapsed, shifting the header controls by + 1px between the collapsed and expanded states. */} + {isOpen && !isExpanded && ( + - - - +
+ +
diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index 9b770678..aa0bd9fe 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -156,7 +156,6 @@ export const ProjectActionsButton = ({ const desktopSshInstances = useDesktopSshStore((state) => state.instances); const loadDesktopSsh = useDesktopSshStore((state) => state.load); - const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen); const terminalShell = useUIStore((state) => state.terminalShell); const terminalLoginShell = useUIStore((state) => state.terminalLoginShells.includes(state.terminalShell)); const setSettingsPage = useUIStore((state) => state.setSettingsPage); @@ -394,7 +393,7 @@ export const ProjectActionsButton = ({ setTabIconKey(normalizedDirectory, tabId, action.icon || 'play'); setActiveTab(normalizedDirectory, tabId); if (options.revealTerminal !== false) { - setBottomTerminalOpen(true); + useUIStore.getState().openContextPanelTab(normalizedDirectory, { mode: 'terminal' }); } const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory); @@ -408,7 +407,6 @@ export const ProjectActionsButton = ({ ensureDirectory, normalizedDirectory, setActiveTab, - setBottomTerminalOpen, setTabIconKey, setTabLabel, t, @@ -540,7 +538,7 @@ export const ProjectActionsButton = ({ store.updateProjectActionRunStatus(key, 'running'); if (run) { store.setActiveTab(run.directory, run.tabId); - useUIStore.getState().setBottomTerminalOpen(true); + useUIStore.getState().openContextPanelTab(run.directory, { mode: 'terminal' }); } delete previewWaitTimeoutByRunKeyRef.current[key]; }, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS); diff --git a/packages/ui/src/components/layout/RightSidebar.tsx b/packages/ui/src/components/layout/RightSidebar.tsx deleted file mode 100644 index 327e301e..00000000 --- a/packages/ui/src/components/layout/RightSidebar.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; -import { useUIStore, RIGHT_SIDEBAR_MIN_WIDTH, RIGHT_SIDEBAR_MAX_WIDTH } from '@/stores/useUIStore'; -import { useI18n } from '@/lib/i18n'; - -const RIGHT_SIDEBAR_CONTENT_WIDTH = 420; - -interface RightSidebarProps { - isOpen: boolean; - children: React.ReactNode; - className?: string; -} - -export const RightSidebar: React.FC = ({ isOpen, children, className }) => { - const { t } = useI18n(); - const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth); - const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth); - const [isResizing, setIsResizing] = React.useState(false); - const startXRef = React.useRef(0); - const startWidthRef = React.useRef(rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH); - const resizingWidthRef = React.useRef(null); - const activeResizePointerIDRef = React.useRef(null); - const sidebarRef = React.useRef(null); - - const clampRightSidebarWidth = React.useCallback((value: number) => { - return Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, value)); - }, []); - - const applyLiveWidth = React.useCallback((nextWidth: number) => { - const sidebar = sidebarRef.current; - if (!sidebar) { - return; - } - - sidebar.style.width = `${nextWidth}px`; - sidebar.style.minWidth = `${nextWidth}px`; - sidebar.style.maxWidth = `${nextWidth}px`; - sidebar.style.setProperty('--oc-right-sidebar-width', `${nextWidth}px`); - }, []); - - const openWidth = Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH)); - const appliedWidth = isOpen ? openWidth : 0; - - React.useLayoutEffect(() => { - if (isResizing) { - return; - } - - const sidebar = sidebarRef.current; - if (!sidebar) { - return; - } - - sidebar.style.minWidth = ''; - sidebar.style.maxWidth = ''; - }, [isOpen, isResizing, openWidth]); - - const handlePointerDown = (event: React.PointerEvent) => { - if (!isOpen) { - return; - } - - try { - event.currentTarget.setPointerCapture(event.pointerId); - } catch { - // ignore - } - - activeResizePointerIDRef.current = event.pointerId; - setIsResizing(true); - startXRef.current = event.clientX; - startWidthRef.current = appliedWidth; - resizingWidthRef.current = appliedWidth; - applyLiveWidth(appliedWidth); - event.preventDefault(); - }; - - const handlePointerMove = (event: React.PointerEvent) => { - if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) { - return; - } - - const delta = startXRef.current - event.clientX; - const nextWidth = clampRightSidebarWidth(startWidthRef.current + delta); - if (resizingWidthRef.current === nextWidth) { - return; - } - - resizingWidthRef.current = nextWidth; - applyLiveWidth(nextWidth); - }; - - const handlePointerEnd = (event: React.PointerEvent) => { - if (activeResizePointerIDRef.current !== event.pointerId) { - return; - } - - try { - event.currentTarget.releasePointerCapture(event.pointerId); - } catch { - // ignore - } - - const finalWidth = clampRightSidebarWidth(resizingWidthRef.current ?? appliedWidth); - activeResizePointerIDRef.current = null; - resizingWidthRef.current = null; - setIsResizing(false); - setRightSidebarWidth(finalWidth); - }; - - const currentWidth = isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth; - - return ( - - ); -}; diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index 6f8f2227..70fbdb12 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -1,72 +1,10 @@ import React from 'react'; -import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel'; -import { GitView } from '@/components/views/GitView'; -import { Icon } from "@/components/icon/Icon"; import { useGitStore } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useUIStore } from '@/stores/useUIStore'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; -import { formatDirectoryName, cn } from '@/lib/utils'; -import { useI18n } from '@/lib/i18n'; -import { SidebarFilesTree } from './SidebarFilesTree'; - -type RightTab = 'git' | 'files' | 'context'; - -const isRightTab = (value: string): value is RightTab => - value === 'git' || value === 'files' || value === 'context'; - -const RIGHT_TAB_FALLBACK: RightTab = 'files'; - -const isBrowserActive = (): boolean => { - if (typeof document !== 'undefined' && document.hidden) return false; - if (typeof navigator !== 'undefined' && !navigator.onLine) return false; - return true; -}; - -/** - * Keeps git status fresh while the right sidebar's Git tab is the visible - * consumer. Replaces the GitPollingProvider removed in commit b2d5ccb4. - * - * Gating rules (mirror the right-sidebar render policy): - * - sidebar must be open - * - right tab must be 'git' (otherwise GitView is not the visible consumer) - * - main tab must not be 'git' (otherwise secondaryView's GitView handles - * refresh and this poll would duplicate work) - * - browser must be visible + online - * - * Any condition flip resets the interval so the next tick starts fresh. - */ -function useRightSidebarGitSync( - directory: string | undefined, - isSidebarOpen: boolean, - rightTab: RightTab | undefined, - mainTab: string | undefined -) { - const { git } = useRuntimeAPIs(); - const ensureStatus = useGitStore((state) => state.ensureStatus); - - const shouldPoll = Boolean( - directory && git && isSidebarOpen && rightTab === 'git' && mainTab !== 'git' - ); - - React.useEffect(() => { - if (!shouldPoll || !directory || !git) return; - - void ensureStatus(directory, git); - - const POLL_INTERVAL = 10_000; - const id = window.setInterval(() => { - if (!isBrowserActive()) return; - void ensureStatus(directory, git); - }, POLL_INTERVAL); - - return () => window.clearInterval(id); - }, [shouldPoll, directory, git, ensureStatus]); -} +import { formatDirectoryName } from '@/lib/utils'; export const ProjectContextPanel: React.FC = () => { const activeProjectId = useProjectsStore((state) => state.activeProjectId); @@ -117,95 +55,3 @@ export const ProjectContextPanel: React.FC = () => { ); }; - -export const RightSidebarTabs: React.FC = () => { - const { t } = useI18n(); - const rightSidebarTab = useUIStore((state) => state.rightSidebarTab); - const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab); - const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen); - const activeMainTab = useUIStore((state) => state.activeMainTab); - const directory = useEffectiveDirectory(); - - useRightSidebarGitSync(directory, isRightSidebarOpen, rightSidebarTab, activeMainTab); - - // When the main view already hosts a right-tab equivalent (e.g. main tab - // 'git' renders GitView in the secondary slot), the right sidebar's - // matching tab is hidden to avoid two live GitView instances running - // effects. The map is small and stable; expand it if more shared - // secondary/right views are added. - const hiddenRightTab: RightTab | null = - activeMainTab === 'git' - ? 'git' - : activeMainTab === 'context' - ? 'context' - : null; - - // Persisted right sidebar tab can be stale across main-tab switches (e.g. - // user opened main 'git' while right tab was 'git'). Snap to the fallback - // so the visible tab never equals the hidden one. - React.useEffect(() => { - if (hiddenRightTab && rightSidebarTab === hiddenRightTab) { - setRightSidebarTab(RIGHT_TAB_FALLBACK); - } - }, [hiddenRightTab, rightSidebarTab, setRightSidebarTab]); - - const tabItems = React.useMemo(() => [ - { - id: 'git', - label: t('layout.rightSidebar.git'), - icon: , - }, - { - id: 'files', - label: t('layout.rightSidebar.files'), - icon: , - }, - { - id: 'context', - label: t('layout.rightSidebar.context'), - icon: , - }, - ], [t]); - - const visibleTabItems = React.useMemo( - () => (hiddenRightTab ? tabItems.filter((item) => item.id !== hiddenRightTab) : tabItems), - [tabItems, hiddenRightTab] - ); - const isRightGitTabActive = isRightSidebarOpen && rightSidebarTab === 'git' && hiddenRightTab !== 'git'; - - const handleTabSelect = React.useCallback( - (tabID: string) => { - if (isRightTab(tabID)) { - setRightSidebarTab(tabID); - } - }, - [setRightSidebarTab] - ); - - return ( -
-
- -
- -
-
- -
-
- -
-
- -
-
-
- ); -}; diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 4763cc33..b164a9cd 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -344,9 +344,9 @@ const FileRow: React.FC = ({ > {isDir ? ( isExpanded ? ( - + ) : ( - + ) ) : ( getFileIcon(node.path, node.extension) @@ -516,6 +516,7 @@ export const SidebarFilesTree: React.FC = () => { const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); + const collapseAllExpandedPaths = useFilesViewTabsStore((state) => state.collapseAllExpandedPaths); const contextTabs = useUIStore((state) => (root ? (state.contextPanelByDirectory[root]?.tabs ?? EMPTY_CONTEXT_TABS) : EMPTY_CONTEXT_TABS)); const openContextFilePaths = React.useMemo(() => new Set( contextTabs @@ -1077,30 +1078,8 @@ export const SidebarFilesTree: React.FC = () => { return (
-
-
- - setSearchQuery(event.target.value)} - placeholder={t('sidebarFilesTree.search.placeholder')} - className="h-8 pl-8 pr-8 typography-meta" - /> - {searchQuery.trim().length > 0 ? ( - - ) : null} -
+
+
{canCreateFile && ( @@ -1149,6 +1128,49 @@ export const SidebarFilesTree: React.FC = () => { {t('sidebarFilesTree.actions.refreshTitle')} + + + + + + + {t('sidebarFilesTree.actions.collapseAllTitle')} + +
+
+ + setSearchQuery(event.target.value)} + placeholder={t('sidebarFilesTree.search.placeholder')} + className="h-8 pl-8 pr-8 typography-meta" + /> + {searchQuery.trim().length > 0 ? ( + + ) : null} +
diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 8785bc2d..d6951bb6 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -85,9 +85,8 @@ export const CommandPalette: React.FC = () => { const setSettingsPage = useUIStore((s) => s.setSettingsPage); const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); const toggleSidebar = useUIStore((s) => s.toggleSidebar); - const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); - const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal); const openContextOverview = useUIStore((s) => s.openContextOverview); + const openContextSurface = useUIStore((s) => s.openContextSurface); const openContextFile = useUIStore((s) => s.openContextFile); const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); @@ -213,21 +212,15 @@ export const CommandPalette: React.FC = () => { } }), }, - { - id: 'toggle-right-sidebar', - title: t('commandPalette.item.toggleRightSidebar'), - icon: , - shortcutId: 'toggle_right_sidebar', - searchText: t('commandPalette.item.toggleRightSidebar'), - onSelect: run(() => toggleRightSidebar()), - }, { id: 'toggle-terminal', title: t('commandPalette.item.toggleTerminal'), icon: , shortcutId: 'toggle_terminal', searchText: t('commandPalette.item.toggleTerminal'), - onSelect: run(() => toggleBottomTerminal()), + onSelect: run(() => { + if (currentDirectory) openContextSurface(currentDirectory, 'terminal'); + }), }, { id: 'context-usage', @@ -273,8 +266,7 @@ export const CommandPalette: React.FC = () => { setSessionSwitcherOpen, openNewSessionDraft, toggleSidebar, - toggleRightSidebar, - toggleBottomTerminal, + openContextSurface, currentDirectory, openContextOverview, setSettingsDialogOpen, diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 9705ec40..f74b9ee6 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -151,12 +151,6 @@ export const HelpDialog: React.FC = () => { icon: "layout-right", keys: '', }, - { - id: 'cycle_right_sidebar_tab', - descriptionKey: 'helpDialog.item.cycleRightSidebarTab', - icon: "layout-right", - keys: '', - }, { id: 'toggle_terminal', descriptionKey: 'helpDialog.item.toggleTerminalDock', diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 5c7191bb..838ac74a 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -536,9 +536,9 @@ const FileRow: React.FC = ({ > {isDir ? ( isExpanded ? ( - + ) : ( - + ) ) : ( getFileIcon(node.path, node.extension) diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 74b6ef3d..a6155f36 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -38,6 +38,7 @@ import { CommandList, } from '@/components/ui/command'; import { Icon } from "@/components/icon/Icon"; +import { Button } from '@/components/ui/button'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; @@ -52,11 +53,12 @@ import { ChangesPanel, type ChangesGroupConfig } from './git/ChangesPanel'; import { CommitSection } from './git/CommitSection'; import { GitEmptyState } from './git/GitEmptyState'; import { HistorySection } from './git/HistorySection'; -import { PullRequestSection } from './git/PullRequestSection'; import { ConflictDialog } from './git/ConflictDialog'; import { StashDialog } from './git/StashDialog'; import { InProgressOperationBanner } from './git/InProgressOperationBanner'; import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection'; +import { deriveBaseBranch } from './git/baseBranch'; +import { getFreshestPrStatusForBranch, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue'; import type { GitRemote } from '@/lib/gitApi'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; @@ -68,7 +70,6 @@ import { useI18n } from '@/lib/i18n'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type CommitAction = 'commit' | 'commitAndPush' | null; type BranchOperation = 'merge' | 'rebase' | null; -type ActionTab = 'commit' | 'branch' | 'pr'; type GitLogDialogMode = 'history' | 'graph'; type HistoryBranchDivider = { insertBeforeIndex: number; @@ -76,12 +77,8 @@ type HistoryBranchDivider = { direction: 'up' | 'down'; } | null; -const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab'; const GIT_RECONCILE_DELAY_MS = 15000; -const isActionTab = (value: unknown): value is ActionTab => - value === 'commit' || value === 'branch' || value === 'pr'; - type GitViewSnapshot = { directory?: string; commitMessage: string; @@ -302,8 +299,16 @@ export const GitView: React.FC = ({ isActive }) => { }))); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); + const openContextSurface = useUIStore((state) => state.openContextSurface); + + const prStatusBranch = status?.current ?? null; + const prChipStatus = useGitHubPrStatusStore((state) => { + if (!currentDirectory || !prStatusBranch) { + return null; + } + return getFreshestPrStatusForBranch(state.entries, currentDirectory, prStatusBranch); + }); const navigateToDiff = useUIStore((state) => state.navigateToDiff); - const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen); const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null); const gitReconcileTimeoutRef = React.useRef(null); @@ -617,21 +622,8 @@ export const GitView: React.FC = ({ isActive }) => { const [gitmojiSearch, setGitmojiSearch] = React.useState(''); const [gitLogDialogMode, setGitLogDialogMode] = React.useState(null); - const actionTabItems = React.useMemo(() => [ - { id: 'commit', label: t('gitView.tabs.commit') }, - { id: 'branch', label: t('gitView.tabs.update') }, - { id: 'pr', label: t('gitView.tabs.pr') }, - ], [t]); - const [actionTab, setActionTab] = React.useState(() => { - if (typeof window === 'undefined') { - return 'commit'; - } - const stored = window.localStorage.getItem(GIT_ACTION_TAB_STORAGE_KEY); - if (stored === 'worktree') { - return 'branch'; - } - return isActionTab(stored) ? stored : 'commit'; - }); + const [isUpdateBranchDialogOpen, setIsUpdateBranchDialogOpen] = React.useState(false); + const [isIntegrateCommitsDialogOpen, setIsIntegrateCommitsDialogOpen] = React.useState(false); const [remotes, setRemotes] = React.useState([]); const [removingRemoteName, setRemovingRemoteName] = React.useState(null); const [branchOperation, setBranchOperation] = React.useState(null); @@ -642,6 +634,7 @@ export const GitView: React.FC = ({ isActive }) => { const [graphLog, setGraphLog] = React.useState(null); const [graphLogLoading, setGraphLogLoading] = React.useState(false); const [graphLogMaxCount, setGraphLogMaxCount] = React.useState(100); + const [graphLogRefreshToken, setGraphLogRefreshToken] = React.useState(0); // Conflict state persistence key const conflictStorageKey = React.useMemo(() => { @@ -666,13 +659,6 @@ export const GitView: React.FC = ({ isActive }) => { window.localStorage.removeItem(conflictStorageKey); }, [conflictStorageKey]); - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - window.localStorage.setItem(GIT_ACTION_TAB_STORAGE_KEY, actionTab); - }, [actionTab]); - // Restore conflict state from localStorage on mount React.useEffect(() => { if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return; @@ -1426,59 +1412,12 @@ export const GitView: React.FC = ({ isActive }) => { })); }, [remotes, remoteBranches, remoteUrl, status?.tracking]); - const baseBranch = React.useMemo(() => { - const remoteNames = new Set(effectiveRemotes.map((remote) => remote.name)); - const normalizeBaseCandidate = (value: string): string => { - if (!value) { - return ''; - } - - let normalized = value.trim(); - if (!normalized || normalized === 'HEAD') { - return ''; - } - - if (localBranches.includes(normalized)) { - return normalized; - } - - if (normalized.startsWith('refs/heads/')) { - normalized = normalized.slice('refs/heads/'.length); - } - if (normalized.startsWith('heads/')) { - normalized = normalized.slice('heads/'.length); - } - if (normalized.startsWith('remotes/')) { - normalized = normalized.slice('remotes/'.length); - } - - const slashIndex = normalized.indexOf('/'); - if (slashIndex > 0) { - const maybeRemote = normalized.slice(0, slashIndex); - if (remoteNames.has(maybeRemote)) { - const withoutRemote = normalized.slice(slashIndex + 1).trim(); - if (withoutRemote) { - normalized = withoutRemote; - } - } - } - - return normalized; - }; - - const fromMeta = normalizeBaseCandidate( - typeof worktreeMetadata?.createdFromBranch === 'string' ? worktreeMetadata.createdFromBranch : '' - ); - if (fromMeta) return fromMeta; - - const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : ''); - if (fromHint) return fromHint; - - if (localBranches.includes('main')) return 'main'; - if (localBranches.includes('master')) return 'master'; - if (localBranches.includes('develop')) return 'develop'; - return 'main'; - }, [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]); + const baseBranch = React.useMemo(() => deriveBaseBranch({ + remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)), + localBranches, + worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch, + rootBranchHint, + }), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]); const updateTargetBranch = React.useMemo(() => { const remoteNames = effectiveRemotes.map((remote) => remote.name); @@ -1571,9 +1510,6 @@ export const GitView: React.FC = ({ isActive }) => { const canShowIntegrateCommitsSection = Boolean( worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ); - const canShowPullRequestSection = Boolean( - currentDirectory && currentBranch - ); const canShowBranchWorkflows = Boolean(currentBranch); const integrateCommitsProps = canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata @@ -1583,15 +1519,6 @@ export const GitView: React.FC = ({ isActive }) => { worktreeMetadata, } : null; - const pullRequestProps = React.useMemo(() => { - if (!canShowPullRequestSection || !currentDirectory || !currentBranch) { - return null; - } - return { - directory: currentDirectory, - branch: currentBranch, - }; - }, [canShowPullRequestSection, currentBranch, currentDirectory]); React.useEffect(() => { if (!currentDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) { @@ -1681,7 +1608,7 @@ export const GitView: React.FC = ({ isActive }) => { if (!cancelled) setGraphLogLoading(false); }); return () => { cancelled = true; }; - }, [gitLogDialogMode, currentDirectory, graphLogMaxCount, git]); + }, [gitLogDialogMode, currentDirectory, graphLogMaxCount, graphLogRefreshToken, git]); // Keep these sections stable in layout; individual cards render placeholders when unavailable. @@ -1826,10 +1753,7 @@ export const GitView: React.FC = ({ isActive }) => { return; } navigateToDiff(path, staged); - if (isMobile) { - setRightSidebarOpen(false); - } - }, [currentDirectory, isMobile, navigateToDiff, openContextDiff, setRightSidebarOpen]); + }, [currentDirectory, isMobile, navigateToDiff, openContextDiff]); const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []); @@ -2406,9 +2330,13 @@ export const GitView: React.FC = ({ isActive }) => { onOpenHistory={() => setGitLogDialogMode('history')} onOpenGraph={() => setGitLogDialogMode('graph')} onOpenStashes={openStashes} - actionTabItems={actionTabItems} - activeActionTab={actionTab} - onSelectActionTab={(tabID) => setActionTab(tabID as ActionTab)} + onOpenUpdateBranch={canShowBranchWorkflows ? () => setIsUpdateBranchDialogOpen(true) : undefined} + onOpenReintegrateCommits={integrateCommitsProps ? () => setIsIntegrateCommitsDialogOpen(true) : undefined} + pullRequest={prChipStatus?.pr ?? null} + prChecks={prChipStatus?.checks ?? null} + onOpenPullRequest={ + currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined + } /> {/* In-progress operation banner */} @@ -2438,8 +2366,7 @@ export const GitView: React.FC = ({ isActive }) => { disableHorizontal preventOverscroll > - {actionTab === 'commit' ? ( -
+
{(changeEntries?.length ?? 0) > 0 ? ( <>
@@ -2475,82 +2402,117 @@ export const GitView: React.FC = ({ isActive }) => { setIsStashesDialogOpen(true)} /> )}
- ) : null} - - {actionTab === 'branch' ? ( -
- {canShowBranchWorkflows ? ( - <> - - {integrateCommitsProps ? ( - { - if (!currentDirectory) return; - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); - }} - /> - ) : null} - - ) : ( -

{t('gitView.branch.actionsUnavailable')}

- )} -
- ) : null} - - {actionTab === 'pr' ? ( -
- {pullRequestProps ? ( - - ) : ( -
-
{t('gitView.pullRequest.title')}
-
- {t('gitView.pullRequest.createHint')} -
-
- )} -
- ) : null}
+ { + // Keep the dialog up while a merge/rebase is running so the + // operation log stays visible until it completes or fails. + if (!open && branchOperation !== null) { + return; + } + setIsUpdateBranchDialogOpen(open); + }} + > + + + {t('gitView.branch.updateTitle')} + + {t('gitView.branch.updateDescriptionPrefix')}{' '} + {status?.current ?? ''}. + + + {canShowBranchWorkflows ? ( + + ) : ( +

{t('gitView.branch.actionsUnavailable')}

+ )} +
+
+ + + + + {t('gitView.integrate.title')} + + {integrateCommitsProps ? ( + + {integrateCommitsProps.sourceBranch} → {defaultTargetBranch} + + ) : null} + + + {integrateCommitsProps ? ( + { + if (!currentDirectory) return; + fetchStatus(currentDirectory, git); + fetchBranches(currentDirectory, git); + fetchLog(currentDirectory, git, logMaxCountLocal); + }} + /> + ) : null} + + + { if (!open) setGitLogDialogMode(null); }}> - - {gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')} - +
+ + {gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')} + + +
{t('gitView.history.dialogDescription')} diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx new file mode 100644 index 00000000..9f8bec34 --- /dev/null +++ b/packages/ui/src/components/views/PullRequestView.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; +import { useGitStatus, useGitBranches, useGitStore } from '@/stores/useGitStore'; +import { useShallow } from 'zustand/react/shallow'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import type { GitRemote } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; +import { ScrollShadow } from '@/components/ui/ScrollShadow'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { PullRequestSection } from './git/PullRequestSection'; +import { deriveBaseBranch } from './git/baseBranch'; + +const normalizePath = (value?: string | null): string => + (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); + +// Remotes rarely change; remembering the last fetched list per directory lets +// a remount pick the same PR-status key immediately instead of flashing +// through the remote-less "checking status" state while remotes reload. +// Runtime-scoped so a backend switch never serves another runtime's remotes. +const remotesCacheByDirectory = new Map(); +const remoteUrlCacheByDirectory = new Map(); +const remoteCacheKey = (directory: string): string => `${getRuntimeKey()}::${directory}`; + +/** + * Standalone pull-request surface: resolves the same repository context + * GitView does (branch, base branch, remotes) from the shared git stores and + * renders the pull-request workflow full-size in the context panel. + */ +export const PullRequestView: React.FC = () => { + const { t } = useI18n(); + const { git } = useRuntimeAPIs(); + const currentDirectory = useEffectiveDirectory(); + const status = useGitStatus(currentDirectory ?? null); + const branches = useGitBranches(currentDirectory ?? null); + const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll }))); + + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); + const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees); + + const normalizedCurrentDirectory = normalizePath(currentDirectory); + const inferredWorktreeMetadata = React.useMemo(() => { + if (!normalizedCurrentDirectory) { + return undefined; + } + + const fromAvailable = availableWorktrees.find( + (metadata) => normalizePath(metadata.path) === normalizedCurrentDirectory + ); + if (fromAvailable) { + return fromAvailable; + } + + for (const metadata of worktreeMap.values()) { + if (normalizePath(metadata.path) === normalizedCurrentDirectory) { + return metadata; + } + } + + return undefined; + }, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]); + + const storeWorktreeMetadata = React.useMemo(() => { + if (currentSessionId) { + return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata; + } + + if (newSessionDraft?.open) { + return inferredWorktreeMetadata; + } + + return undefined; + }, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]); + + const worktreeAttachment = useSessionWorktreeStore((s) => + currentSessionId ? s.getAttachment(currentSessionId) : undefined + ); + const authoritativeProjectRoot = worktreeAttachment && !worktreeAttachment.degraded && !worktreeAttachment.legacy + ? worktreeAttachment.worktreeRoot ?? undefined + : undefined; + + const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); + + React.useEffect(() => { + if (!currentDirectory || !git) { + return; + } + void ensureAll(currentDirectory, git); + }, [currentDirectory, ensureAll, git]); + + const [rootBranchHint, setRootBranchHint] = React.useState(null); + React.useEffect(() => { + const projectRoot = authoritativeProjectRoot || worktreeMetadata?.projectDirectory; + if (!projectRoot) { + setRootBranchHint(null); + return; + } + + let cancelled = false; + void getRootBranch(projectRoot) + .then((branch) => { + if (cancelled) return; + const normalized = branch.trim(); + setRootBranchHint(normalized && normalized !== 'HEAD' ? normalized : null); + }) + .catch(() => { + if (!cancelled) { + setRootBranchHint(null); + } + }); + + return () => { + cancelled = true; + }; + }, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]); + + const [remotes, setRemotes] = React.useState(() => + (currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? [] + ); + const [remoteUrl, setRemoteUrl] = React.useState(() => + (currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null + ); + React.useEffect(() => { + if (!currentDirectory || !git?.getRemotes) { + setRemotes([]); + return; + } + + setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); + let cancelled = false; + void git.getRemotes(currentDirectory) + .then((remoteList) => { + if (cancelled) return; + remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []); + setRemotes(remoteList ?? []); + }) + .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); }); + + return () => { + cancelled = true; + }; + }, [currentDirectory, git]); + + React.useEffect(() => { + if (!currentDirectory || !git?.getRemoteUrl) { + setRemoteUrl(null); + return; + } + + setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); + let cancelled = false; + void git.getRemoteUrl(currentDirectory) + .then((url) => { + if (cancelled) return; + remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url); + setRemoteUrl(url); + }) + .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); }); + + return () => { + cancelled = true; + }; + }, [currentDirectory, git]); + + const localBranches = React.useMemo(() => { + if (!branches?.all) return []; + return branches.all + .filter((branchName: string) => !branchName.startsWith('remotes/')) + .sort(); + }, [branches]); + + const remoteBranches = React.useMemo(() => { + if (!branches?.all) return []; + return branches.all + .filter((branchName: string) => branchName.startsWith('remotes/')) + .map((branchName: string) => branchName.replace(/^remotes\//, '')) + .sort(); + }, [branches]); + + const effectiveRemotes = React.useMemo(() => { + if (remotes.length > 0) { + return remotes; + } + + const inferredNames = new Set(); + const tracking = status?.tracking?.trim(); + if (tracking && tracking.includes('/')) { + inferredNames.add(tracking.split('/')[0]); + } + + for (const branchName of remoteBranches) { + const slashIndex = branchName.indexOf('/'); + if (slashIndex > 0) { + inferredNames.add(branchName.slice(0, slashIndex)); + } + } + + if (inferredNames.size === 0 && remoteUrl) { + inferredNames.add('origin'); + } + + return Array.from(inferredNames).map((name) => ({ + name, + fetchUrl: remoteUrl ?? '', + pushUrl: remoteUrl ?? '', + })); + }, [remotes, remoteBranches, remoteUrl, status?.tracking]); + + const baseBranch = React.useMemo(() => deriveBaseBranch({ + remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)), + localBranches, + worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch, + rootBranchHint, + }), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]); + + const currentBranch = status?.current ?? null; + + if (!currentDirectory || !currentBranch) { + return ( +
+ +
{t('gitView.pullRequest.title')}
+
{t('gitView.pullRequest.createHint')}
+
+ ); + } + + return ( + + + + ); +}; diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index ed7acc1a..e91fd081 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -36,8 +36,6 @@ export const TerminalView: React.FC = ({ visible }) => { const terminalFontSize = useUIStore(state => state.terminalFontSize); const terminalShell = useUIStore(state => state.terminalShell); const terminalLoginShell = useUIStore(state => state.terminalLoginShells.includes(state.terminalShell)); - const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight); - const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded); const { isMobile, isTablet, hasTouchOnlyPointer } = useDeviceInfo(); const isTouchTerminal = isMobile || isTablet; const useTouchTerminalInput = (isTouchTerminal || hasTouchOnlyPointer) && runtime.platform === 'web'; @@ -146,11 +144,8 @@ export const TerminalView: React.FC = ({ visible }) => { }, [useTouchTerminalInput]); const activeMainTab = useUIStore((state) => state.activeMainTab); - const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen); - const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen); - const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded); const isTerminalActive = activeMainTab === 'terminal'; - const isTerminalVisible = visible ?? (isTerminalActive || isBottomTerminalOpen); + const isTerminalVisible = visible ?? isTerminalActive; const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible); React.useEffect(() => { @@ -847,34 +842,6 @@ export const TerminalView: React.FC = ({ visible }) => { }; }, [isTerminalVisible, terminalViewportKey, useTouchTerminalInput]); - React.useEffect(() => { - if (useTouchTerminalInput || !isTerminalVisible || !isBottomTerminalOpen) { - return; - } - - const controller = terminalControllerRef.current; - if (!controller) { - return; - } - - const fitOnce = () => { - controller.fit(); - }; - - if (typeof window !== 'undefined') { - const rafId = window.requestAnimationFrame(() => { - fitOnce(); - }); - const timeoutIds = [320].map((delay) => window.setTimeout(fitOnce, delay)); - return () => { - window.cancelAnimationFrame(rafId); - timeoutIds.forEach((id) => window.clearTimeout(id)); - }; - } - - fitOnce(); - }, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isTerminalVisible, useTouchTerminalInput]); - if (!hasActiveContext) { return (
@@ -899,7 +866,6 @@ export const TerminalView: React.FC = ({ visible }) => { const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending; const shouldRenderViewport = hasOpenedTerminalViewport; - const showBottomDockControls = !isTouchTerminal && isBottomTerminalOpen && !isTerminalActive; const quickKeySize: 'lg' | 'xs' = isTouchTerminal ? 'lg' : 'xs'; const quickKeyIconClass = isTouchTerminal ? 'w-10 p-0' : 'w-9 p-0'; const preserveTerminalFocus = (event: React.PointerEvent) => { @@ -1077,32 +1043,6 @@ export const TerminalView: React.FC = ({ visible }) => { {t('terminalView.preview.open')} ) : null} - {showBottomDockControls ? ( - <> - - - - ) : null}
) : null} diff --git a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx index bc9d8116..0348d3be 100644 --- a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx +++ b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx @@ -46,7 +46,12 @@ interface BranchIntegrationSectionProps { isOperating?: boolean; operationLogs?: OperationLogEntry[]; onOperationComplete?: () => void; - mode?: 'dialog' | 'inline'; + /** + * 'dialog' renders its own trigger button + dialog, 'inline' renders a + * titled section, 'bare' renders just the form body for embedding in an + * externally-owned dialog. + */ + mode?: 'dialog' | 'inline' | 'bare'; defaultTargetBranch?: string; } @@ -167,7 +172,7 @@ export const BranchIntegrationSection: React.FC = }, [branchDropdownOpen]); React.useEffect(() => { - if (mode !== 'inline' || selectedBranch) return; + if (mode === 'dialog' || selectedBranch) return; setSelectedBranch(resolveDefaultBranch()); }, [mode, resolveDefaultBranch, selectedBranch]); @@ -412,6 +417,10 @@ export const BranchIntegrationSection: React.FC = const body = isOperating ? renderOperating() : renderForm(); + if (mode === 'bare') { + return body; + } + if (mode === 'inline') { return (
diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index c38b746c..c96a8a59 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; import { DropdownMenu, DropdownMenuContent, @@ -18,6 +17,8 @@ import type { GitIdentityProfile, GitRemote, GitRemoteComparison, + GitHubPullRequest, + GitHubChecksSummary, } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; @@ -45,9 +46,11 @@ interface GitHeaderProps { onOpenHistory?: () => void; onOpenGraph?: () => void; onOpenStashes?: () => void; - actionTabItems?: SortableTabsStripItem[]; - activeActionTab?: string; - onSelectActionTab?: (tabID: string) => void; + onOpenUpdateBranch?: () => void; + onOpenReintegrateCommits?: () => void; + pullRequest?: GitHubPullRequest | null; + prChecks?: GitHubChecksSummary | null; + onOpenPullRequest?: () => void; } const IDENTITY_ICON_MAP: Record = { @@ -58,6 +61,7 @@ const IDENTITY_ICON_MAP: Record = { code: 'code', heart: 'heart', user: 'user-3', + fingerprint: 'fingerprint', }; const IDENTITY_COLOR_MAP: Record = { @@ -249,9 +253,11 @@ export const GitHeader: React.FC = ({ onOpenHistory, onOpenGraph, onOpenStashes, - actionTabItems, - activeActionTab, - onSelectActionTab, + onOpenUpdateBranch, + onOpenReintegrateCommits, + pullRequest, + prChecks, + onOpenPullRequest, }) => { const { t } = useI18n(); if (!status) { @@ -260,7 +266,7 @@ export const GitHeader: React.FC = ({ const managementButtons = (
- {onOpenHistory || onOpenGraph || onOpenStashes ? ( + {onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? ( @@ -271,7 +277,7 @@ export const GitHeader: React.FC = ({ className="h-8 w-8 px-0" aria-label={t('gitView.header.repositoryViews')} > - + @@ -286,7 +292,7 @@ export const GitHeader: React.FC = ({ ) : null} {onOpenGraph ? ( - + {t('gitView.graph.title')} ) : null} @@ -296,12 +302,75 @@ export const GitHeader: React.FC = ({ {t('gitView.stashes.title')} ) : null} + {onOpenUpdateBranch ? ( + + + {t('gitView.header.updateBranch')} + + ) : null} + {onOpenReintegrateCommits ? ( + + + {t('gitView.integrate.title')} + + ) : null} ) : null}
); + const prChecksColor = prChecks + ? prChecks.state === 'success' + ? 'var(--status-success)' + : prChecks.state === 'failure' + ? 'var(--status-error)' + : 'var(--status-warning)' + : null; + + const prVisualState = pullRequest + ? pullRequest.state === 'merged' + ? 'merged' + : pullRequest.state === 'closed' + ? 'closed' + : pullRequest.draft + ? 'draft' + : prChecks?.state === 'failure' + || pullRequest.mergeable === false + || pullRequest.mergeableState === 'blocked' + || pullRequest.mergeableState === 'dirty' + ? 'blocked' + : 'open' + : null; + + const prChip = pullRequest && onOpenPullRequest ? ( + + + + + {t('gitView.header.openPullRequest')} + + ) : null; + const syncButtons = ( = ({ )}
- {managementButtons} {identityControl}
- {actionTabItems && activeActionTab && onSelectActionTab ? ( -
-
- -
- {upstreamStatusPill ? ( -
{upstreamStatusPill}
- ) : null} -
{syncButtons}
-
- ) : null} +
+ {prChip ?
{prChip}
: null} +
+ {upstreamStatusPill ? ( +
{upstreamStatusPill}
+ ) : null} + {managementButtons} +
{syncButtons}
+
); }; diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index 30c0cdf8..428b0b54 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -50,6 +50,8 @@ export const IntegrateCommitsSection: React.FC<{ defaultTargetBranch: string; refreshKey?: number; onRefresh?: () => void; + /** Hide the built-in section heading when a dialog already provides one. */ + showHeader?: boolean; }> = ({ repoRoot, sourceBranch, @@ -58,6 +60,7 @@ export const IntegrateCommitsSection: React.FC<{ defaultTargetBranch, refreshKey, onRefresh, + showHeader = true, }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); @@ -332,22 +335,24 @@ export const IntegrateCommitsSection: React.FC<{ return (
-
-
- -

{t('gitView.integrate.title')}

- {ui.kind === 'ready' && ui.plan.commits.length > 0 ? ( - - {t('gitView.integrate.toMoveCount', { count: ui.plan.commits.length })} - - ) : null} + {showHeader ? ( +
+
+ +

{t('gitView.integrate.title')}

+ {ui.kind === 'ready' && ui.plan.commits.length > 0 ? ( + + {t('gitView.integrate.toMoveCount', { count: ui.plan.commits.length })} + + ) : null} +
+
+ {ui.kind === 'loading' || ui.kind === 'running' ? ( + + ) : null} +
-
- {ui.kind === 'loading' || ui.kind === 'running' ? ( - - ) : null} -
-
+ ) : null}
diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index f647d2fb..25703471 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -1,26 +1,19 @@ import React from 'react'; +import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { generatePullRequestDescription } from '@/lib/gitApi'; -import { renderMagicPrompt } from '@/lib/magicPrompts'; import { openExternalUrl } from '@/lib/url'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDeviceInfo } from '@/lib/device'; @@ -30,21 +23,43 @@ import { Icon } from "@/components/icon/Icon"; import { useUIStore } from '@/stores/useUIStore'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSelectionStore } from '@/sync/selection-store'; -import { useConfigStore } from '@/stores/useConfigStore'; +import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; +import { getPrContextKey, usePrContextStore } from '@/stores/usePrContextStore'; +import { summarizeCheckRuns } from '@/lib/githubChecks'; import type { GitHubPullRequest, GitHubCheckRun, GitHubAPI, - GitHubPullRequestContextResult, GitHubPullRequestStatus, GitRemote, } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; type MergeMethod = 'merge' | 'squash' | 'rebase'; +type PrSegment = 'overview' | 'checks' | 'comments'; + +const PR_CHECKS_AUTO_REFRESH_MS = 35_000; + +const formatElapsedDuration = (startISO?: string, endISO?: string, now?: number): string | null => { + if (!startISO) return null; + const start = Date.parse(startISO); + if (!Number.isFinite(start)) return null; + const end = endISO ? Date.parse(endISO) : (now ?? Date.now()); + if (!Number.isFinite(end) || end <= start) return null; + const totalMinutes = Math.floor((end - start) / 60_000); + if (totalMinutes < 1) return '<1m'; + if (totalMinutes < 60) return `${totalMinutes}m`; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +}; + +const isFailedConclusion = (conclusion?: string | null): boolean => { + const normalized = typeof conclusion === 'string' ? conclusion.toLowerCase() : ''; + return Boolean(normalized) && !['success', 'neutral', 'skipped'].includes(normalized); +}; type DetectedUpstream = { owner: string; repo: string; url: string; defaultBranch?: string; defaultBranchSha?: string | null; remoteName?: string | null }; const statusColor = (state: string | undefined | null): string => { @@ -141,6 +156,7 @@ type PullRequestDraftSnapshot = { additionalContext: string; targetBaseBranch?: string; selectedRemoteName?: string; + activeSegment?: PrSegment; }; const getTrackingRemoteName = (trackingBranch: string | null | undefined): string => { @@ -238,14 +254,6 @@ type TimelineCommentItem = { line: number | null; }; -type ChatDispatchTarget = { - sessionId: string; - providerID: string; - modelID: string; - currentAgentName: string | null; - currentVariant: string | null; -}; - const pullRequestDraftSnapshots = new Map(); const openExternal = openExternalUrl; @@ -318,6 +326,7 @@ export const PullRequestSection: React.FC<{ const setSettingsPage = useUIStore((state) => state.setSettingsPage); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const { isMobile, hasTouchInput } = useDeviceInfo(); const openGitHubSettings = React.useCallback(() => { @@ -466,13 +475,9 @@ export const PullRequestSection: React.FC<{ } }, [availableBaseBranches, baseBranch, targetBaseBranch]); - const [checksDialogOpen, setChecksDialogOpen] = React.useState(false); - const [checkDetails, setCheckDetails] = React.useState(null); - const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false); + const [activeSegment, setActiveSegmentState] = React.useState(() => initialSnapshot?.activeSegment ?? 'overview'); const [expandedCheckStepKeys, setExpandedCheckStepKeys] = React.useState>(new Set()); - const [commentsDialogOpen, setCommentsDialogOpen] = React.useState(false); - const [commentsDetails, setCommentsDetails] = React.useState(null); - const [isLoadingCommentsDetails, setIsLoadingCommentsDetails] = React.useState(false); + const [expandedCheckRunKeys, setExpandedCheckRunKeys] = React.useState>(new Set()); const attemptedBodyHydrationRef = React.useRef>(new Set()); const lastSyncedPrNumberRef = React.useRef(null); @@ -495,6 +500,107 @@ export const PullRequestSection: React.FC<{ }, [useDetectedUpstream, detectedUpstream?.defaultBranch]); const pr = status?.pr ?? null; + + const prContextKey = pr ? getPrContextKey(directory, pr.number) : null; + const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined)); + const ensurePrContext = usePrContextStore((state) => state.ensure); + const prContext = prContextEntry?.result ?? null; + const isLoadingPrContext = prContextEntry?.isLoading ?? false; + + const setActiveSegment = React.useCallback((segment: PrSegment) => { + setActiveSegmentState(segment); + const snapshot = pullRequestDraftSnapshots.get(snapshotKey); + if (snapshot) { + pullRequestDraftSnapshots.set(snapshotKey, { ...snapshot, activeSegment: segment }); + } + }, [snapshotKey]); + + // Load the context the active segment needs; checks include details. + React.useEffect(() => { + if (!pr || !github?.prContext || activeSegment === 'overview') { + return; + } + void ensurePrContext(github, directory, pr.number, { + includeCheckDetails: activeSegment === 'checks', + sourceRepo: status?.repo ?? null, + }); + }, [activeSegment, directory, ensurePrContext, github, pr, status?.repo]); + + const checks = status?.checks ?? null; + const checksArePending = (checks?.pending ?? 0) > 0; + + // The detailed run list (pulls/context) and the status aggregate (pr/status) + // come from different endpoints with different cache ages. The run list is + // the fresher, richer source whenever we have it — derive the aggregate from + // it and push it into the status store so every consumer (header, badges, + // git-view chip) shows the same numbers as the visible runs. + const contextCheckRuns = prContext?.checkRuns ?? null; + const contextFetchedAt = prContext?.fetchedAt; + React.useEffect(() => { + if (!contextCheckRuns || contextCheckRuns.length === 0) { + return; + } + const derived = summarizeCheckRuns(contextCheckRuns); + updatePrStatus(prStatusKey, (previous) => { + if (!previous?.pr) { + return previous; + } + // Never let older context data regress a fresher status snapshot. + if (typeof contextFetchedAt === 'number' + && typeof previous.fetchedAt === 'number' + && contextFetchedAt < previous.fetchedAt) { + return previous; + } + const current = previous.checks; + const unchanged = current + && current.state === derived.state + && current.total === derived.total + && current.success === derived.success + && current.failure === derived.failure + && current.pending === derived.pending + && current.inProgress === derived.inProgress + && current.queued === derived.queued + && current.startedAt === derived.startedAt; + if (unchanged) { + return previous; + } + return { + ...previous, + checks: derived, + // Adopt the context's freshness so a later stale status response + // (older server stamp) is rejected by the store's freshness guard. + ...(typeof contextFetchedAt === 'number' ? { fetchedAt: contextFetchedAt } : {}), + }; + }); + }, [contextCheckRuns, contextFetchedAt, prStatusKey, updatePrStatus]); + + // While checks run and the checks segment is visible, keep the detailed + // run list fresh; the shared context store dedupes against other callers. + React.useEffect(() => { + if (activeSegment !== 'checks' || !checksArePending || !pr || !github?.prContext) { + return; + } + const intervalId = window.setInterval(() => { + void ensurePrContext(github, directory, pr.number, { + includeCheckDetails: true, + sourceRepo: status?.repo ?? null, + force: true, + }); + }, PR_CHECKS_AUTO_REFRESH_MS); + return () => window.clearInterval(intervalId); + }, [activeSegment, checksArePending, directory, ensurePrContext, github, pr, status?.repo]); + + // Coarse clock for "running for Nm" labels; only ticks while checks run. + const [nowTick, setNowTick] = React.useState(() => Date.now()); + React.useEffect(() => { + if (!checksArePending) { + return; + } + setNowTick(Date.now()); + const intervalId = window.setInterval(() => setNowTick(Date.now()), 30_000); + return () => window.clearInterval(intervalId); + }, [checksArePending]); + const currentPrBodyHydrationKey = pr ? `${directory}#${pr.number}` : null; const isHydratingCurrentPrBody = Boolean( currentPrBodyHydrationKey && hydratingPrBodyKey === currentPrBodyHydrationKey, @@ -517,7 +623,7 @@ export const PullRequestSection: React.FC<{ setHydratingPrBodyKey(hydrationKey); let cancelled = false; - void github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false, sourceRepo: status?.repo ?? null }) + void ensurePrContext(github, directory, pr.number, { sourceRepo: status?.repo ?? null }) .then((ctx) => { if (cancelled) { return; @@ -550,7 +656,7 @@ export const PullRequestSection: React.FC<{ return () => { cancelled = true; }; - }, [directory, github, pr, prStatusKey, status?.repo, updatePrStatus]); + }, [directory, ensurePrContext, github, pr, prStatusKey, status?.repo, updatePrStatus]); React.useEffect(() => { if (!pr) { @@ -576,55 +682,6 @@ export const PullRequestSection: React.FC<{ lastSyncedPrNumberRef.current = pr.number; }, [isEditingPr, pr]); - const openChecksDialog = React.useCallback(async () => { - if (!github?.prContext) { - toast.error(t('gitView.pr.toast.githubApiUnavailable')); - return; - } - if (!pr) return; - - setChecksDialogOpen(true); - setExpandedCheckStepKeys(new Set()); - setIsLoadingCheckDetails(true); - try { - const ctx = await github.prContext(directory, pr.number, { - includeDiff: false, - includeCheckDetails: true, - sourceRepo: status?.repo ?? null, - }); - setCheckDetails(ctx); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - toast.error(t('gitView.pr.toast.loadCheckDetailsFailed'), { description: message }); - } finally { - setIsLoadingCheckDetails(false); - } - }, [directory, github, pr, status?.repo, t]); - - const openCommentsDialog = React.useCallback(async () => { - if (!github?.prContext) { - toast.error(t('gitView.pr.toast.githubApiUnavailable')); - return; - } - if (!pr) return; - - setCommentsDialogOpen(true); - setIsLoadingCommentsDetails(true); - try { - const ctx = await github.prContext(directory, pr.number, { - includeDiff: false, - includeCheckDetails: false, - sourceRepo: status?.repo ?? null, - }); - setCommentsDetails(ctx); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - toast.error(t('gitView.pr.toast.loadCommentsFailed'), { description: message }); - } finally { - setIsLoadingCommentsDetails(false); - } - }, [directory, github, pr, status?.repo, t]); - const formatTimestamp = React.useCallback((value?: string) => { if (!value) return ''; const ts = Date.parse(value); @@ -661,7 +718,7 @@ export const PullRequestSection: React.FC<{ }, [connectedGitHubLogin]); const timelineComments = React.useMemo(() => { - const issue = (commentsDetails?.issueComments ?? []).map((comment) => ({ + const issue = (prContext?.issueComments ?? []).map((comment) => ({ id: `issue-${comment.id}`, body: comment.body || '', authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'), @@ -673,7 +730,7 @@ export const PullRequestSection: React.FC<{ line: null as number | null, })); - const review = (commentsDetails?.reviewComments ?? []).map((comment) => ({ + const review = (prContext?.reviewComments ?? []).map((comment) => ({ id: `review-${comment.id}`, body: comment.body || '', authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'), @@ -694,70 +751,53 @@ export const PullRequestSection: React.FC<{ return aVal - bVal; }); return all; - }, [commentsDetails, t]); + }, [prContext, t]); - const resolveChatDispatchTarget = React.useCallback((): ChatDispatchTarget | null => { - if (!currentSessionId) { + // PR comments/checks are pinned as inline-comment drafts above the chat + // input (like terminal selections), not sent as an immediate message — the + // user decides how to prompt and when to send. + const resolveDraftTarget = React.useCallback((): InlineCommentDraftTarget | null => { + // Same convention as diff/file comments: a new-session draft pins context + // under the 'draft' key, which the composer adopts when the session is + // created — starting a fresh session from a PR comment is a valid flow. + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + if (!sessionKey) { toast.error(t('gitView.pr.toast.noActiveSession'), { description: t('gitView.pr.toast.noActiveSessionDescription') }); return null; } + return { directory, sessionKey }; + }, [currentSessionId, directory, newSessionDraftOpen, t]); - const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); - const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; - const providerID = currentProviderId || lastUsedProvider?.providerID; - const modelID = currentModelId || lastUsedProvider?.modelID; - if (!providerID || !modelID) { - toast.error(t('gitView.pr.toast.noModelSelected')); - return null; - } - - return { - sessionId: currentSessionId, - providerID, - modelID, - currentAgentName: currentAgentName ?? null, - currentVariant: currentVariant ?? null, - }; - }, [currentSessionId, t]); - - const dispatchSyntheticPrompt = React.useCallback(( - target: ChatDispatchTarget, - visibleText: string, - instructionsText: string, - payloadText: string, - ) => { - void useSessionUIStore.getState().sendMessage( - visibleText, - target.providerID, - target.modelID, - target.currentAgentName ?? undefined, - undefined, - undefined, - [ - { text: instructionsText, synthetic: true }, - { text: payloadText, synthetic: true }, - ], - target.currentVariant ?? undefined, - ).catch((e) => { - const message = e instanceof Error ? e.message : String(e); - toast.error(t('gitView.pr.toast.sendMessageFailed'), { description: message }); + const attachCommentDraft = React.useCallback((target: InlineCommentDraftTarget, comment: TimelineCommentItem) => { + const authorLabel = comment.authorLogin ? `@${comment.authorLogin}` : comment.authorName; + const location = comment.path ? ` · ${comment.path}${comment.line ? `:${comment.line}` : ''}` : ''; + useInlineCommentDraftStore.getState().addDraft(target, { + source: 'pr-comment', + fileLabel: `PR #${pr?.number ?? ''} ${authorLabel}${location}`, + startLine: comment.line ?? 0, + endLine: comment.line ?? 0, + code: comment.body, + language: 'markdown', + text: '', }); - }, [t]); + }, [pr?.number]); - const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun) => { + const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun, options?: { hideHeader?: boolean }) => { const status = run.status || 'unknown'; const conclusion = run.conclusion ?? undefined; const statusText = conclusion ? `${status} / ${conclusion}` : status; const appName = run.app?.name || run.app?.slug; return (
-
-
-
{run.name}
-
- {appName ? `${appName} · ${statusText}` : statusText} +
+ {!options?.hideHeader ? ( +
+
{run.name}
+
+ {appName ? `${appName} · ${statusText}` : statusText} +
-
+ ) : null} {run.detailsUrl ? ( + +

{t('gitView.pr.actions.markReady')}

+ + ) : null} + {canMerge ? ( + <> + + + + + +

{t('gitView.pr.actions.mergePr')}

+
+ + ) : null} +
) : null}
@@ -1499,45 +1590,38 @@ export const PullRequestSection: React.FC<{ {t('gitView.pr.checkingStatus')}
) : pr ? ( -
-
-
- {isEditingPr ? ( -
- setEditTitle(e.target.value)} - placeholder={t('gitView.pr.placeholder.title')} - autoCorrect={hasTouchInput ? "on" : "off"} - autoCapitalize={hasTouchInput ? "sentences" : "off"} - spellCheck={hasTouchInput} - /> -