From 5b0a97d1707581d65e8de2085a464f4bf12d78b1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 9 Feb 2026 03:13:34 +0200 Subject: [PATCH] feat(ui): add desktop git sidebar + terminal dock and improve in-app PR workflow (#362) * feat: add unified dropdown with services content in header * feat: add right Git sidebar with resizable panel * feat: implement responsive panel auto-toggle and terminal rehydration - Auto-close the right sidebar when width is below a threshold and auto-open it when space permits - Auto-close the bottom terminal when height is below a threshold and auto-open it when enough space - Apply a dedicated rehydrated streaming configuration for terminal sessions to optimize reconnect behavior * feat: enhance PR view with status caching and annotations * feat(ui): enable chat dispatch in PullRequestSection * feat(TerminalView): adjust layout * feat: refine chat input layout and text selection menu * fix(ui): show empty state in GitView when no changes * feat(git): update PR actions styling and create PR button --- packages/ui/src/components/chat/ChatInput.tsx | 2 +- .../chat/message/TextSelectionMenu.tsx | 115 +- .../desktop/DesktopHostSwitcher.tsx | 151 ++- .../components/layout/BottomTerminalDock.tsx | 108 ++ packages/ui/src/components/layout/Header.tsx | 517 ++++--- .../ui/src/components/layout/MainLayout.tsx | 129 +- .../ui/src/components/layout/RightSidebar.tsx | 110 ++ packages/ui/src/components/layout/Sidebar.tsx | 61 +- .../ui/src/components/mcp/McpDropdown.tsx | 160 ++- .../src/components/session/SessionSidebar.tsx | 387 +----- .../components/terminal/TerminalViewport.tsx | 118 +- .../ui/src/components/ui/animated-tabs.tsx | 53 +- packages/ui/src/components/views/GitView.tsx | 157 ++- .../ui/src/components/views/TerminalView.tsx | 476 +++---- .../components/views/git/BranchSelector.tsx | 10 +- .../components/views/git/ChangesSection.tsx | 47 +- .../ui/src/components/views/git/GitHeader.tsx | 237 ++-- .../views/git/PullRequestSection.tsx | 1190 ++++++++++++++--- .../src/components/views/git/SyncActions.tsx | 58 +- .../views/git/WorktreeBranchDisplay.tsx | 34 +- packages/ui/src/index.css | 19 + packages/ui/src/lib/api/types.ts | 27 +- packages/ui/src/lib/terminalTheme.ts | 11 +- packages/ui/src/stores/useUIStore.ts | 100 ++ packages/vscode/src/bridge.ts | 31 + packages/vscode/src/githubPr.ts | 66 + packages/vscode/src/githubPulls.ts | 100 +- packages/vscode/webview/api/github.ts | 3 + packages/web/server/index.js | 168 ++- packages/web/src/api/github.ts | 14 + 30 files changed, 3216 insertions(+), 1443 deletions(-) create mode 100644 packages/ui/src/components/layout/BottomTerminalDock.tsx create mode 100644 packages/ui/src/components/layout/RightSidebar.tsx diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 763da996..5f7fe4b2 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1765,7 +1765,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo )}
= ({ containerRef }) => { const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [isDragging, setIsDragging] = React.useState(false); const [isClosing, setIsClosing] = React.useState(false); + const [isOpening, setIsOpening] = React.useState(false); const menuRef = React.useRef(null); const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null); const hideTimeoutRef = React.useRef(null); + const openRafRef = React.useRef(null); const createSession = useSessionStore((state) => state.createSession); const setPendingInputText = useSessionStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); @@ -33,6 +37,10 @@ export const TextSelectionMenu: React.FC = ({ containerR window.clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } + if (openRafRef.current !== null) { + window.cancelAnimationFrame(openRafRef.current); + openRafRef.current = null; + } }; }, []); @@ -41,6 +49,11 @@ export const TextSelectionMenu: React.FC = ({ containerR window.clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } + if (openRafRef.current !== null) { + window.cancelAnimationFrame(openRafRef.current); + openRafRef.current = null; + } + setIsOpening(false); setIsClosing(true); hideTimeoutRef.current = window.setTimeout(() => { @@ -49,7 +62,7 @@ export const TextSelectionMenu: React.FC = ({ containerR pendingSelectionRef.current = null; setIsClosing(false); hideTimeoutRef.current = null; - }, 140); + }, MENU_TRANSITION_MS); }, []); const showMenu = React.useCallback(() => { @@ -62,6 +75,7 @@ export const TextSelectionMenu: React.FC = ({ containerR setIsClosing(false); const { text, rect } = pendingSelectionRef.current; + const shouldAnimateIn = !position.show; // Position menu above the selection const menuX = rect.left + rect.width / 2; @@ -73,7 +87,18 @@ export const TextSelectionMenu: React.FC = ({ containerR y: menuY, show: true, }); - }, []); + + if (shouldAnimateIn) { + setIsOpening(true); + if (openRafRef.current !== null) { + window.cancelAnimationFrame(openRafRef.current); + } + openRafRef.current = window.requestAnimationFrame(() => { + setIsOpening(false); + openRafRef.current = null; + }); + } + }, [position.show]); const handleSelectionChange = React.useCallback(() => { const selection = window.getSelection(); @@ -221,7 +246,12 @@ export const TextSelectionMenu: React.FC = ({ containerR 'bg-[var(--surface-elevated)] border-t border-[var(--interactive-border)]', 'px-3 py-2', 'safe-area-bottom', - isClosing ? 'animate-out fade-out-0 duration-150 pointer-events-none' : 'animate-in fade-in-0 duration-150' + 'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]', + isClosing + ? 'opacity-0 translate-y-[4px] pointer-events-none' + : isOpening + ? 'opacity-0 translate-y-[4px]' + : 'opacity-100 translate-y-0' )} style={{ paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))', @@ -280,52 +310,61 @@ export const TextSelectionMenu: React.FC = ({ containerR return createPortal(
- + -
+
- + +
, document.body ); diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index f92a9095..d7fd6f4c 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -16,6 +16,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { + RiAddLine, RiCheckLine, RiCloudOffLine, RiEarthLine, @@ -143,9 +144,16 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => { type DesktopHostSwitcherDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; + embedded?: boolean; + onHostSwitched?: () => void; }; -export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwitcherDialogProps) { +export function DesktopHostSwitcherDialog({ + open, + onOpenChange, + embedded = false, + onHostSwitched, +}: DesktopHostSwitcherDialogProps) { const [configHosts, setConfigHosts] = React.useState([]); const [defaultHostId, setDefaultHostId] = React.useState(null); const [statusById, setStatusById] = React.useState>({}); @@ -160,6 +168,7 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi const [newLabel, setNewLabel] = React.useState(''); const [newUrl, setNewUrl] = React.useState(''); + const [isAddFormOpen, setIsAddFormOpen] = React.useState(!embedded); const allHosts = React.useMemo(() => { const local = buildLocalHost(); @@ -241,11 +250,12 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi setEditUrl(''); setNewLabel(''); setNewUrl(''); + setIsAddFormOpen(!embedded); setError(''); return; } void refresh(); - }, [open, refresh]); + }, [embedded, open, refresh]); React.useEffect(() => { if (!open) return; @@ -256,13 +266,14 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || ''); if (!origin) return; const target = toNavigationUrl(origin); + onHostSwitched?.(); try { window.location.assign(target); } catch { window.location.href = target; } - }, []); + }, [onHostSwitched]); const beginEdit = React.useCallback((host: DesktopHost) => { setEditingId(host.id); @@ -309,7 +320,10 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi await persist(nextHosts, defaultHostId); setNewLabel(''); setNewUrl(''); - }, [configHosts, defaultHostId, newLabel, newUrl, persist]); + if (embedded) { + setIsAddFormOpen(false); + } + }, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist]); const deleteHost = React.useCallback(async (id: string) => { if (id === LOCAL_HOST_ID) return; @@ -329,9 +343,34 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi const tauriAvailable = isTauriShell(); - return ( - - + const content = ( + <> + {embedded ? ( +
+
+
+ Current + {current.label} + + Default + {currentDefaultLabel} +
+ +
+
+ ) : ( @@ -341,7 +380,9 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi Switch between Local and remote OpenChamber servers + )} + {!embedded && (
Current: @@ -362,6 +403,7 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
+ )} {!tauriAvailable && (
@@ -531,38 +573,85 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
)} -
-
-
Add instance
- + + Add instance +
-
- setNewLabel(e.target.value)} - placeholder="Label (optional)" - disabled={!tauriAvailable || isSaving} - /> - setNewUrl(e.target.value)} - placeholder="https://host:port" - disabled={!tauriAvailable || isSaving} - /> + ) : ( +
+
+
Add instance
+
+ {embedded && ( + + )} + +
+
+
+ setNewLabel(e.target.value)} + placeholder="Label (optional)" + disabled={!tauriAvailable || isSaving} + /> + setNewUrl(e.target.value)} + placeholder="https://host:port" + disabled={!tauriAvailable || isSaving} + /> +
-
+ )} {error && (
{error}
)} + + ); + + if (embedded) { + return ( +
+ {content} +
+ ); + } + + return ( + + + {content} ); diff --git a/packages/ui/src/components/layout/BottomTerminalDock.tsx b/packages/ui/src/components/layout/BottomTerminalDock.tsx new file mode 100644 index 00000000..662371c9 --- /dev/null +++ b/packages/ui/src/components/layout/BottomTerminalDock.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; + +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 bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight); + const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight); + const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen); + const [isResizing, setIsResizing] = React.useState(false); + const startYRef = React.useRef(0); + const startHeightRef = React.useRef(bottomTerminalHeight || 300); + + React.useEffect(() => { + if (isMobile || !isResizing) { + 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); + }; + }, [isMobile, isResizing, setBottomTerminalHeight, setBottomTerminalOpen]); + + if (isMobile) { + return null; + } + + const appliedHeight = isOpen + ? Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300)) + : 0; + + const handlePointerDown = (event: React.PointerEvent) => { + if (!isOpen) return; + setIsResizing(true); + startYRef.current = event.clientY; + startHeightRef.current = appliedHeight; + event.preventDefault(); + }; + + return ( +
+ {isOpen && ( +
+ )} + +
+ {children} +
+
+ ); +}; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 7a3dd280..855d6fa5 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -12,8 +12,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { AnimatedTabs } from '@/components/ui/animated-tabs'; -import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiRefreshLine, RiSettings3Line, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; @@ -27,7 +28,7 @@ import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel, hasModifier } from '@/lib/utils'; import { useDiffFileCount } from '@/components/views/DiffView'; -import { McpDropdown } from '@/components/mcp/McpDropdown'; +import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota'; import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; @@ -46,7 +47,7 @@ import { import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'; import type { UsageWindow } from '@/types'; import type { GitHubAuthStatus } from '@/lib/api/types'; -import { DesktopHostSwitcherButton } from '@/components/desktop/DesktopHostSwitcher'; +import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { isDesktopShell } from '@/lib/desktop'; @@ -106,9 +107,10 @@ interface TabConfig { export const Header: React.FC = () => { const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const toggleSidebar = useUIStore((state) => state.toggleSidebar); + const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal); + const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const toggleCommandPalette = useUIStore((state) => state.toggleCommandPalette); - const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog); const activeMainTab = useUIStore((state) => state.activeMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); @@ -195,6 +197,16 @@ export const Header: React.FC = () => { const githubAccounts = githubAuthStatus?.accounts ?? []; const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false); const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false); + const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false); + const [isUsageRefreshSpinning, setIsUsageRefreshSpinning] = React.useState(false); + const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>( + isDesktopApp ? 'instance' : 'usage' + ); + useEffect(() => { + if (!isDesktopApp && desktopServicesTab === 'instance') { + setDesktopServicesTab('usage'); + } + }, [desktopServicesTab, isDesktopApp]); useQuotaAutoRefresh(); const selectedModels = useQuotaStore((state) => state.selectedModels); const expandedFamilies = useQuotaStore((state) => state.expandedFamilies); @@ -318,6 +330,15 @@ export const Header: React.FC = () => { } }, [setQuotaDisplayMode]); + const handleUsageRefresh = React.useCallback(() => { + if (isUsageRefreshSpinning) return; + setIsUsageRefreshSpinning(true); + const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500)); + Promise.all([fetchAllQuotas(), minSpinPromise]).finally(() => { + setIsUsageRefreshSpinning(false); + }); + }, [fetchAllQuotas, isUsageRefreshSpinning]); + const currentSession = React.useMemo(() => { if (!currentSessionId) return null; return sessions.find((s) => s.id === currentSessionId) ?? null; @@ -604,18 +625,49 @@ export const Header: React.FC = () => { badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined, }, { id: 'files', label: 'Files', icon: RiFolder6Line }, - { id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine }, - { + ); + + if (isMobile) { + base.push({ + id: 'terminal', + label: 'Terminal', + icon: RiTerminalBoxLine, + }, { id: 'git', label: 'Git', icon: RiGitBranchLine, - showDot: isMobile && diffFileCount > 0, - }, - ); + showDot: diffFileCount > 0, + }); + } return base; }, [diffFileCount, isMobile, showPlanTab]); + useEffect(() => { + if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal')) { + setActiveMainTab('chat'); + } + }, [activeMainTab, isMobile, setActiveMainTab]); + + const servicesTabs = React.useMemo(() => { + const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: RemixiconComponentType }> = []; + if (isDesktopApp) { + base.push({ value: 'instance', label: 'Instance', icon: RiServerLine }); + } + base.push( + { value: 'usage', label: 'Usage', icon: RiTimerLine }, + { value: 'mcp', label: 'MCP', icon: RiCommandLine } + ); + return base; + }, [isDesktopApp]); + + const quotaDisplayTabs = React.useMemo(() => { + return [ + { value: 'usage' as const, label: 'Used' }, + { value: 'remaining' as const, label: 'Remaining' }, + ]; + }, []); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (hasModifier(e) && !e.shiftKey && !e.altKey) { @@ -749,9 +801,225 @@ export const Header: React.FC = () => {
- {isDesktopApp && ( - - )} + { + setIsDesktopServicesOpen(open); + if (open && desktopServicesTab === 'usage' && quotaResults.length === 0) { + fetchAllQuotas(); + } + }} + > + + + + + + + +

Instance / Usage / MCP

+
+
+ +
+ + value={desktopServicesTab} + onValueChange={(value) => { + setDesktopServicesTab(value); + if (value === 'usage' && quotaResults.length === 0) { + fetchAllQuotas(); + } + }} + tabs={servicesTabs} + className="rounded-md" + /> +
+ + {isDesktopApp && desktopServicesTab === 'instance' && ( + {}} + onHostSwitched={() => setIsDesktopServicesOpen(false)} + /> + )} + + {desktopServicesTab === 'mcp' && ( + + )} + + {desktopServicesTab === 'usage' && ( +
+
+ +
+ Rate limits + + Last updated {formatTime(quotaLastUpdated)} + +
+
+ + value={quotaDisplayMode} + onValueChange={handleDisplayModeChange} + tabs={quotaDisplayTabs} + size="sm" + className="w-[8.25rem]" + /> + +
+
+
+ {!hasRateLimits && ( + event.preventDefault()} + > + No rate limits available. + + )} + {rateLimitGroups.map((group, index) => { + const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; + + return ( + + + + {group.providerName} + + + {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( + event.preventDefault()} + > + No rate limits reported. + + ) : ( + <> + {group.entries.map(([label, window]) => ( + event.preventDefault()} + > + + {(() => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + return ( + <> + + + {formatWindowLabel(label)} + {(window.resetAfterFormatted ?? window.resetAtFormatted) ? ( + + {window.resetAfterFormatted ?? window.resetAtFormatted} + + ) : null} + + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + + + + + ); + })()} + + + ))} + + {group.modelFamilies && group.modelFamilies.length > 0 && ( +
+ {group.modelFamilies.map((family) => { + const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); + + return ( + toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} + > + + + {family.familyLabel} + + {isExpanded ? ( + + ) : ( + + )} + + +
+ {family.models.map(([modelName, window]) => ( +
+
+ + {modelName} + {(() => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + return ( + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + + ); + })()} + + {(() => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + return ( + + ); + })()} +
+
+ ))} +
+
+
+ ); + })} +
+ )} + + )} + {index < rateLimitGroups.length - 1 && } +
+ ); + })} +
+ )} +
+
- - - -

