From e2fa7dbad23694a247df19d922e6bea9fa627996 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 27 Jul 2026 23:07:42 +0300 Subject: [PATCH] feat(ui): context panel 2.0 - surface rail, changes-first git view, live PR surface (#2418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add context surface registry and rail switcher * feat(ui): move git and project notes into context surfaces, embed editor file tree * feat(ui): replace right sidebar with context surfaces, per-surface panel widths * refactor(ui): retire legacy main-tab overlays and right-sidebar state * feat(ui): rail polish, right-docked file tree, terminal surface * feat(ui): move terminal into context surface, per-surface tab closing, editor empty state * feat(ui): tune default rail order and activity dot * fix(ui): keep context panel controls anchored during width animations * feat(ui): lazy-follow context panel resize with window-level drag tracking * feat(ui): panel dividers, right-dock tree icon, muted outline folder icons * feat(ui): restructure git view into changes-first surface with standalone PR surface - Remove commit/update/pr tabs; git view is always changes + commit - Promote pull request to its own rail surface with shared repo context - Move update-branch and re-integrate flows into separate dialogs - Add PR status chip and repo actions menu to the git header row - Seed new PR-status entries from resolved sibling remotes to avoid a false "checking status" state when the PR is already known - History/graph dialog refresh button, fingerprint global identity icon, muted outline folder icons follow-ups * feat(ui): progressive-disclosure PR surface with live checks and pinned chat context - Segment the PR surface into Overview / Checks / Comments pill tabs with live badges; merge controls move to the status row - Live checks segment: progress bar, per-run rows with workflow names, elapsed timers, expandable failures, auto-refresh while pending - PR comments and failed checks pin as chat-context drafts (like terminal selections) instead of sending an immediate message; works on new-session drafts too - Shared prContext cache client+server, ETag conditional requests in the octokit wrapper (304s bypass rate limits), extended checks aggregate (inProgress/queued/startedAt) - Resolve gh-CLI auth login for merge-permission checks - Full-width description editor with matched control heights * fix(ui): single source of truth for PR checks and status readers - Derive the checks aggregate from the visible run list and sync it into the PR-status store so bar, badges, header, and git-view chip agree - Route PR body hydration through the shared context cache - Git-view PR chip reads the freshest entry across remote keys * fix(github): freshness stamps prevent stale cache responses from regressing PR state - pr/status and pulls/context responses carry a server-side fetchedAt that survives cache serves - The status store rejects responses older than the held snapshot (only clearing the loading flag), and the checks sync adopts the context's stamp so stale status polls cannot flip fresher derived checks - Regression test for the stale-response guard * perf(github): repo-level pull-list cache collapses per-branch PR resolution - One pulls.list per repo per state per 45s answers every branch (10 worktrees = 1 call, not 10 query fans); in-flight fetches coalesce - A complete repo list makes a no-PR miss authoritative, skipping the per-owner head queries AND the Search API fallback (the 30/min killer) - force refresh bypasses the repo list cache; PR create/merge/ready invalidate it * perf(github): back off Search API misses per repo+branch A branch without a PR re-searched on every poll; with >100 closed PRs the list miss is never authoritative, so the search fallback still ran and burned the 30/min search quota. Remember misses for 10 minutes; PR creation clears remembered misses for the repo. * fix(github): dedupe re-run check runs to the latest per (app, name) listForRef returns the superseded completed run alongside its re-run; GitHub's UI shows only the latest per name. Mirror that in both pr/status and pulls/context so counts and run lists match github.com. * fix(ui): address review findings on registry test, surface docs, and PR-context keys - Rail-order test asserts against the registry itself (was stale after the 'pr' surface landed and failed) - surfaces DOCUMENTATION.md describes actual behavior: has-content surfaces hide until content exists; only multi-instance/terminal panes are keep-alive, singleton surfaces remount and restore from stores - PR-context cache keys are runtime-scoped JSON tuples; invalidation compares the directory exactly instead of by string prefix (+ test) * fix(ui): wrap long unbreakable tokens in check-run details Annotation messages with long SHAs/URLs overflowed the panel; break-words on annotation title/message/rawDetails and output summary/text, and the expanded run body clips instead of widening the panel. * fix(ui): busy state for context-attach buttons and honest attach labels - 'Attach failed checks' / 'Attach all to chat' show a spinner and disable while the context request runs (previously nothing happened for seconds) - Action labels/tooltips reworded from send-to-agent to attach-to-chat semantics across all locales * fix(i18n): Ukrainian attach wording uses 'прикріпити' with proper cases * fix(ui): runtime-scope PR-view remote caches, correct surfaces doc on preview - Remote/remote-url caches in PullRequestView are keyed by runtime + directory so a backend switch never serves another runtime's remotes - surfaces DOCUMENTATION.md: preview is not keep-alive; preview tabs remount on switch like singleton surfaces * fix(ui): rail active color, clearer collapse icon, remove dead bottom-terminal dock Design-review feedback on the context panel: - Context rail: icons enlarged 16px -> 18px; the active surface is now highlighted with the primary color only (no background, no scale animation), replacing the previous scale-up effect that read as a resize rather than a selected state. - Files tree: the icon-only 'collapse all folders' toolbar button now uses collapse-vertical instead of contract-up-down, which was easily mistaken for a close button. The labelled 'Collapse all' dropdown item in the session sidebar keeps its icon since text removes the ambiguity. - Terminal: removed the leftover bottom-dock expand/close buttons that rendered in the context-panel terminal but controlled a dock that no longer exists (nothing toggles it anymore), so the expand button appeared to do nothing and duplicated the panel-header fullscreen control. Cleaned up the entire inert layer with it: four useUIStore fields (isBottomTerminalOpen/Expanded, bottomTerminalHeight, hasManuallyResizedBottomTerminal), five actions, their persistence, the MainLayout resize listener that only served the dock height, the dock-driven refit effect in TerminalView, and the terminalView.bottomDock.* keys across all 10 locale dictionaries. Validated: ui type-check and lint clean; messages parity test (2 pass) and useUIStore contextPanel test (13 pass) green; icon sprite regenerated via icons:generate. * refactor: use PR visual state for git header icon Derives the pull request icon color from a single visual state Covers merged, closed, draft, blocked, and open PR states Removes conditional class handling from the git header icon --- packages/ui/src/components/chat/ChatInput.tsx | 14 +- .../src/components/chat/PendingChangesBar.tsx | 1 - .../chat/TurnChangedFilesDropdown.tsx | 1 - .../chat/composer/ui/ComposerContextChips.tsx | 30 +- .../chat/message/parts/ToolPart.tsx | 1 - packages/ui/src/components/icon/sprite.ts | 3 + .../components/layout/BottomTerminalDock.tsx | 159 --- .../ui/src/components/layout/ContextPanel.tsx | 529 ++++++-- .../components/layout/ContextPanelRail.tsx | 176 +++ packages/ui/src/components/layout/Header.tsx | 106 +- .../ui/src/components/layout/MainLayout.tsx | 172 +-- .../layout/ProjectActionsButton.tsx | 6 +- .../ui/src/components/layout/RightSidebar.tsx | 160 --- .../components/layout/RightSidebarTabs.tsx | 156 +-- .../components/layout/SidebarFilesTree.tsx | 74 +- .../ui/src/components/ui/CommandPalette.tsx | 18 +- packages/ui/src/components/ui/HelpDialog.tsx | 6 - .../ui/src/components/views/FilesView.tsx | 4 +- packages/ui/src/components/views/GitView.tsx | 306 ++--- .../src/components/views/PullRequestView.tsx | 253 ++++ .../ui/src/components/views/TerminalView.tsx | 62 +- .../views/git/BranchIntegrationSection.tsx | 13 +- .../ui/src/components/views/git/GitHeader.tsx | 118 +- .../views/git/IntegrateCommitsSection.tsx | 35 +- .../views/git/PullRequestSection.tsx | 1182 +++++++++-------- .../ui/src/components/views/git/baseBranch.ts | 64 + packages/ui/src/hooks/useKeyboardShortcuts.ts | 89 +- packages/ui/src/hooks/useMenuActions.ts | 67 +- packages/ui/src/lib/api/types.ts | 14 +- packages/ui/src/lib/githubChecks.ts | 42 + .../ui/src/lib/i18n/messages/en.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/en.ts | 61 +- .../ui/src/lib/i18n/messages/es.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/es.ts | 61 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/fr.ts | 61 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/ja.ts | 61 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/ko.ts | 61 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/pl.ts | 61 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 61 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/uk.ts | 73 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 61 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 7 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 61 +- .../ui/src/lib/messages/inlineComments.ts | 8 + packages/ui/src/lib/shortcuts.ts | 7 - packages/ui/src/lib/surfaces/DOCUMENTATION.md | 47 + packages/ui/src/lib/surfaces/registry.ts | 179 +++ packages/ui/src/stores/DOCUMENTATION.md | 2 +- .../ui/src/stores/useFilesViewTabsStore.ts | 24 + .../src/stores/useGitHubPrStatusStore.test.ts | 29 + .../ui/src/stores/useGitHubPrStatusStore.ts | 114 +- .../ui/src/stores/useGitIdentitiesStore.ts | 2 +- .../src/stores/useInlineCommentDraftStore.ts | 2 +- .../ui/src/stores/usePrContextStore.test.ts | 95 ++ packages/ui/src/stores/usePrContextStore.ts | 169 +++ .../stores/useUIStore.contextPanel.test.ts | 149 ++- packages/ui/src/stores/useUIStore.ts | 316 ++--- packages/web/server/lib/github/octokit.js | 55 +- packages/web/server/lib/github/pr-status.js | 122 +- packages/web/server/lib/github/routes.js | 241 +++- 67 files changed, 3767 insertions(+), 2347 deletions(-) delete mode 100644 packages/ui/src/components/layout/BottomTerminalDock.tsx create mode 100644 packages/ui/src/components/layout/ContextPanelRail.tsx delete mode 100644 packages/ui/src/components/layout/RightSidebar.tsx create mode 100644 packages/ui/src/components/views/PullRequestView.tsx create mode 100644 packages/ui/src/components/views/git/baseBranch.ts create mode 100644 packages/ui/src/lib/githubChecks.ts create mode 100644 packages/ui/src/lib/surfaces/DOCUMENTATION.md create mode 100644 packages/ui/src/lib/surfaces/registry.ts create mode 100644 packages/ui/src/stores/usePrContextStore.test.ts create mode 100644 packages/ui/src/stores/usePrContextStore.ts 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} - /> -