Rate limits

-
-
- -
- - Rate limits -
-
- - -
- -
-
-
- Last updated {formatTime(quotaLastUpdated)} -
-
- {!hasRateLimits && ( - event.preventDefault()}> - No rate limits available. - - )} - {rateLimitGroups.map((group, index) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - - return ( - - - - {group.providerName} - - - {/* Provider-level entries */} - {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( - event.preventDefault()} - > - No rate limits reported. - - ) : ( - <> - {/* Provider-level windows */} - {group.entries.map(([label, window]) => ( - event.preventDefault()} - > - - {(() => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - return ( - <> - - {formatWindowLabel(label)} - - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - - - - - {window.resetAfterFormatted ?? window.resetAtFormatted ?? ''} - - - ); - })()} - - - ))} - - {/* Model families with collapsible sections - default COLLAPSED */} - {group.modelFamilies && group.modelFamilies.length > 0 && ( -
- {group.modelFamilies.map((family) => { - const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); - - return ( - toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} - > - - - {family.familyLabel} - - {isExpanded ? ( - - ) : ( - - )} - - -
- {family.models.map(([modelName, window]) => ( -
-
- - {modelName} - {(() => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - return ( - - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - - ); - })()} - - {(() => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - return ( - - ); - })()} -
-
- ))} -
-
-
- ); - })} -
- )} - - )} - {index < rateLimitGroups.length - 1 && } -
- ); - })} -
- - + + + + + +

Terminal panel

+
+
-

Keyboard Shortcuts ({getModifierLabel()}+.)

+

Git sidebar

+ {githubAuthStatus?.connected && !isMobile ? ( githubAccounts.length > 1 ? ( diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 4d7ae864..a523c6ad 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Header } from './Header'; +import { BottomTerminalDock } from './BottomTerminalDock'; import { Sidebar } from './Sidebar'; +import { RightSidebar } from './RightSidebar'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { CommandPalette } from '../ui/CommandPalette'; import { HelpDialog } from '../ui/HelpDialog'; @@ -19,8 +21,16 @@ import { cn } from '@/lib/utils'; import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views'; export const MainLayout: React.FC = () => { + const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140; + const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220; + const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640; + const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700; const { isSidebarOpen, + isRightSidebarOpen, + isBottomTerminalOpen, + setRightSidebarOpen, + setBottomTerminalOpen, activeMainTab, setIsMobile, isSessionSwitcherOpen, @@ -32,6 +42,8 @@ export const MainLayout: React.FC = () => { } = useUIStore(); const { isMobile } = useDeviceInfo(); + const rightSidebarAutoClosedRef = React.useRef(false); + const bottomTerminalAutoClosedRef = React.useRef(false); useEdgeSwipe({ enabled: true }); @@ -78,6 +90,95 @@ export const MainLayout: React.FC = () => { }; }, []); + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + let timeoutId: number | undefined; + + const handleResponsivePanels = () => { + const state = useUIStore.getState(); + const width = window.innerWidth; + const height = window.innerHeight; + + const shouldCloseRightSidebar = width < RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH; + const canAutoOpenRightSidebar = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH; + + if (shouldCloseRightSidebar) { + if (state.isRightSidebarOpen) { + setRightSidebarOpen(false); + rightSidebarAutoClosedRef.current = true; + } + } else if (canAutoOpenRightSidebar && rightSidebarAutoClosedRef.current) { + setRightSidebarOpen(true); + rightSidebarAutoClosedRef.current = false; + } + + const shouldCloseBottomTerminal = + height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT; + const canAutoOpenBottomTerminal = + height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT; + + if (shouldCloseBottomTerminal) { + if (state.isBottomTerminalOpen) { + setBottomTerminalOpen(false); + bottomTerminalAutoClosedRef.current = true; + } + } else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) { + setBottomTerminalOpen(true); + bottomTerminalAutoClosedRef.current = false; + } + }; + + const handleResize = () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + + timeoutId = window.setTimeout(() => { + handleResponsivePanels(); + }, 100); + }; + + handleResponsivePanels(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }; + }, [setBottomTerminalOpen, setRightSidebarOpen]); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const unsubscribe = useUIStore.subscribe((state, prevState) => { + const width = window.innerWidth; + const height = window.innerHeight; + + const rightCanAutoOpen = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH; + const bottomCanAutoOpen = + height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT; + + if (state.isRightSidebarOpen !== prevState.isRightSidebarOpen && rightCanAutoOpen) { + rightSidebarAutoClosedRef.current = false; + } + + if (state.isBottomTerminalOpen !== prevState.isBottomTerminalOpen && bottomCanAutoOpen) { + bottomTerminalAutoClosedRef.current = false; + } + }); + + return () => { + unsubscribe(); + }; + }, []); + React.useEffect(() => { if (typeof window === 'undefined' || typeof document === 'undefined') { return; @@ -378,16 +479,26 @@ export const MainLayout: React.FC = () => { -
-
- +
+
+
+
+ +
+ {secondaryView && ( +
+ {secondaryView} +
+ )} +
+ + +
- {secondaryView && ( -
- {secondaryView} -
- )} -
+ + + +
diff --git a/packages/ui/src/components/layout/RightSidebar.tsx b/packages/ui/src/components/layout/RightSidebar.tsx new file mode 100644 index 00000000..53146d0b --- /dev/null +++ b/packages/ui/src/components/layout/RightSidebar.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; + +const RIGHT_SIDEBAR_MIN_WIDTH = 400; +const RIGHT_SIDEBAR_MAX_WIDTH = 860; + +interface RightSidebarProps { + isOpen: boolean; + isMobile: boolean; + children: React.ReactNode; +} + +export const RightSidebar: React.FC = ({ isOpen, isMobile, children }) => { + 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 || 420); + + React.useEffect(() => { + if (isMobile || !isResizing) { + return; + } + + const handlePointerMove = (event: PointerEvent) => { + const delta = startXRef.current - event.clientX; + const nextWidth = Math.min( + RIGHT_SIDEBAR_MAX_WIDTH, + Math.max(RIGHT_SIDEBAR_MIN_WIDTH, startWidthRef.current + delta) + ); + setRightSidebarWidth(nextWidth); + }; + + const handlePointerUp = () => { + setIsResizing(false); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp, { once: true }); + + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }; + }, [isMobile, isResizing, setRightSidebarWidth]); + + React.useEffect(() => { + if (isMobile && isResizing) { + setIsResizing(false); + } + }, [isMobile, isResizing]); + + if (isMobile) { + return null; + } + + const appliedWidth = isOpen + ? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420)) + : 0; + + const handlePointerDown = (event: React.PointerEvent) => { + if (!isOpen) { + return; + } + setIsResizing(true); + startXRef.current = event.clientX; + startWidthRef.current = appliedWidth; + event.preventDefault(); + }; + + return ( + + ); +}; diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index d123a2e9..0c18ddf1 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RiDownloadLine, RiInformationLine, RiSettings3Line } from '@remixicon/react'; +import { RiDownloadLine, RiInformationLine, RiQuestionLine, RiSettings3Line } from '@remixicon/react'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -20,7 +20,7 @@ interface SidebarProps { } export const Sidebar: React.FC = ({ isOpen, isMobile, children }) => { - const { sidebarWidth, setSidebarWidth, setSettingsDialogOpen, setAboutDialogOpen } = useUIStore(); + const { sidebarWidth, setSidebarWidth, setSettingsDialogOpen, setAboutDialogOpen, toggleHelpDialog } = useUIStore(); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH); @@ -193,39 +193,58 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children }) Settings - {(available || downloaded) ? ( - +
+ {(available || downloaded) ? ( + - ) : !isDesktopApp && ( + ) : !isDesktopApp && ( + + + + + About OpenChamber + + )} - About OpenChamber + Keyboard shortcuts - )} +
= ({ active, className }) => { + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const directory = currentDirectory ?? null; + const status = useMcpStore((state) => state.getStatusForDirectory(directory)); + const refresh = useMcpStore((state) => state.refresh); + const connect = useMcpStore((state) => state.connect); + const disconnect = useMcpStore((state) => state.disconnect); + const [isSpinning, setIsSpinning] = React.useState(false); + const [busyName, setBusyName] = React.useState(null); + + React.useEffect(() => { + void refresh({ directory, silent: true }); + }, [refresh, directory]); + + React.useEffect(() => { + if (!active) return; + void refresh({ directory, silent: true }); + }, [active, refresh, directory]); + + const sortedNames = React.useMemo(() => { + return Object.keys(status).sort((a, b) => a.localeCompare(b)); + }, [status]); + + const handleRefresh = React.useCallback((e?: React.MouseEvent) => { + e?.preventDefault(); + if (isSpinning) return; + setIsSpinning(true); + const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500)); + Promise.all([refresh({ directory }), minSpinPromise]).finally(() => { + setIsSpinning(false); + }); + }, [isSpinning, refresh, directory]); + + return ( +
+
+
+
+
MCP Servers
+ {directory && ( +
+ {directory.split('/').pop() || directory} +
+ )} +
+ +
+
+ +
+ {sortedNames.map((serverName) => { + const serverStatus = status[serverName]; + const tone = statusTone(serverStatus); + const isConnected = serverStatus?.status === 'connected'; + const isBusy = busyName === serverName; + const tooltip = statusTooltip(serverStatus); + + return ( +
+
+
+ + + + + +

{tooltip}

+
+
+ {serverName} +
+
+ + { + setBusyName(serverName); + try { + if (checked) { + await connect(serverName, directory); + } else { + await disconnect(serverName, directory); + } + } finally { + setBusyName(null); + } + }} + /> +
+ ); + })} + + {sortedNames.length === 0 && ( +
+ Configure MCP servers in Opencode config. +
+ )} +
+
+ ); +}; + export const McpDropdown: React.FC = ({ headerIconButtonClass }) => { const [open, setOpen] = React.useState(false); const [tooltipOpen, setTooltipOpen] = React.useState(false); @@ -277,35 +405,7 @@ export const McpDropdown: React.FC = ({ headerIconButtonClass -
- MCP Servers - -
- - - -
- {renderServerList()} -
- - {directory && ( - <> - -
- - {directory.split('/').pop() || directory} - -
- - )} +
); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 53ab8f53..52c6b6ec 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -41,10 +41,6 @@ import { RiFileCopyLine, RiFolderAddLine, RiGitBranchLine, - RiGitClosePullRequestLine, - RiGitMergeLine, - RiGitPrDraftLine, - RiGitPullRequestLine, RiLinkUnlinkM, RiGithubLine, @@ -71,10 +67,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { useGitStore } from '@/stores/useGitStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import type { GitHubPullRequestStatus } from '@/lib/api/types'; import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog'; -import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog'; const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); @@ -87,9 +80,6 @@ const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; -const PR_REVALIDATE_TTL_MS = 90_000; -const PR_REVALIDATE_INTERVAL_MS = 90_000; -const PR_REVALIDATE_CONCURRENCY = 3; const formatDateLabel = (value: string | number) => { const targetDate = new Date(value); @@ -150,58 +140,6 @@ const toFiniteNumber = (value: unknown): number | undefined => { return undefined; }; -const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => { - const pr = status?.pr; - if (!pr) { - return null; - } - if (pr.state === 'merged') { - return 'merged'; - } - if (pr.state === 'closed') { - return 'closed'; - } - if (pr.draft) { - return 'draft'; - } - const checksFailed = status?.checks?.state === 'failure'; - const notMergeable = status?.canMerge === false || pr.mergeable === false; - if (checksFailed || notMergeable) { - return 'blocked'; - } - return 'open'; -}; - -const getPrTooltipLabel = (status: GitHubPullRequestStatus | null): string => { - const pr = status?.pr; - if (!pr) { - return 'Open pull request'; - } - const parts: string[] = [`PR #${pr.number}`]; - if (pr.state === 'merged') { - parts.push('Merged'); - } else if (pr.state === 'closed') { - parts.push('Closed'); - } else if (pr.draft) { - parts.push('Draft'); - } else { - parts.push('Open'); - } - if (status?.checks?.state === 'failure') { - parts.push('Checks failing'); - } - if (status?.canMerge === false || pr.mergeable === false) { - parts.push('Merge blocked'); - } - return parts.join(' · '); -}; - -type TauriShell = { - shell?: { - open?: (url: string) => Promise; - }; -}; - const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => { if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) { return transform; @@ -256,7 +194,6 @@ interface SortableProjectItemProps { onNewSession: () => void; onNewWorktreeSession?: () => void; onNewSessionFromGitHubIssue?: () => void; - onNewSessionFromGitHubPR?: () => void; onOpenMultiRunLauncher: () => void; onRenameStart: () => void; onRenameSave: () => void; @@ -289,7 +226,6 @@ const SortableProjectItem: React.FC = ({ onNewSession, onNewWorktreeSession, onNewSessionFromGitHubIssue, - onNewSessionFromGitHubPR, onOpenMultiRunLauncher, onRenameStart, onRenameSave, @@ -448,12 +384,6 @@ const SortableProjectItem: React.FC = ({ New session from GitHub issue )} - {showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && ( - - - New session from GitHub PR - - )} {showCreateButtons && isRepo && !hideDirectoryControls && ( @@ -604,8 +534,6 @@ export const SessionSidebar: React.FC = ({ const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [hoveredProjectId, setHoveredProjectId] = React.useState(null); const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); - const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false); - const [worktreePrByGroupKey, setWorktreePrByGroupKey] = React.useState>(new Map()); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); const [collapsedGroups, setCollapsedGroups] = React.useState>(() => { @@ -661,7 +589,6 @@ export const SessionSidebar: React.FC = ({ const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false); const [projectRenameDraft, setProjectRenameDraft] = React.useState(''); const [projectRootBranches, setProjectRootBranches] = React.useState>(new Map()); - const worktreePrLastCheckedAtRef = React.useRef>(new Map()); const projectHeaderSentinelRefs = React.useRef>(new Map()); const ignoreIntersectionUntil = React.useRef(0); const persistCollapsedProjectsTimer = React.useRef(null); @@ -682,8 +609,6 @@ export const SessionSidebar: React.FC = ({ const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher); - const { github, git } = useRuntimeAPIs(); - const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); const gitDirectories = useGitStore((state) => state.directories); @@ -709,24 +634,6 @@ export const SessionSidebar: React.FC = ({ const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - const openExternal = React.useCallback(async (url: string) => { - if (typeof window === 'undefined') return; - const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; - if (tauri?.shell?.open) { - try { - await tauri.shell.open(url); - return; - } catch { - // fall through - } - } - try { - window.open(url, '_blank', 'noopener,noreferrer'); - } catch { - // ignore - } - }, []); - const flushCollapsedProjectsPersist = React.useCallback(() => { if (isVSCode) { return; @@ -1493,219 +1400,6 @@ export const SessionSidebar: React.FC = ({ ); const reserveHeaderActionsSpace = activeProjectRepoState !== false; - const worktreePrInFlight = React.useRef>(new Set()); - const visibleWorktreeGroups = React.useMemo(() => { - return visibleProjectSections.flatMap((section) => - section.groups - .filter((group) => !group.isMain) - .map((group) => ({ - key: `${section.project.id}:${group.id}`, - directory: group.directory, - label: group.label, - worktree: group.worktree, - })) - ); - }, [visibleProjectSections]); - - React.useEffect(() => { - const validKeys = new Set(); - projectSections.forEach((section) => { - section.groups.forEach((group) => { - if (!group.isMain) { - validKeys.add(`${section.project.id}:${group.id}`); - } - }); - }); - setWorktreePrByGroupKey((prev) => { - if (prev.size === 0) return prev; - const next = new Map(prev); - let mutated = false; - Array.from(next.keys()).forEach((key) => { - if (!validKeys.has(key)) { - next.delete(key); - mutated = true; - } - }); - return mutated ? next : prev; - }); - Array.from(worktreePrLastCheckedAtRef.current.keys()).forEach((key) => { - if (!validKeys.has(key)) { - worktreePrLastCheckedAtRef.current.delete(key); - } - }); - }, [projectSections]); - - const ensureWorktreePrLoaded = React.useCallback(async ( - groupKey: string, - directory: string | null, - label: string, - worktree?: WorktreeMetadata | null, - options?: { force?: boolean }, - ) => { - if (!github?.prStatus || !directory || worktreePrInFlight.current.has(groupKey)) { - return; - } - const lastCheckedAt = worktreePrLastCheckedAtRef.current.get(groupKey) ?? 0; - const isFresh = Date.now() - lastCheckedAt < PR_REVALIDATE_TTL_MS; - if (!options?.force && isFresh) { - return; - } - - const resolvedBranchFromDirectory = await git.getGitStatus(directory) - .then((status) => status?.current ?? '') - .catch(() => ''); - const normalizeBranchCandidate = (value: string) => value - .replace(/^refs\/heads\//, '') - .replace(/^remotes\//, '') - .replace(/^origin\//, '') - .trim(); - const branches = [resolvedBranchFromDirectory, worktree?.branch, worktree?.name, worktree?.label, label] - .map((value) => (value || '').trim()) - .map(normalizeBranchCandidate) - .filter((value) => value.length > 0); - const uniqueBranches = Array.from(new Set(branches)); - if (uniqueBranches.length === 0) { - worktreePrLastCheckedAtRef.current.set(groupKey, Date.now()); - return; - } - - worktreePrInFlight.current.add(groupKey); - try { - let matched: GitHubPullRequestStatus | null = null; - for (const branch of uniqueBranches) { - const status = await github.prStatus(directory, branch); - const hasPr = status?.connected !== false && Boolean(status?.pr); - if (hasPr) { - matched = status; - break; - } - } - setWorktreePrByGroupKey((prev) => { - const current = prev.get(groupKey); - if (!matched) { - if (!current) { - return prev; - } - const next = new Map(prev); - next.delete(groupKey); - return next; - } - - const currentPr = current?.pr; - const nextPr = matched.pr; - const unchanged = Boolean( - currentPr - && nextPr - && currentPr.number === nextPr.number - && currentPr.state === nextPr.state - && currentPr.draft === nextPr.draft - && currentPr.mergeable === nextPr.mergeable - && current?.canMerge === matched.canMerge - && current?.checks?.state === matched.checks?.state - && current?.checks?.failure === matched.checks?.failure - && current?.checks?.pending === matched.checks?.pending - && current?.checks?.success === matched.checks?.success - ); - - if (unchanged) { - return prev; - } - - const next = new Map(prev); - next.set(groupKey, matched); - return next; - }); - } catch { - setWorktreePrByGroupKey((prev) => { - if (!prev.has(groupKey)) { - return prev; - } - const next = new Map(prev); - next.delete(groupKey); - return next; - }); - } finally { - worktreePrLastCheckedAtRef.current.set(groupKey, Date.now()); - worktreePrInFlight.current.delete(groupKey); - } - }, [github, git]); - - const revalidateVisibleWorktreePrs = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean }) => { - const targetGroups = visibleWorktreeGroups.filter((group) => { - if (!group.directory) { - return false; - } - if (options?.onlyExistingPr && !worktreePrByGroupKey.has(group.key)) { - return false; - } - return true; - }); - if (targetGroups.length === 0) { - return; - } - - let cursor = 0; - const workerCount = Math.min(PR_REVALIDATE_CONCURRENCY, targetGroups.length); - await Promise.all( - Array.from({ length: workerCount }).map(async () => { - while (cursor < targetGroups.length) { - const index = cursor; - cursor += 1; - const group = targetGroups[index]; - await ensureWorktreePrLoaded(group.key, group.directory, group.label, group.worktree, { force: options?.force }); - } - }) - ); - }, [visibleWorktreeGroups, worktreePrByGroupKey, ensureWorktreePrLoaded]); - - React.useEffect(() => { - if (visibleWorktreeGroups.length === 0) { - return; - } - const timer = window.setTimeout(() => { - void revalidateVisibleWorktreePrs(); - }, 120); - return () => { - window.clearTimeout(timer); - }; - }, [visibleWorktreeGroups, revalidateVisibleWorktreePrs]); - - React.useEffect(() => { - if (!activeProjectId) { - return; - } - void revalidateVisibleWorktreePrs(); - }, [activeProjectId, revalidateVisibleWorktreePrs]); - - React.useEffect(() => { - const onFocus = () => { - void revalidateVisibleWorktreePrs(); - }; - const onVisibility = () => { - if (document.visibilityState === 'visible') { - void revalidateVisibleWorktreePrs(); - } - }; - window.addEventListener('focus', onFocus); - document.addEventListener('visibilitychange', onVisibility); - return () => { - window.removeEventListener('focus', onFocus); - document.removeEventListener('visibilitychange', onVisibility); - }; - }, [revalidateVisibleWorktreePrs]); - - React.useEffect(() => { - const interval = window.setInterval(() => { - if (document.visibilityState !== 'visible') { - return; - } - void revalidateVisibleWorktreePrs({ onlyExistingPr: true }); - }, PR_REVALIDATE_INTERVAL_MS); - return () => { - window.clearInterval(interval); - }; - }, [revalidateVisibleWorktreePrs]); - const projectSessionMeta = React.useMemo(() => { const metaByProject = new Map>(); const firstSessionByProject = new Map(); @@ -2273,17 +1967,6 @@ export const SessionSidebar: React.FC = ({ const allGroupSessions = collectGroupSessions(group.sessions); const normalizedGroupDirectory = normalizePath(group.directory ?? null); const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId)); - const groupPrStatus = projectId ? worktreePrByGroupKey.get(groupKey) ?? null : null; - const groupPr = groupPrStatus?.pr ?? null; - const prVisualState = getPrVisualState(groupPrStatus); - const prColorVar = prVisualState ? `var(--pr-${prVisualState})` : 'var(--status-info)'; - const PrStateIcon = prVisualState === 'draft' - ? RiGitPrDraftLine - : prVisualState === 'merged' - ? RiGitMergeLine - : prVisualState === 'closed' - ? RiGitClosePullRequestLine - : RiGitPullRequestLine; const isActiveGroup = Boolean( normalizedGroupDirectory && currentSessionDirectory @@ -2294,15 +1977,7 @@ export const SessionSidebar: React.FC = ({
{ - if (!group.isMain) { - void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree); - } - }} onClick={() => { - if (!group.isMain) { - void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree); - } setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(groupKey)) { @@ -2318,9 +1993,6 @@ export const SessionSidebar: React.FC = ({ onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - if (!group.isMain) { - void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree); - } setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(groupKey)) { @@ -2341,31 +2013,7 @@ export const SessionSidebar: React.FC = ({ )} {!group.isMain || isGitProject ? ( - !group.isMain && groupPr?.url ? ( - - - - - -

{getPrTooltipLabel(groupPrStatus)}

-
-
- ) : ( - - ) + ) : null}

@@ -2472,9 +2120,6 @@ export const SessionSidebar: React.FC = ({ hideDirectoryControls, currentSessionDirectory, projectRepoStatus, - worktreePrByGroupKey, - openExternal, - ensureWorktreePrLoaded, renderSessionNode, toggleGroupSessionLimit, activeProjectId, @@ -2663,19 +2308,6 @@ export const SessionSidebar: React.FC = ({

New from issue

- - - - -

New from PR

-
); }; diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index cd1255bb..115ee4cf 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -64,11 +64,12 @@ interface TerminalViewportProps { fontSize: number; className?: string; enableTouchScroll?: boolean; + autoFocus?: boolean; } const TerminalViewport = React.forwardRef( ( - { sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, enableTouchScroll }, + { sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, enableTouchScroll, autoFocus = true }, ref ) => { const containerRef = React.useRef(null); @@ -94,6 +95,7 @@ const TerminalViewport = React.forwardRef(null); const keydownProbeTimeoutRef = React.useRef(null); const lastObservedValueRef = React.useRef(''); + const cursorBlinkStateRef = React.useRef(null); const [, forceRender] = React.useReducer((x) => x + 1, 0); const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0); @@ -157,6 +159,36 @@ const TerminalViewport = React.forwardRef { + if (cursorBlinkStateRef.current === enabled) { + return; + } + + const terminal = terminalRef.current as unknown as { + setOption?: (key: string, value: unknown) => void; + options?: { cursorBlink?: boolean }; + } | null; + + if (!terminal) { + return; + } + + try { + if (typeof terminal.setOption === 'function') { + terminal.setOption('cursorBlink', enabled); + cursorBlinkStateRef.current = enabled; + return; + } + + if (terminal.options) { + terminal.options.cursorBlink = enabled; + cursorBlinkStateRef.current = enabled; + } + } catch { + // ignored + } + }, []); + const useTextInput = useHiddenInputOverlay && isAndroid; const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => { @@ -198,6 +230,16 @@ const TerminalViewport = React.forwardRef { + if (useHiddenInputOverlay) { + focusHiddenInput(); + setTerminalCursorBlink(true); + return; + } + terminalRef.current?.focus(); + setTerminalCursorBlink(true); + }, [focusHiddenInput, setTerminalCursorBlink, useHiddenInputOverlay]); + const readEditableValue = React.useCallback((target: HTMLElement) => { if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) { return target.value; @@ -839,6 +881,29 @@ const TerminalViewport = React.forwardRef { + setTerminalCursorBlink(true); + }; + + const handleTerminalTextareaBlur = () => { + setTerminalCursorBlink(false); + }; + + const handleDocumentFocusIn = (event: FocusEvent) => { + const target = event.target as Node | null; + if (target && container.contains(target)) { + setTerminalCursorBlink(true); + return; + } + setTerminalCursorBlink(false); + }; + + const handleWindowBlur = () => { + setTerminalCursorBlink(false); + }; + + let localTerminalTextarea: HTMLTextAreaElement | null = null; + const initialize = async () => { try { const ghostty = await getGhostty(); @@ -859,6 +924,16 @@ const TerminalViewport = React.forwardRef { inputHandlerRef.current(data); @@ -907,12 +978,22 @@ const TerminalViewport = React.forwardRef { disposed = true; touchScrollCleanupRef.current?.(); touchScrollCleanupRef.current = null; + document.removeEventListener('focusin', handleDocumentFocusIn, true); + window.removeEventListener('blur', handleWindowBlur); + localDisposables.forEach((disposable) => disposable.dispose()); + if (localTerminalTextarea) { + localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus); + localTerminalTextarea.removeEventListener('blur', handleTerminalTextareaBlur); + } localResizeObserver?.disconnect(); localTextareaObserver?.disconnect(); @@ -921,9 +1002,10 @@ const TerminalViewport = React.forwardRef { @@ -935,10 +1017,20 @@ const TerminalViewport = React.forwardRef { + if (!autoFocus) { + return; } - }, [useHiddenInputOverlay, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]); + + const terminal = terminalRef.current; + if (!terminal) { + return; + } + + focusTerminalInput(); + }, [autoFocus, focusTerminalInput, sessionKey, terminalReadyVersion]); React.useEffect(() => { setupTouchScroll(); @@ -984,11 +1076,7 @@ const TerminalViewport = React.forwardRef ({ focus: () => { - if (useHiddenInputOverlay) { - focusHiddenInput(); - return; - } - terminalRef.current?.focus(); + focusTerminalInput(); }, clear: () => { const terminal = terminalRef.current; @@ -1003,7 +1091,7 @@ const TerminalViewport = React.forwardRef { isInteractive?: boolean; animate?: boolean; collapseLabelsOnSmall?: boolean; + collapseLabelsOnNarrow?: boolean; + size?: 'default' | 'sm'; } export function AnimatedTabs({ @@ -25,9 +27,12 @@ export function AnimatedTabs({ isInteractive = true, animate = true, collapseLabelsOnSmall = false, + collapseLabelsOnNarrow = false, + size = 'default', }: AnimatedTabsProps) { const containerRef = React.useRef(null); const activeTabRef = React.useRef(null); + const [isReadyToAnimate, setIsReadyToAnimate] = React.useState(false); const updateClipPath = React.useCallback(() => { const container = containerRef.current; @@ -47,7 +52,10 @@ export function AnimatedTabs({ React.useLayoutEffect(() => { updateClipPath(); - }, [updateClipPath, value, tabs.length]); + if (!isReadyToAnimate) { + setIsReadyToAnimate(true); + } + }, [isReadyToAnimate, updateClipPath, value, tabs.length]); React.useEffect(() => { const container = containerRef.current; @@ -60,28 +68,34 @@ export function AnimatedTabs({ }, [updateClipPath]); return ( -
+
-
+ > +
{tabs.map((tab) => { const Icon = tab.icon; return (
- {Icon ? : null} - + {Icon ? : null} + {tab.label}
@@ -91,7 +105,12 @@ export function AnimatedTabs({
-
+
{tabs.map((tab) => { const isActive = value === tab.value; const Icon = tab.icon; @@ -105,8 +124,9 @@ export function AnimatedTabs({ if (!isInteractive) return; onValueChange(tab.value); }} - className={cn( - 'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold transition-colors duration-150', + className={cn( + 'animated-tabs__button flex flex-1 items-center justify-center font-semibold transition-colors duration-150', + size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm', collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25', isActive ? 'text-accent-foreground' : 'text-muted-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background' @@ -118,12 +138,15 @@ export function AnimatedTabs({ > {Icon ? ( ) : null} - - {tab.label} - + + {tab.label} + ); diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 1840155f..21676684 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -15,6 +15,7 @@ import { useIsGitRepo, } from '@/stores/useGitStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { RiGitBranchLine, RiGitMergeLine, @@ -47,9 +48,9 @@ import { useUIStore } from '@/stores/useUIStore'; import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; -import { GitEmptyState } from './git/GitEmptyState'; import { ChangesSection } from './git/ChangesSection'; 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'; @@ -59,12 +60,18 @@ import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIn import type { GitRemote } from '@/lib/gitApi'; import { BranchPickerDialog } from '@/components/session/BranchPickerDialog'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; +import { cn } from '@/lib/utils'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; type BranchOperation = 'merge' | 'rebase' | null; type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree'; +const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab'; + +const isActionTab = (value: unknown): value is ActionTab => + value === 'commit' || value === 'branch' || value === 'pr' || value === 'worktree'; + type GitViewSnapshot = { directory?: string; @@ -206,7 +213,11 @@ const gitViewSnapshots = new Map(); const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); -export const GitView: React.FC = () => { +interface GitViewProps { + mode?: 'full' | 'sidebar'; +} + +export const GitView: React.FC = ({ mode = 'full' }) => { const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); const { currentSessionId, worktreeMetadata: worktreeMap } = useSessionStore(); @@ -294,11 +305,13 @@ export const GitView: React.FC = () => { initialSnapshot?.commitMessage ?? '' ); const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false); + const actionPanelScrollRef = React.useRef(null); const [syncAction, setSyncAction] = React.useState(null); const [commitAction, setCommitAction] = React.useState(null); const [logMaxCountLocal, setLogMaxCountLocal] = React.useState(25); const [isSettingIdentity, setIsSettingIdentity] = React.useState(false); const { triggerFireworks } = useFireworksCelebration(); + const isSidebarMode = mode === 'sidebar'; const autoAppliedDefaultRef = React.useRef>(new Map()); const identityApplyCountRef = React.useRef(0); @@ -326,6 +339,17 @@ export const GitView: React.FC = () => { initialSnapshot?.generatedHighlights ?? [] ); + const scrollActionPanelToBottom = React.useCallback(() => { + const scrollTarget = actionPanelScrollRef.current; + if (!scrollTarget) return; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + scrollTarget.scrollTo({ top: scrollTarget.scrollHeight, behavior: 'smooth' }); + }); + }); + }, []); + const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null; const sourceBranchForIntegrate = status?.current || null; const shouldShowIntegrateCommits = React.useMemo(() => { @@ -372,7 +396,13 @@ export const GitView: React.FC = () => { const [gitmojiEmojis, setGitmojiEmojis] = React.useState([]); const [gitmojiSearch, setGitmojiSearch] = React.useState(''); const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false); - const [actionTab, setActionTab] = React.useState('commit'); + const [actionTab, setActionTab] = React.useState(() => { + if (typeof window === 'undefined') { + return 'commit'; + } + const stored = window.localStorage.getItem(GIT_ACTION_TAB_STORAGE_KEY); + return isActionTab(stored) ? stored : 'commit'; + }); const [remotes, setRemotes] = React.useState([]); const [branchOperation, setBranchOperation] = React.useState(null); const [operationLogs, setOperationLogs] = React.useState([]); @@ -403,6 +433,13 @@ export const GitView: React.FC = () => { 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; @@ -797,7 +834,7 @@ export const GitView: React.FC = () => { } setGeneratedHighlights(highlights); - toast.success('Commit message generated'); + scrollActionPanelToBottom(); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to generate commit message'; @@ -805,7 +842,7 @@ export const GitView: React.FC = () => { } finally { setIsGeneratingMessage(false); } - }, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis]); + }, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]); const handleCreateBranch = async (branchName: string) => { if (!currentDirectory || !status) return; @@ -1048,10 +1085,8 @@ export const GitView: React.FC = () => { return globalIdentity ?? null; }, [currentIdentity, profiles, globalIdentity]); - const uniqueChangeCount = changeEntries.length; const selectedCount = selectedPaths.size; const isBusy = isLoading || syncAction !== null || commitAction !== null; - const hasChanges = uniqueChangeCount > 0; const canShowIntegrateCommitsSection = Boolean( worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ); @@ -1561,6 +1596,7 @@ export const GitView: React.FC = () => { onSelectIdentity={handleApplyIdentity} isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} + isSidebarMode={isSidebarMode} onOpenHistory={() => setIsHistoryDialogOpen(true)} onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined} /> @@ -1582,47 +1618,17 @@ export const GitView: React.FC = () => { )}
-
-
- {hasChanges ? ( - useUIStore.getState().navigateToDiff(path)} - onRevertFile={handleRevertFile} - /> - ) : ( -
- { - if (effectiveRemotes.length > 0) { - handleSyncAction('pull', effectiveRemotes[0]); - } else { - toast.error('No remotes configured'); - } - }} - isPulling={syncAction === 'pull'} - /> -
- )} -
- -
+
+
value={actionTab} onValueChange={setActionTab} collapseLabelsOnSmall + collapseLabelsOnNarrow={isSidebarMode} tabs={[ { value: 'commit', label: 'Commit', icon: RiGitCommitLine }, - { value: 'branch', label: 'Update branch', icon: RiGitMergeLine }, + { value: 'branch', label: 'Update', icon: RiGitMergeLine }, { value: 'pr', label: 'PR', icon: RiGitPullRequestLine }, { value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal }, ]} @@ -1631,29 +1637,63 @@ export const GitView: React.FC = () => {
{actionTab === 'commit' ? ( - handleCommit({ pushAfter: false })} - onCommitAndPush={() => handleCommit({ pushAfter: true })} - commitAction={commitAction} - isBusy={isBusy} - gitmojiEnabled={settingsGitmojiEnabled} - onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} - /> +
+ {(changeEntries?.length ?? 0) > 0 ? ( + <> + useUIStore.getState().navigateToDiff(path)} + onRevertFile={handleRevertFile} + /> + + handleCommit({ pushAfter: false })} + onCommitAndPush={() => handleCommit({ pushAfter: true })} + commitAction={commitAction} + isBusy={isBusy} + gitmojiEnabled={settingsGitmojiEnabled} + onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} + /> + + ) : ( + 0 ? (status?.behind ?? 0) : 0} + isPulling={syncAction === 'pull'} + onPull={() => { + const remote = effectiveRemotes[0]; + if (!remote) { + return; + } + void handleSyncAction('pull', remote); + }} + /> + )} +
) : null} {actionTab === 'branch' ? ( @@ -1711,6 +1751,7 @@ export const GitView: React.FC = () => { directory={pullRequestProps.directory} branch={pullRequestProps.branch} baseBranch={baseBranch} + onGeneratedDescription={scrollActionPanelToBottom} /> ) : (
diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index d3973447..0dcf2101 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { RiAddLine, RiAlertLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCheckboxCircleLine, RiCircleLine, RiCloseLine, RiCommandLine, RiDeleteBinLine, RiRestartLine } from '@remixicon/react'; +import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react'; import { useSessionStore } from '@/stores/useSessionStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { type TerminalStreamEvent } from '@/lib/api/types'; @@ -54,6 +53,15 @@ const STREAM_OPTIONS = { connectionTimeoutMs: 10_000, }; +const REHYDRATED_STREAM_OPTIONS = { + retry: { + maxRetries: 0, + initialDelayMs: 200, + maxDelayMs: 500, + }, + connectionTimeoutMs: 1_500, +}; + const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string | null => { if (modifier) { switch (key) { @@ -88,18 +96,6 @@ export const TerminalView: React.FC = () => { const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true; const effectiveDirectory = useEffectiveDirectory() ?? null; - const { homeDirectory } = useDirectoryStore(); - - const displayDirectory = React.useMemo(() => { - if (!effectiveDirectory) return ''; - if (!homeDirectory) return effectiveDirectory; - if (effectiveDirectory === homeDirectory) return '~'; - if (effectiveDirectory.startsWith(homeDirectory + '/')) { - return '~' + effectiveDirectory.slice(homeDirectory.length); - } - return effectiveDirectory; - }, [effectiveDirectory, homeDirectory]); - const terminalStore = useTerminalStore(); const terminalSessions = terminalStore.sessions; const terminalHydrated = terminalStore.hasHydrated; @@ -110,7 +106,6 @@ export const TerminalView: React.FC = () => { const setTabSessionId = terminalStore.setTabSessionId; const setConnecting = terminalStore.setConnecting; const appendToBuffer = terminalStore.appendToBuffer; - const clearBuffer = terminalStore.clearBuffer; const directoryTerminalState = React.useMemo(() => { if (!effectiveDirectory) return undefined; @@ -136,7 +131,6 @@ export const TerminalView: React.FC = () => { const terminalSessionId = activeTab?.terminalSessionId ?? null; const bufferChunks = activeTab?.bufferChunks ?? []; - const bufferLength = activeTab?.bufferLength ?? 0; const isConnecting = activeTab?.isConnecting ?? false; const [connectionError, setConnectionError] = React.useState(null); @@ -151,6 +145,7 @@ export const TerminalView: React.FC = () => { const directoryRef = React.useRef(effectiveDirectory); const terminalControllerRef = React.useRef(null); const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); + const isTerminalVisibleRef = React.useRef(false); const nudgeOnConnectTerminalIdRef = React.useRef(null); const rehydratedTerminalIdsRef = React.useRef>(new Set()); const rehydratedSnapshotTakenRef = React.useRef(false); @@ -177,15 +172,28 @@ export const TerminalView: React.FC = () => { }, [terminalHydrated]); const activeMainTab = useUIStore((state) => state.activeMainTab); + const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen); const isTerminalActive = activeMainTab === 'terminal'; + const isTerminalVisible = isTerminalActive || isBottomTerminalOpen; + const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible); React.useEffect(() => { - if (!isTerminalActive || runtime.platform === 'vscode') { + if (!isTerminalVisible || runtime.platform === 'vscode') { return; } primeTerminalInputTransport(); - }, [isTerminalActive, runtime.platform]); + }, [isTerminalVisible, runtime.platform]); + + React.useEffect(() => { + if (isTerminalVisible) { + setHasOpenedTerminalViewport(true); + } + }, [isTerminalVisible]); + + React.useEffect(() => { + isTerminalVisibleRef.current = isTerminalVisible; + }, [isTerminalVisible]); React.useEffect(() => { terminalIdRef.current = terminalSessionId; @@ -226,7 +234,12 @@ export const TerminalView: React.FC = () => { ); const startStream = React.useCallback( - (directory: string, tabId: string, terminalId: string) => { + ( + directory: string, + tabId: string, + terminalId: string, + streamOptions = STREAM_OPTIONS + ) => { if (activeTerminalIdRef.current === terminalId) { return; } @@ -313,7 +326,7 @@ export const TerminalView: React.FC = () => { } }, }, - STREAM_OPTIONS + streamOptions ); streamCleanupRef.current = () => { @@ -327,7 +340,7 @@ export const TerminalView: React.FC = () => { React.useEffect(() => { let cancelled = false; - if (!terminalHydrated) { + if (!terminalHydrated || !hasOpenedTerminalViewport) { return; } @@ -368,6 +381,9 @@ export const TerminalView: React.FC = () => { (tab?.bufferLength ?? 0) === 0 && (tab?.bufferChunks?.length ?? 0) === 0; + const isRehydratedSession = + Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string); + if (!terminalId) { setConnectionError(null); setIsFatalError(false); @@ -412,11 +428,19 @@ export const TerminalView: React.FC = () => { terminalIdRef.current = terminalId; - if (shouldNudgeExisting) { - nudgeOnConnectTerminalIdRef.current = terminalId; + if (isRehydratedSession) { rehydratedTerminalIdsRef.current.delete(terminalId); } - startStream(directory, tabId, terminalId); + + if (shouldNudgeExisting) { + nudgeOnConnectTerminalIdRef.current = terminalId; + } + startStream( + directory, + tabId, + terminalId, + isRehydratedSession ? REHYDRATED_STREAM_OPTIONS : STREAM_OPTIONS + ); }; void ensureSession(); @@ -431,6 +455,7 @@ export const TerminalView: React.FC = () => { effectiveDirectory, terminalSessionId, activeTabId, + hasOpenedTerminalViewport, enableTabs, terminalHydrated, ensureDirectory, @@ -441,6 +466,25 @@ export const TerminalView: React.FC = () => { terminal, ]); + React.useEffect(() => { + if (!isTerminalVisible) { + return; + } + + if (typeof window === 'undefined') { + terminalControllerRef.current?.focus(); + return; + } + + const rafId = window.requestAnimationFrame(() => { + terminalControllerRef.current?.focus(); + }); + + return () => { + window.cancelAnimationFrame(rafId); + }; + }, [activeTabId, isTerminalVisible]); + const handleRestart = React.useCallback(async () => { if (!effectiveDirectory) return; if (isRestarting) return; @@ -472,21 +516,6 @@ export const TerminalView: React.FC = () => { await handleRestart(); }, [handleRestart]); - const handleClear = React.useCallback(() => { - if (!effectiveDirectory) return; - if (!activeTabId) return; - clearBuffer(effectiveDirectory, activeTabId); - terminalControllerRef.current?.clear(); - terminalControllerRef.current?.focus(); - - const terminalId = terminalIdRef.current; - if (terminalId) { - void terminal.sendInput(terminalId, '\u000c').catch((error) => { - setConnectionError(error instanceof Error ? error.message : 'Failed to refresh prompt'); - }); - } - }, [activeTabId, clearBuffer, effectiveDirectory, setConnectionError, terminal]); - const handleCreateTab = React.useCallback(() => { if (!effectiveDirectory) return; const tabId = createTab(effectiveDirectory); @@ -565,6 +594,9 @@ export const TerminalView: React.FC = () => { const handleViewportResize = React.useCallback( (cols: number, rows: number) => { lastViewportSizeRef.current = { cols, rows }; + if (!isTerminalVisibleRef.current) { + return; + } const terminalId = terminalIdRef.current; if (!terminalId) return; void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => { @@ -705,7 +737,7 @@ export const TerminalView: React.FC = () => { const viewportSessionKey = terminalSessionId ?? terminalSessionKey; React.useEffect(() => { - if (!isTerminalActive) { + if (!isTerminalVisible) { return; } const controller = terminalControllerRef.current; @@ -727,19 +759,7 @@ export const TerminalView: React.FC = () => { }; } fitOnce(); - }, [isTerminalActive, terminalSessionKey, terminalSessionId]); - - const isReconnecting = connectionError?.includes('Reconnecting'); - - const statusIcon = connectionError - ? isReconnecting - ? - : - : terminalSessionId && !isConnecting && !isRestarting - ? - : isConnecting || isRestarting - ? - : ; + }, [isTerminalVisible, terminalSessionKey, terminalSessionId]); if (!hasActiveContext) { return ( @@ -764,195 +784,177 @@ export const TerminalView: React.FC = () => { } const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting; + const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport; + const quickKeysControls = ( + <> + + + + + + + + + + + ); return (
-
-
-
- {displayDirectory} -
- {isMobile ? ( -
- {statusIcon} - - -
- ) : null} -
- +
{enableTabs && directoryTerminalState ? ( -
- {directoryTerminalState.tabs.map((tab) => { - const isActive = tab.id === activeTabId; - return ( -
- - -
- ); - })} +
+
+
+ {directoryTerminalState.tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ + +
+ ); + })} - + +
+
+ + {!isMobile && showQuickKeys ? ( +
+ {quickKeysControls} +
+ ) : null}
) : null} - {showQuickKeys ? ( + + {showQuickKeys && (isMobile || !enableTabs || !directoryTerminalState) ? (
- - - - - - - - - + {quickKeysControls}
) : null}
@@ -962,8 +964,8 @@ export const TerminalView: React.FC = () => { style={{ backgroundColor: xtermTheme.background }} data-keyboard-avoid="true" > -
- {isTerminalActive ? ( +
+ {shouldRenderViewport ? ( isMobile ? ( { fontFamily={resolvedFontStack} fontSize={terminalFontSize} enableTouchScroll={hasTouchInput} + autoFocus={isTerminalVisible} /> ) : ( @@ -994,6 +997,7 @@ export const TerminalView: React.FC = () => { fontFamily={resolvedFontStack} fontSize={terminalFontSize} enableTouchScroll={hasTouchInput} + autoFocus={isTerminalVisible} /> ) diff --git a/packages/ui/src/components/views/git/BranchSelector.tsx b/packages/ui/src/components/views/git/BranchSelector.tsx index a475c389..dd586bee 100644 --- a/packages/ui/src/components/views/git/BranchSelector.tsx +++ b/packages/ui/src/components/views/git/BranchSelector.tsx @@ -36,6 +36,7 @@ interface BranchSelectorProps { onCheckout: (branch: string) => void; onCreate: (name: string) => Promise; disabled?: boolean; + tooltipDelayMs?: number; } const sanitizeBranchNameInput = (value: string): string => { @@ -59,6 +60,7 @@ export const BranchSelector: React.FC = ({ onCheckout, onCreate, disabled = false, + tooltipDelayMs = 1000, }) => { const [isOpen, setIsOpen] = React.useState(false); const [search, setSearch] = React.useState(''); @@ -127,17 +129,17 @@ export const BranchSelector: React.FC = ({ return ( - +
- -
    - {changeEntries.map((file) => ( - onToggleFile(file.path)} - onViewDiff={() => onViewDiff(file.path)} - onRevert={() => onRevertFile(file.path)} - isReverting={revertingPaths.has(file.path)} - /> - ))} -
-
+
+ +
    + {changeEntries.map((file) => ( + onToggleFile(file.path)} + onViewDiff={() => onViewDiff(file.path)} + onRevert={() => onRevertFile(file.path)} + isReverting={revertingPaths.has(file.path)} + /> + ))} +
+
+ +
); }; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 2c304f37..3fe42001 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { - RiArrowUpLine, - RiArrowDownLine, RiArrowDownSLine, + RiCheckLine, RiLoader4Line, RiGitBranchLine, RiGitRepositoryLine, @@ -26,6 +25,7 @@ import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types'; +import { useUIStore } from '@/stores/useUIStore'; type SyncAction = 'fetch' | 'pull' | 'push' | null; @@ -47,6 +47,7 @@ interface GitHeaderProps { onSelectIdentity: (profile: GitIdentityProfile) => void; isApplyingIdentity: boolean; isWorktreeMode: boolean; + isSidebarMode?: boolean; onOpenHistory?: () => void; onOpenBranchPicker?: () => void; } @@ -103,6 +104,8 @@ interface IdentityDropdownProps { identities: GitIdentityProfile[]; onSelect: (profile: GitIdentityProfile) => void; isApplying: boolean; + tooltipDelayMs?: number; + iconOnly?: boolean; } const IdentityDropdown: React.FC = ({ @@ -110,18 +113,20 @@ const IdentityDropdown: React.FC = ({ identities, onSelect, isApplying, + tooltipDelayMs = 1000, + iconOnly = false, }) => { const isDisabled = isApplying || identities.length === 0; return ( - + - -

- {activeProfile?.userName || 'Unknown user'} -

-

- {activeProfile?.userEmail || 'No email configured'} -

-
+ Git identity
{identities.length === 0 ? ( @@ -158,25 +158,31 @@ const IdentityDropdown: React.FC = ({

) : ( - identities.map((profile) => ( - onSelect(profile)}> - - - - - {profile.name} - - - {profile.userEmail} + identities.map((profile) => { + const isSelected = activeProfile?.id === profile.id; + return ( + onSelect(profile)}> + + + + + {profile.name} + + + {profile.userEmail} + + {isSelected ? ( + + ) : null} - - - )) + + ); + }) )} @@ -201,77 +207,31 @@ export const GitHeader: React.FC = ({ onSelectIdentity, isApplyingIdentity, isWorktreeMode, + isSidebarMode = false, onOpenHistory, onOpenBranchPicker, }) => { + const isMobile = useUIStore((state) => state.isMobile); + if (!status) { return null; } - return ( -
- {isWorktreeMode ? ( - - ) : ( - - )} - - {(Boolean(status.tracking) || status.ahead > 0 || status.behind > 0) && ( - - -
- - - {status.ahead} - - {Boolean(status.tracking) && ( - - - {status.behind} - - )} -
-
- - {status.tracking - ? `Upstream: ${status.tracking}` - : 'Unpublished commits (no upstream set yet)'} - -
- )} - - - -
+ const useTwoRowHeader = isSidebarMode || isMobile; + const managementButtons = ( +
{onOpenBranchPicker ? ( - + Manage branches @@ -279,28 +239,111 @@ export const GitHeader: React.FC = ({ ) : null} {onOpenHistory ? ( - + - Show commit history + History ) : null} +
+ ); - + const syncButtons = ( + + ); + + const identityControl = ( + + ); + + if (useTwoRowHeader) { + return ( +
+
+
+ {isWorktreeMode ? ( + + ) : ( + + )} +
+
+ +
+
+ {syncButtons} + {managementButtons} +
+
{identityControl}
+
+
+ ); + } + + return ( +
+
+ {isWorktreeMode ? ( + + ) : ( + + )} + +
{syncButtons}
+
+ +
+ {managementButtons} + {identityControl} +
); }; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index d3c4eb82..39052a51 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -1,13 +1,22 @@ import React from 'react'; import { + RiChat4Line, + RiCheckLine, + RiCheckboxCircleLine, RiAiGenerate2, + RiArrowDownSLine, + RiArrowRightSLine, RiCheckboxBlankLine, RiCheckboxLine, + RiCloseLine, + RiEditLine, + RiErrorWarningLine, RiExternalLinkLine, RiGitClosePullRequestLine, RiGitMergeLine, RiGitPrDraftLine, RiGitPullRequestLine, + RiInformationLine, RiLoader4Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; @@ -20,7 +29,10 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +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, @@ -30,6 +42,7 @@ import { generatePullRequestDescription } from '@/lib/gitApi'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageStore } from '@/stores/messageStore'; import { useSessionStore } from '@/stores/useSessionStore'; @@ -44,6 +57,10 @@ import type { type MergeMethod = 'merge' | 'squash' | 'rebase'; +const PR_REVALIDATE_TTL_MS = 90_000; +const PR_REVALIDATE_INTERVAL_MS = 30_000; +const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000; + const statusColor = (state: string | undefined | null): string => { switch (state) { case 'success': @@ -88,6 +105,8 @@ const branchToTitle = (branch: string): string => { .replace(/\b\w/g, (c) => c.toUpperCase()); }; +const getPullRequestSnapshotKey = (directory: string, branch: string): string => `${directory}::${branch}`; + type PullRequestDraftSnapshot = { title: string; body: string; @@ -95,7 +114,28 @@ type PullRequestDraftSnapshot = { additionalContext: string; }; +type TimelineCommentItem = { + id: string; + body: string; + authorName: string; + authorLogin: string | null; + avatarUrl: string | null; + createdAt?: string; + context: string; + path: string | null; + line: number | null; +}; + +type ChatDispatchTarget = { + sessionId: string; + providerID: string; + modelID: string; + currentAgentName: string | null; + currentVariant: string | null; +}; + const pullRequestDraftSnapshots = new Map(); +const pullRequestStatusSnapshots = new Map(); type TauriShell = { shell?: { @@ -128,7 +168,8 @@ export const PullRequestSection: React.FC<{ branch: string; baseBranch: string; variant?: 'framed' | 'plain'; -}> = ({ directory, branch, baseBranch, variant = 'framed' }) => { + onGeneratedDescription?: () => void; +}> = ({ directory, branch, baseBranch, variant = 'framed', onGeneratedDescription }) => { const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); @@ -143,15 +184,20 @@ export const PullRequestSection: React.FC<{ setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSidebarSection]); - const snapshotKey = React.useMemo(() => `${directory}::${branch}`, [directory, branch]); + const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]); const initialSnapshot = React.useMemo( () => pullRequestDraftSnapshots.get(snapshotKey) ?? null, [snapshotKey] ); + const initialStatusSnapshot = React.useMemo( + () => pullRequestStatusSnapshots.get(snapshotKey) ?? null, + [snapshotKey] + ); const [isLoading, setIsLoading] = React.useState(false); - const [status, setStatus] = React.useState(null); + const [status, setStatus] = React.useState(() => initialStatusSnapshot); const [error, setError] = React.useState(null); + const [isInitialStatusResolved, setIsInitialStatusResolved] = React.useState(() => Boolean(initialStatusSnapshot)); const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch)); const [body, setBody] = React.useState(() => initialSnapshot?.body ?? ''); @@ -161,8 +207,13 @@ export const PullRequestSection: React.FC<{ const [isGenerating, setIsGenerating] = React.useState(false); const [isCreating, setIsCreating] = React.useState(false); + const [isUpdating, setIsUpdating] = React.useState(false); const [isMerging, setIsMerging] = React.useState(false); const [isMarkingReady, setIsMarkingReady] = React.useState(false); + const [isEditingPr, setIsEditingPr] = React.useState(false); + const [hydratingPrBodyKey, setHydratingPrBodyKey] = React.useState(null); + const [editTitle, setEditTitle] = React.useState(''); + const [editBody, setEditBody] = React.useState(''); const [isContextOpen, setIsContextOpen] = React.useState(false); const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false); @@ -170,10 +221,101 @@ export const PullRequestSection: React.FC<{ const [checksDialogOpen, setChecksDialogOpen] = React.useState(false); const [checkDetails, setCheckDetails] = React.useState(null); const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false); + 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 isRefreshInFlightRef = React.useRef(false); + const lastRefreshAtRef = React.useRef(0); + const lastDiscoveryPollAtRef = React.useRef(0); + const statusRef = React.useRef(null); + const attemptedBodyHydrationRef = React.useRef>(new Set()); + const lastSyncedPrNumberRef = React.useRef(null); const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch); const pr = status?.pr ?? null; + const currentPrBodyHydrationKey = pr ? `${directory}#${pr.number}` : null; + const isHydratingCurrentPrBody = Boolean( + currentPrBodyHydrationKey && hydratingPrBodyKey === currentPrBodyHydrationKey, + ); + + React.useEffect(() => { + if (!github?.prContext || !pr) { + return; + } + + if (typeof pr.body === 'string' && pr.body.length > 0) { + return; + } + + const hydrationKey = `${directory}#${pr.number}`; + if (attemptedBodyHydrationRef.current.has(hydrationKey)) { + return; + } + attemptedBodyHydrationRef.current.add(hydrationKey); + setHydratingPrBodyKey(hydrationKey); + + let cancelled = false; + void github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false }) + .then((ctx) => { + if (cancelled) { + return; + } + const ctxPr = ctx?.pr; + if (!ctxPr) { + return; + } + setStatus((prev) => { + if (!prev?.pr || prev.pr.number !== pr.number) { + return prev; + } + return { + ...prev, + pr: { + ...prev.pr, + body: ctxPr.body || '', + }, + }; + }); + }) + .catch(() => {}) + .finally(() => { + if (cancelled) { + return; + } + setHydratingPrBodyKey((prev) => (prev === hydrationKey ? null : prev)); + }); + + return () => { + cancelled = true; + }; + }, [directory, github, pr]); + + React.useEffect(() => { + if (!pr) { + setIsEditingPr(false); + setEditTitle(''); + setEditBody(''); + lastSyncedPrNumberRef.current = null; + return; + } + + const numberChanged = + lastSyncedPrNumberRef.current !== null && lastSyncedPrNumberRef.current !== pr.number; + + if (numberChanged) { + setIsEditingPr(false); + } + + if (!isEditingPr || numberChanged) { + setEditTitle(pr.title || ''); + setEditBody(pr.body || ''); + } + + lastSyncedPrNumberRef.current = pr.number; + }, [isEditingPr, pr]); const openChecksDialog = React.useCallback(async () => { if (!github?.prContext) { @@ -183,6 +325,7 @@ export const PullRequestSection: React.FC<{ if (!pr) return; setChecksDialogOpen(true); + setExpandedCheckStepKeys(new Set()); setIsLoadingCheckDetails(true); try { const ctx = await github.prContext(directory, pr.number, { @@ -198,59 +341,272 @@ export const PullRequestSection: React.FC<{ } }, [directory, github, pr]); + const openCommentsDialog = React.useCallback(async () => { + if (!github?.prContext) { + toast.error('GitHub runtime API unavailable'); + return; + } + if (!pr) return; + + setCommentsDialogOpen(true); + setIsLoadingCommentsDetails(true); + try { + const ctx = await github.prContext(directory, pr.number, { + includeDiff: false, + includeCheckDetails: false, + }); + setCommentsDetails(ctx); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to load comments', { description: message }); + } finally { + setIsLoadingCommentsDetails(false); + } + }, [directory, github, pr]); + + const formatTimestamp = React.useCallback((value?: string) => { + if (!value) return ''; + const ts = Date.parse(value); + if (!Number.isFinite(ts)) { + return value; + } + return new Date(ts).toLocaleString(); + }, []); + + const connectedGitHubLogin = React.useMemo(() => { + const login = githubAuthStatus?.user?.login; + return typeof login === 'string' ? login.trim() : ''; + }, [githubAuthStatus]); + + const selfMentionHighlightClass = React.useMemo(() => { + return "[&_a[href*='oc-self-mention=1']]:!text-[var(--primary-base)] [&_a[href*='oc-self-mention=1']]:font-semibold [&_a[href*='oc-self-mention=1']]:!no-underline [&_a[href*='oc-self-mention=1']:hover]:!text-[var(--primary-hover)]"; + }, []); + + const linkifyMentionsMarkdown = React.useCallback((content: string) => { + const selfLoginLower = connectedGitHubLogin.toLowerCase(); + const mentionRegex = /(^|[^\w`])@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g; + return content.replace(mentionRegex, (_match, prefix: string, username: string) => { + const mention = `@${username}`; + const usernameLower = username.toLowerCase(); + const selfTag = selfLoginLower && usernameLower === selfLoginLower ? '?oc-self-mention=1' : ''; + return `${prefix}[${mention}](https://github.com/${usernameLower}${selfTag})`; + }); + }, [connectedGitHubLogin]); + + const timelineComments = React.useMemo(() => { + const issue = (commentsDetails?.issueComments ?? []).map((comment) => ({ + id: `issue-${comment.id}`, + body: comment.body || '', + authorName: comment.author?.name || comment.author?.login || 'Unknown author', + authorLogin: comment.author?.login || null, + avatarUrl: comment.author?.avatarUrl || null, + createdAt: comment.createdAt, + context: 'General comment', + path: null as string | null, + line: null as number | null, + })); + + const review = (commentsDetails?.reviewComments ?? []).map((comment) => ({ + id: `review-${comment.id}`, + body: comment.body || '', + authorName: comment.author?.name || comment.author?.login || 'Unknown author', + authorLogin: comment.author?.login || null, + avatarUrl: comment.author?.avatarUrl || null, + createdAt: comment.createdAt, + context: 'Code review comment', + path: comment.path || null, + line: comment.line ?? null, + })); + + const all = [...issue, ...review]; + all.sort((a, b) => { + const aTs = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTs = b.createdAt ? Date.parse(b.createdAt) : 0; + const aVal = Number.isFinite(aTs) ? aTs : 0; + const bVal = Number.isFinite(bTs) ? bTs : 0; + return aVal - bVal; + }); + return all; + }, [commentsDetails]); + + const resolveChatDispatchTarget = React.useCallback((): ChatDispatchTarget | null => { + if (!currentSessionId) { + toast.error('No active session', { description: 'Open a chat session first.' }); + return null; + } + + const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); + const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const providerID = currentProviderId || lastUsedProvider?.providerID; + const modelID = currentModelId || lastUsedProvider?.modelID; + if (!providerID || !modelID) { + toast.error('No model selected'); + return null; + } + + return { + sessionId: currentSessionId, + providerID, + modelID, + currentAgentName: currentAgentName ?? null, + currentVariant: currentVariant ?? null, + }; + }, [currentSessionId]); + + const dispatchSyntheticPrompt = React.useCallback(( + target: ChatDispatchTarget, + visibleText: string, + instructionsText: string, + payloadText: string, + ) => { + void useMessageStore.getState().sendMessage( + visibleText, + target.providerID, + target.modelID, + target.currentAgentName ?? undefined, + target.sessionId, + undefined, + null, + [ + { text: instructionsText, synthetic: true }, + { text: payloadText, synthetic: true }, + ], + target.currentVariant ?? undefined, + ).catch((e) => { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to send message', { description: message }); + }); + }, []); + const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun) => { 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} -
- {run.output?.summary ? ( -
- {run.output.summary} +
+
+
+
{run.name}
+
+ {appName ? `${appName} · ${statusText}` : statusText}
+
+ + {run.detailsUrl ? ( + ) : null} - {run.job?.steps && run.job.steps.length > 0 ? ( -
-
Steps
-
- {run.job.steps.map((step, idx) => { - const c = (step.conclusion || '').toLowerCase(); - const isFail = c && !['success', 'neutral', 'skipped'].includes(c); +
+ + {run.output?.title ? ( +
{run.output.title}
+ ) : null} + {run.output?.summary ? ( +
+ {run.output.summary} +
+ ) : null} + {run.output?.text ? ( +
+ {run.output.text} +
+ ) : null} + + {Array.isArray(run.annotations) && run.annotations.length > 0 ? ( +
+
+ Failed annotations{run.annotations.length > 20 ? ` (showing 20/${run.annotations.length})` : ''} +
+
+ {run.annotations.slice(0, 20).map((annotation, idx) => ( +
+
+ {annotation.title || annotation.level || 'Issue'} + {annotation.path ? ` · ${annotation.path}` : ''} + {typeof annotation.startLine === 'number' ? `:${annotation.startLine}` : ''} + {typeof annotation.endLine === 'number' && annotation.endLine !== annotation.startLine ? `-${annotation.endLine}` : ''} +
+
+ {annotation.message} +
+ {annotation.rawDetails ? ( +
+ {annotation.rawDetails} +
+ ) : null} +
+ ))} +
+
+ ) : null} + + {run.job?.steps && run.job.steps.length > 0 ? ( +
+
Steps
+
+ {run.job.steps.map((step, idx) => { + const c = (step.conclusion || '').toLowerCase(); + const isFail = c && !['success', 'neutral', 'skipped'].includes(c); + const stepKey = `${run.id ?? 'run'}:${run.job?.jobId ?? 'job'}:${step.number ?? idx}:${step.name}`; + const stepExpanded = expandedCheckStepKeys.has(stepKey); + if (!isFail) { return (
{step.name} {step.conclusion ? {step.conclusion} : null}
); - })} -
+ } + return ( + + + +
+ {typeof step.number === 'number' ?
Step: {step.number}
: null} + {step.status ?
Status: {step.status}
: null} + {step.conclusion ?
Conclusion: {step.conclusion}
: null} + {step.startedAt ?
Started: {formatTimestamp(step.startedAt)}
: null} + {step.completedAt ?
Completed: {formatTimestamp(step.completedAt)}
: null} +
+
+
+ ); + })}
- ) : null} -
- - {run.detailsUrl ? ( - +
) : null}
); - }, []); + }, [expandedCheckStepKeys, formatTimestamp]); const sendFailedChecksToChat = React.useCallback(async () => { setActiveMainTab('chat'); @@ -260,17 +616,8 @@ export const PullRequestSection: React.FC<{ return; } if (!directory || !pr) return; - if (!currentSessionId) { - toast.error('No active session', { description: 'Open a chat session first.' }); - return; - } - - const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; - const providerID = currentProviderId || lastUsedProvider?.providerID; - const modelID = currentModelId || lastUsedProvider?.modelID; - if (!providerID || !modelID) { - toast.error('No model selected'); + const target = resolveChatDispatchTarget(); + if (!target) { return; } @@ -291,37 +638,36 @@ export const PullRequestSection: React.FC<{ const visibleText = 'Review these PR failed checks and propose likely fixes. Do not implement until I confirm.'; const instructionsText = `Use the attached checks payload. - Summarize what is failing. +- Prioritize check annotations/errors over generic status text. - Identify likely root cause(s). - Propose a minimal fix plan and verification steps. - No speculation: ask for missing info if needed.`; + const failedAnnotations = failed.flatMap((run) => { + const annotations = Array.isArray(run.annotations) ? run.annotations : []; + return annotations.map((annotation) => ({ + run: run.name, + level: annotation.level, + title: annotation.title, + path: annotation.path, + startLine: annotation.startLine, + endLine: annotation.endLine, + message: annotation.message, + rawDetails: annotation.rawDetails, + })); + }); const payloadText = `GitHub PR failed checks (JSON)\n${JSON.stringify({ repo: context.repo ?? null, pr: context.pr ?? null, failedChecks: failed, + failedAnnotations, }, null, 2)}`; - void useMessageStore.getState().sendMessage( - visibleText, - providerID, - modelID, - currentAgentName ?? undefined, - currentSessionId, - undefined, - null, - [ - { text: instructionsText, synthetic: true }, - { text: payloadText, synthetic: true }, - ], - currentVariant - ).catch((e) => { - const message = e instanceof Error ? e.message : String(e); - toast.error('Failed to send message', { description: message }); - }); + dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load checks', { description: message }); } - }, [currentSessionId, directory, github, pr, setActiveMainTab]); + }, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]); const sendCommentsToChat = React.useCallback(async () => { setActiveMainTab('chat'); @@ -331,17 +677,8 @@ export const PullRequestSection: React.FC<{ return; } if (!directory || !pr) return; - if (!currentSessionId) { - toast.error('No active session', { description: 'Open a chat session first.' }); - return; - } - - const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; - const providerID = currentProviderId || lastUsedProvider?.providerID; - const modelID = currentModelId || lastUsedProvider?.modelID; - if (!providerID || !modelID) { - toast.error('No model selected'); + const target = resolveChatDispatchTarget(); + if (!target) { return; } @@ -368,47 +705,114 @@ export const PullRequestSection: React.FC<{ reviewComments, }, null, 2)}`; - void useMessageStore.getState().sendMessage( - visibleText, - providerID, - modelID, - currentAgentName ?? undefined, - currentSessionId, - undefined, - null, - [ - { text: instructionsText, synthetic: true }, - { text: payloadText, synthetic: true }, - ], - currentVariant - ).catch((e) => { - const message = e instanceof Error ? e.message : String(e); - toast.error('Failed to send message', { description: message }); - }); + dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load PR comments', { description: message }); } - }, [currentSessionId, directory, github, pr, setActiveMainTab]); + }, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]); - const refresh = React.useCallback(async () => { + const sendSingleCommentToChat = React.useCallback((comment: TimelineCommentItem) => { + setCommentsDialogOpen(false); + setActiveMainTab('chat'); + + const target = resolveChatDispatchTarget(); + if (!target) { + return; + } + + const visibleText = 'Address this comment from PR and propose required changes. Do not implement until I confirm.'; + const instructionsText = `Use the attached single-comment payload. +- Explain what the reviewer is asking for. +- Identify exact code areas likely impacted. +- Propose a minimal implementation plan and verification steps. +- Call out ambiguity and ask focused follow-up questions if needed.`; + const payloadText = `GitHub PR comment (JSON)\n${JSON.stringify({ + repo: commentsDetails?.repo ?? null, + pr: commentsDetails?.pr ?? pr ?? null, + comment, + }, null, 2)}`; + + dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText); + }, [commentsDetails, dispatchSyntheticPrompt, pr, resolveChatDispatchTarget, setActiveMainTab]); + + React.useEffect(() => { + statusRef.current = status; + }, [status]); + + const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => { if (!canShow) return; + if (options?.onlyExistingPr && !statusRef.current?.pr) { + return; + } + if (!options?.force && Date.now() - lastRefreshAtRef.current < PR_REVALIDATE_TTL_MS) { + return; + } + if (isRefreshInFlightRef.current) { + return; + } + + isRefreshInFlightRef.current = true; + lastRefreshAtRef.current = Date.now(); + if (githubAuthChecked && githubAuthStatus?.connected === false) { setStatus({ connected: false }); setError(null); - setIsLoading(false); + if (!options?.silent) { + setIsLoading(false); + } + if (options?.markInitialResolved !== false) { + setIsInitialStatusResolved(true); + } + isRefreshInFlightRef.current = false; return; } if (!github?.prStatus) { setStatus(null); setError('GitHub runtime API unavailable'); + if (options?.markInitialResolved !== false) { + setIsInitialStatusResolved(true); + } + isRefreshInFlightRef.current = false; return; } - setIsLoading(true); + if (!options?.silent) { + setIsLoading(true); + } setError(null); try { const next = await github.prStatus(directory, branch); - setStatus(next); + setStatus((prev) => { + const nextPr = next.pr; + const prevPr = prev?.pr; + // Some runtimes occasionally return PR status without body. + // Keep already hydrated description for the same PR number. + const shouldCarryBody = Boolean( + nextPr + && prevPr + && nextPr.number === prevPr.number + && (!nextPr.body || !nextPr.body.trim()) + && typeof prevPr.body === 'string' + && prevPr.body.trim().length > 0, + ); + + if (!shouldCarryBody || !nextPr) { + return next; + } + + const carriedBody = prevPr?.body; + if (!carriedBody) { + return next; + } + + return { + ...next, + pr: { + ...nextPr, + body: carriedBody, + }, + }; + }); if (next.connected === false) { setError(null); } @@ -416,18 +820,72 @@ export const PullRequestSection: React.FC<{ const message = e instanceof Error ? e.message : String(e); setError(message || 'Failed to load PR status'); } finally { - setIsLoading(false); + if (!options?.silent) { + setIsLoading(false); + } + if (options?.markInitialResolved !== false) { + setIsInitialStatusResolved(true); + } + isRefreshInFlightRef.current = false; } }, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus]); React.useEffect(() => { const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null; + const statusSnapshot = pullRequestStatusSnapshots.get(snapshotKey) ?? null; setTitle(snapshot?.title ?? branchToTitle(branch)); setBody(snapshot?.body ?? ''); setDraft(snapshot?.draft ?? false); - void refresh(); + setStatus(statusSnapshot); + setError(null); + setIsInitialStatusResolved(Boolean(statusSnapshot)); + void refresh({ force: true, markInitialResolved: true }); }, [branch, refresh, snapshotKey]); + React.useEffect(() => { + const onFocus = () => { + void refresh({ force: true, silent: true }); + }; + const onVisibility = () => { + if (document.visibilityState === 'visible') { + void refresh({ force: true, silent: true }); + } + }; + + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', onVisibility); + return () => { + window.removeEventListener('focus', onFocus); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [refresh]); + + React.useEffect(() => { + const interval = window.setInterval(() => { + if (document.visibilityState !== 'visible') { + return; + } + + const hasPr = Boolean(statusRef.current?.pr); + if (!hasPr) { + const now = Date.now(); + const shouldRunDiscovery = now - lastDiscoveryPollAtRef.current >= PR_DISCOVERY_INTERVAL_MS; + if (!shouldRunDiscovery) { + return; + } + lastDiscoveryPollAtRef.current = now; + void refresh({ force: true, silent: true }); + return; + } + + void refresh({ onlyExistingPr: true, force: true, silent: true }); + }, PR_REVALIDATE_INTERVAL_MS); + + return () => { + window.clearInterval(interval); + }; + }, [refresh]); + React.useEffect(() => { if (githubAuthChecked && githubAuthStatus?.connected === false) { setStatus({ connected: false }); @@ -447,6 +905,13 @@ export const PullRequestSection: React.FC<{ }); }, [snapshotKey, title, body, draft, additionalContext, directory, branch]); + React.useEffect(() => { + if (!status) { + return; + } + pullRequestStatusSnapshots.set(snapshotKey, status); + }, [snapshotKey, status]); + const generateDescription = React.useCallback(async () => { if (isGenerating) return; if (!directory) return; @@ -464,14 +929,14 @@ export const PullRequestSection: React.FC<{ if (generated.body?.trim()) { setBody(generated.body.trim()); } - toast.success('PR description generated'); + onGeneratedDescription?.(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to generate description', { description: message }); } finally { setIsGenerating(false); } - }, [baseBranch, branch, directory, isGenerating, additionalContext]); + }, [baseBranch, branch, directory, isGenerating, additionalContext, onGeneratedDescription]); const createPr = React.useCallback(async () => { if (!github?.prCreate) { @@ -496,7 +961,7 @@ export const PullRequestSection: React.FC<{ }); toast.success('PR created'); setStatus((prev) => (prev ? { ...prev, pr } : prev)); - await refresh(); + await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to create PR', { description: message }); @@ -518,7 +983,7 @@ export const PullRequestSection: React.FC<{ } else { toast.message('PR not merged', { description: result.message || 'Not mergeable' }); } - await refresh(); + await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Merge failed', { description: message }); @@ -539,7 +1004,7 @@ export const PullRequestSection: React.FC<{ try { await github.prReady({ directory, number: pr.number }); toast.success('Marked ready for review'); - await refresh(); + await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to mark ready', { description: message }); @@ -551,6 +1016,46 @@ export const PullRequestSection: React.FC<{ } }, [directory, github, refresh]); + const updatePr = React.useCallback(async (pr: GitHubPullRequest) => { + if (!github?.prUpdate) { + toast.error('GitHub runtime API unavailable'); + return; + } + + const trimmedTitle = editTitle.trim(); + if (!trimmedTitle) { + toast.error('Title is required'); + return; + } + + setIsUpdating(true); + try { + const updated = await github.prUpdate({ + directory, + number: pr.number, + title: trimmedTitle, + body: editBody, + }); + setStatus((prev) => (prev + ? { + ...prev, + pr: { + ...(prev.pr ?? pr), + ...updated, + }, + } + : prev)); + setIsEditingPr(false); + toast.success('PR updated'); + await refresh({ force: true }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to update PR', { description: message }); + } finally { + setIsUpdating(false); + } + }, [directory, editBody, editTitle, github, refresh]); + if (!canShow) { return null; } @@ -576,29 +1081,58 @@ export const PullRequestSection: React.FC<{ : 'border-0 bg-transparent rounded-none'; const headerClassName = variant === 'framed' - ? 'px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2' - : 'px-0 py-3 border-b border-border/40 flex items-center justify-between gap-2'; + ? 'px-3 py-2 border-b border-border/40 flex flex-col gap-1' + : 'px-0 py-3 border-b border-border/40 flex flex-col gap-1'; const bodyClassName = variant === 'framed' ? 'flex flex-col gap-3 p-3' : 'flex flex-col gap-3 py-3'; return (
-
- -

Pull Request

- {pr ? ( - #{pr.number} - ) : null} +
+
+ {pr ? ( + + + + +

Open PR on GitHub

+
+ ) : ( + + )} +

Pull Request

+ {pr ? ( + #{pr.number} + ) : null} +
+
+ {isLoading ? : null} + {checks ? ( + + + {checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`} + + ) : null} +
-
- {isLoading ? : null} - {checks ? ( - - - {checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`} + {pr ? ( +
+ + {pr.state}{pr.draft ? ' (draft)' : ''} - ) : null} -
+ {pr.mergeable === false ? ' · not mergeable' : ''} + {pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown' + ? ` · ${pr.mergeableState}` + : ''} +
+ ) : null}
@@ -628,50 +1162,44 @@ export const PullRequestSection: React.FC<{
) : null} - {pr ? ( + {!pr && !isInitialStatusResolved && !error && !shouldShowConnectionNotice ? ( +
+ + Checking PR status... +
+ ) : pr ? (
-
+
-
{pr.title}
-
- - {pr.state}{pr.draft ? ' (draft)' : ''} - - {pr.mergeable === false ? ' · not mergeable' : ''} - {pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown' - ? ` · ${pr.mergeableState}` - : ''} -
-
-
- {checks ? ( - - ) : null} - + {isEditingPr ? ( +
+ setEditTitle(e.target.value)} + placeholder="PR title" + /> +