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
This commit is contained in:
Bohdan Triapitsyn
2026-02-09 03:13:34 +02:00
committed by GitHub
parent 3f29b2c6a2
commit 5b0a97d170
30 changed files with 3216 additions and 1443 deletions
@@ -1765,7 +1765,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
)}
<div
className={cn(
"flex flex-col relative overflow-visible overflow-hidden",
"flex flex-col relative overflow-visible",
"border border-border/80",
"focus-within:ring-1 focus-within:ring-primary/50"
)}
@@ -15,14 +15,18 @@ interface MenuPosition {
show: boolean;
}
const MENU_TRANSITION_MS = 200;
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const [position, setPosition] = React.useState<MenuPosition>({ 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<HTMLDivElement>(null);
const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
const hideTimeoutRef = React.useRef<number | null>(null);
const openRafRef = React.useRef<number | null>(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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ containerR
return createPortal(
<div
ref={menuRef}
className={cn(
'fixed z-50 flex items-center gap-1',
'rounded-lg border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-lg',
'px-1.5 py-1',
isClosing ? 'animate-out fade-out-0 duration-150 pointer-events-none' : 'animate-in fade-in-0 duration-150'
)}
className="fixed z-50"
style={{
left: position.x,
top: position.y,
transform: 'translate(-50%, -100%)',
}}
>
<button
onClick={handleAddToChat}
<div
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
'flex items-center gap-1',
'rounded-lg border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-lg',
'px-1.5 py-1',
'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'
)}
title="Add to current chat"
type="button"
>
<RiAddLine className="h-4 w-4" />
<span>Add to chat</span>
</button>
<button
onClick={handleAddToChat}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Add to current chat"
type="button"
>
<RiAddLine className="h-4 w-4" />
<span>Add to chat</span>
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<button
onClick={handleCreateNewSession}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Create new session with selection"
type="button"
>
<RiChatNewLine className="h-4 w-4" />
<span>New session</span>
</button>
<button
onClick={handleCreateNewSession}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Create new session with selection"
type="button"
>
<RiChatNewLine className="h-4 w-4" />
<span>New session</span>
</button>
</div>
</div>,
document.body
);
@@ -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<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
const content = (
<>
{embedded ? (
<div className="flex-shrink-0 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<span className="typography-ui-header font-semibold text-foreground">Current</span>
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{current.label}</span>
<span className="text-muted-foreground"></span>
<span className="typography-ui-header font-semibold text-foreground">Default</span>
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{currentDefaultLabel}</span>
</div>
<button
type="button"
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
'hover:text-foreground hover:bg-interactive-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
onClick={() => void probeAll(allHosts)}
disabled={!tauriAvailable || isLoading || isProbing}
aria-label="Refresh instances"
>
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
</button>
</div>
</div>
) : (
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiServerLine className="h-5 w-5" />
@@ -341,7 +380,9 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
Switch between Local and remote OpenChamber servers
</DialogDescription>
</DialogHeader>
)}
{!embedded && (
<div className="flex items-center justify-between gap-2 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-meta text-muted-foreground">Current:</span>
@@ -362,6 +403,7 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
</Button>
</div>
</div>
)}
{!tauriAvailable && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
@@ -531,38 +573,85 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
</div>
)}
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
<Button
{embedded && !isAddFormOpen ? (
<div className="flex-shrink-0 border-t border-[var(--interactive-border)]">
<button
type="button"
size="sm"
onClick={() => void addHost()}
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
className="w-full flex items-center gap-2 px-2 py-2 text-left text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
onClick={() => setIsAddFormOpen(true)}
disabled={!tauriAvailable || isSaving}
>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Add
</Button>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label">Add instance</span>
</button>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Label (optional)"
disabled={!tauriAvailable || isSaving}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://host:port"
disabled={!tauriAvailable || isSaving}
/>
) : (
<div className={cn(
'flex-shrink-0',
embedded
? 'border-t border-[var(--interactive-border)] px-2 py-2'
: 'rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2.5'
)}>
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
<div className="flex items-center gap-2">
{embedded && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsAddFormOpen(false)}
disabled={isSaving}
>
Cancel
</Button>
)}
<Button
type="button"
size="sm"
onClick={() => void addHost()}
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Add
</Button>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Label (optional)"
disabled={!tauriAvailable || isSaving}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://host:port"
disabled={!tauriAvailable || isSaving}
/>
</div>
</div>
</div>
)}
{error && (
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
)}
</>
);
if (embedded) {
return (
<div className="w-full max-h-[70vh] flex flex-col overflow-hidden gap-2">
{content}
</div>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
{content}
</DialogContent>
</Dialog>
);
@@ -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<BottomTerminalDockProps> = ({ 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 (
<section
className={cn(
'relative flex overflow-hidden border-t border-border bg-sidebar',
isResizing ? 'transition-none' : 'transition-[height] duration-300 ease-in-out',
!isOpen && 'border-t-0'
)}
style={{
height: `${appliedHeight}px`,
minHeight: `${appliedHeight}px`,
maxHeight: `${appliedHeight}px`,
}}
aria-hidden={!isOpen || appliedHeight === 0}
>
{isOpen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-[4px] w-full cursor-row-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
)}
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal panel"
/>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
aria-hidden={!isOpen}
>
{children}
</div>
</section>
);
};
+300 -217
View File
@@ -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 = () => {
<div className="flex items-center gap-1 pr-3">
<OpenInAppButton directory={openDirectory} className="mr-1" />
{isDesktopApp && (
<DesktopHostSwitcherButton headerIconButtonClass={headerIconButtonClass} />
)}
<DropdownMenu
open={isDesktopServicesOpen}
onOpenChange={(open) => {
setIsDesktopServicesOpen(open);
if (open && desktopServicesTab === 'usage' && quotaResults.length === 0) {
fetchAllQuotas();
}
}}
>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Open instance, usage and MCP"
className={headerIconButtonClass}
>
<RiStackLine className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Instance / Usage / MCP</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-[min(30rem,calc(100vw-2rem))] max-h-[75vh] overflow-hidden p-0">
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2">
<AnimatedTabs<'instance' | 'usage' | 'mcp'>
value={desktopServicesTab}
onValueChange={(value) => {
setDesktopServicesTab(value);
if (value === 'usage' && quotaResults.length === 0) {
fetchAllQuotas();
}
}}
tabs={servicesTabs}
className="rounded-md"
/>
</div>
{isDesktopApp && desktopServicesTab === 'instance' && (
<DesktopHostSwitcherDialog
embedded
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
onOpenChange={() => {}}
onHostSwitched={() => setIsDesktopServicesOpen(false)}
/>
)}
{desktopServicesTab === 'mcp' && (
<McpDropdownContent active={isDesktopServicesOpen && desktopServicesTab === 'mcp'} />
)}
{desktopServicesTab === 'usage' && (
<div className="max-h-[calc(75vh-3.25rem)] overflow-y-auto overflow-x-hidden">
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
<DropdownMenuLabel className="flex items-center justify-between gap-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
<span className="truncate typography-ui-label text-muted-foreground">
Last updated {formatTime(quotaLastUpdated)}
</span>
</div>
<div className="flex items-center gap-1.5">
<AnimatedTabs<'usage' | 'remaining'>
value={quotaDisplayMode}
onValueChange={handleDisplayModeChange}
tabs={quotaDisplayTabs}
size="sm"
className="w-[8.25rem]"
/>
<button
type="button"
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
'hover:text-foreground hover:bg-interactive-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
aria-label="Refresh rate limits"
>
<RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</button>
</div>
</DropdownMenuLabel>
</div>
{!hasRateLimits && (
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
</DropdownMenuItem>
)}
{rateLimitGroups.map((group, index) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
return (
<React.Fragment key={group.providerId}>
<DropdownMenuLabel className="sticky top-[44px] z-10 flex items-center gap-2 bg-[var(--surface-elevated)] typography-ui-label text-foreground">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
{group.providerName}
</DropdownMenuLabel>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<DropdownMenuItem
key={`${group.providerId}-empty`}
className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<span className="typography-ui-label text-muted-foreground">No rate limits reported.</span>
</DropdownMenuItem>
) : (
<>
{group.entries.map(([label, window]) => (
<DropdownMenuItem
key={`${group.providerId}-${label}`}
className="cursor-default items-start hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<span className="flex min-w-0 flex-1 flex-col gap-2">
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="min-w-0 flex items-center gap-2">
<span className="truncate typography-ui-label text-foreground">{formatWindowLabel(label)}</span>
{(window.resetAfterFormatted ?? window.resetAtFormatted) ? (
<span className="truncate typography-ui-label text-muted-foreground">
{window.resetAfterFormatted ?? window.resetAtFormatted}
</span>
) : null}
</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</span>
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1.5 mb-1.5" />
</>
);
})()}
</span>
</DropdownMenuItem>
))}
{group.modelFamilies && group.modelFamilies.length > 0 && (
<div className="px-2 py-1">
{group.modelFamilies.map((family) => {
const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other');
return (
<Collapsible
key={family.familyId ?? 'other'}
open={isExpanded}
onOpenChange={() => toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-1.5 text-left">
<span className="typography-ui-label font-medium text-foreground">
{family.familyLabel}
</span>
{isExpanded ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-1 pl-2">
{family.models.map(([modelName, window]) => (
<div
key={`${group.providerId}-${modelName}`}
className="py-1.5"
>
<div className="flex min-w-0 flex-col gap-1.5">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{modelName}</span>
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
);
})()}
</span>
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1.5 mb-1.5" />
);
})()}
</div>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</>
)}
{index < rateLimitGroups.length - 1 && <DropdownMenuSeparator />}
</React.Fragment>
);
})}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
@@ -768,223 +1036,38 @@ export const Header: React.FC = () => {
</TooltipContent>
</Tooltip>
<DropdownMenu onOpenChange={(open) => {
if (open && quotaResults.length === 0) {
fetchAllQuotas();
}
}}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="View rate limits"
className={headerIconButtonClass}
disabled={isQuotaLoading}
>
<RiTimerLine className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Rate limits</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-80 max-h-[70vh] overflow-y-auto overflow-x-hidden p-0">
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
<DropdownMenuLabel className="flex items-center justify-between gap-3 typography-ui-header font-semibold text-foreground">
<span>Rate limits</span>
<div className="flex items-center gap-1">
<div className="flex items-center rounded-md border border-[var(--interactive-border)] p-0.5">
<button
type="button"
className={cn(
'px-2 py-0.5 rounded-sm typography-micro text-[10px] transition-colors',
quotaDisplayMode === 'usage'
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => handleDisplayModeChange('usage')}
aria-label="Show used quota"
>
Used
</button>
<button
type="button"
className={cn(
'px-2 py-0.5 rounded-sm typography-micro text-[10px] transition-colors',
quotaDisplayMode === 'remaining'
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => handleDisplayModeChange('remaining')}
aria-label="Show remaining quota"
>
Remaining
</button>
</div>
<button
type="button"
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
'hover:text-foreground hover:bg-interactive-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
onClick={() => fetchAllQuotas()}
disabled={isQuotaLoading}
aria-label="Refresh rate limits"
>
<RiRefreshLine className="h-4 w-4" />
</button>
</div>
</DropdownMenuLabel>
<div className="px-2 pb-2 typography-micro text-muted-foreground text-[10px]">
Last updated {formatTime(quotaLastUpdated)}
</div>
</div>
{!hasRateLimits && (
<DropdownMenuItem className="cursor-default" onSelect={(event) => event.preventDefault()}>
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
</DropdownMenuItem>
)}
{rateLimitGroups.map((group, index) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
return (
<React.Fragment key={group.providerId}>
<DropdownMenuLabel className="sticky top-[60px] z-10 flex items-center gap-2 bg-[var(--surface-elevated)] typography-ui-label text-foreground">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
{group.providerName}
</DropdownMenuLabel>
{/* Provider-level entries */}
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<DropdownMenuItem
key={`${group.providerId}-empty`}
className="cursor-default"
onSelect={(event) => event.preventDefault()}
>
<span className="typography-ui-label text-muted-foreground">No rate limits reported.</span>
</DropdownMenuItem>
) : (
<>
{/* Provider-level windows */}
{group.entries.map(([label, window]) => (
<DropdownMenuItem
key={`${group.providerId}-${label}`}
className="cursor-default items-start"
onSelect={(event) => event.preventDefault()}
>
<span className="flex min-w-0 flex-1 flex-col gap-2">
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{formatWindowLabel(label)}</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</span>
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1" />
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
<span>{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}</span>
</span>
</>
);
})()}
</span>
</DropdownMenuItem>
))}
{/* Model families with collapsible sections - default COLLAPSED */}
{group.modelFamilies && group.modelFamilies.length > 0 && (
<div className="px-2 py-1">
{group.modelFamilies.map((family) => {
const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other');
return (
<Collapsible
key={family.familyId ?? 'other'}
open={isExpanded}
onOpenChange={() => toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-1.5 text-left">
<span className="typography-ui-label font-medium text-foreground">
{family.familyLabel}
</span>
{isExpanded ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-1 pl-2">
{family.models.map(([modelName, window]) => (
<div
key={`${group.providerId}-${modelName}`}
className="py-1.5"
>
<div className="flex min-w-0 flex-col gap-1.5">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{modelName}</span>
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
);
})()}
</span>
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1" />
);
})()}
</div>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</>
)}
{index < rateLimitGroups.length - 1 && <DropdownMenuSeparator />}
</React.Fragment>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<McpDropdown headerIconButtonClass={headerIconButtonClass} />
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleBottomTerminal}
aria-label="Toggle terminal panel"
className={headerIconButtonClass}
>
<RiTerminalBoxLine className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Terminal panel</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleHelpDialog}
aria-label="Keyboard shortcuts"
onClick={toggleRightSidebar}
aria-label="Toggle git sidebar"
className={headerIconButtonClass}
>
<RiQuestionLine className="h-5 w-5" />
<RiLayoutRightLine className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Keyboard Shortcuts ({getModifierLabel()}+.)</p>
<p>Git sidebar</p>
</TooltipContent>
</Tooltip>
{githubAuthStatus?.connected && !isMobile ? (
githubAccounts.length > 1 ? (
<DropdownMenu>
@@ -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 = () => {
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
<SessionSidebar />
</Sidebar>
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
<div className="flex flex-1 min-w-0 flex-col overflow-hidden">
<div className="flex flex-1 min-h-0 overflow-hidden">
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
<RightSidebar isOpen={isRightSidebarOpen} isMobile={isMobile}>
<ErrorBoundary><GitView mode="sidebar" /></ErrorBoundary>
</RightSidebar>
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
<ErrorBoundary><TerminalView /></ErrorBoundary>
</BottomTerminalDock>
</div>
</div>
</div>
@@ -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<RightSidebarProps> = ({ 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 (
<aside
className={cn(
'relative flex h-full overflow-hidden border-l border-border bg-sidebar',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-l-0'
)}
style={{
width: `${appliedWidth}px`,
minWidth: `${appliedWidth}px`,
maxWidth: `${appliedWidth}px`,
overflowX: 'clip',
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
)}
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="vertical"
aria-label="Resize right panel"
/>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
aria-hidden={!isOpen}
>
{isOpen ? children : null}
</div>
</aside>
);
};
+40 -21
View File
@@ -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<SidebarProps> = ({ 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<SidebarProps> = ({ isOpen, isMobile, children })
<RiSettings3Line className="h-4 w-4" />
<span>Settings</span>
</button>
{(available || downloaded) ? (
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1',
'text-xs font-semibold',
'bg-primary/10 text-primary',
'hover:bg-primary/20',
'transition-colors'
)}
>
<RiDownloadLine className="h-3.5 w-3.5" />
<span>Update</span>
</button>
<div className="flex items-center gap-1">
{(available || downloaded) ? (
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1',
'text-xs font-semibold',
'bg-primary/10 text-primary',
'hover:bg-primary/20',
'transition-colors'
)}
>
<RiDownloadLine className="h-3.5 w-3.5" />
<span>Update</span>
</button>
) : !isDesktopApp && (
) : !isDesktopApp && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setAboutDialogOpen(true)}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md',
'text-sidebar-foreground/70',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
'transition-all duration-200'
)}
>
<RiInformationLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">About OpenChamber</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setAboutDialogOpen(true)}
onClick={toggleHelpDialog}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md',
'text-sidebar-foreground/70',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
'transition-all duration-200'
)}
aria-label="Keyboard shortcuts"
>
<RiInformationLine className="h-4 w-4" />
<RiQuestionLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">About OpenChamber</TooltipContent>
<TooltipContent side="top">Keyboard shortcuts</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
<UpdateDialog
+130 -30
View File
@@ -6,7 +6,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
@@ -55,6 +54,135 @@ interface McpDropdownProps {
headerIconButtonClass: string;
}
interface McpDropdownContentProps {
active: boolean;
className?: string;
}
export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ 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<string | null>(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 (
<div className={cn('w-full', className)}>
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
<div className="flex items-center justify-between gap-3 px-2 py-2.5">
<div className="min-w-0 flex items-center gap-2">
<div className="typography-ui-header font-semibold text-foreground">MCP Servers</div>
{directory && (
<div className="truncate typography-ui-label text-muted-foreground">
{directory.split('/').pop() || directory}
</div>
)}
</div>
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
disabled={isSpinning}
onClick={handleRefresh}
aria-label="Refresh"
>
<RiRefreshLine className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
</button>
</div>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{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 (
<div
key={serverName}
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg hover:bg-interactive-hover/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<span
className={cn(
'h-2 w-2 rounded-full flex-shrink-0',
tone === 'success' && 'bg-status-success',
tone === 'error' && 'bg-status-error',
tone === 'warning' && 'bg-status-warning',
tone === 'default' && 'bg-muted-foreground/40'
)}
aria-label={tooltip}
/>
</TooltipTrigger>
<TooltipContent side="left">
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
<span className="typography-ui-label truncate">{serverName}</span>
</div>
</div>
<Switch
checked={isConnected}
disabled={isBusy}
className="data-[state=checked]:bg-status-info"
onCheckedChange={async (checked) => {
setBusyName(serverName);
try {
if (checked) {
await connect(serverName, directory);
} else {
await disconnect(serverName, directory);
}
} finally {
setBusyName(null);
}
}}
/>
</div>
);
})}
{sortedNames.length === 0 && (
<div className="px-2 py-3 typography-ui-label text-muted-foreground text-center">
Configure MCP servers in Opencode config.
</div>
)}
</div>
</div>
);
};
export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass }) => {
const [open, setOpen] = React.useState(false);
const [tooltipOpen, setTooltipOpen] = React.useState(false);
@@ -277,35 +405,7 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
</Tooltip>
<DropdownMenuContent align="end" className="w-72">
<div className="flex items-center justify-between px-2 py-1">
<span className="typography-ui-label font-semibold">MCP Servers</span>
<button
type="button"
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
disabled={isSpinning}
onClick={handleRefresh}
aria-label="Refresh"
>
<RiRefreshLine className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
</button>
</div>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto py-1">
{renderServerList()}
</div>
{directory && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1">
<span className="typography-meta text-muted-foreground truncate block">
{directory.split('/').pop() || directory}
</span>
</div>
</>
)}
<McpDropdownContent active={open} />
</DropdownMenuContent>
</DropdownMenu>
);
@@ -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<unknown>;
};
};
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<SortableProjectItemProps> = ({
onNewSession,
onNewWorktreeSession,
onNewSessionFromGitHubIssue,
onNewSessionFromGitHubPR,
onOpenMultiRunLauncher,
onRenameStart,
onRenameSave,
@@ -448,12 +384,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
New session from GitHub issue
</DropdownMenuItem>
)}
{showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && (
<DropdownMenuItem onClick={onNewSessionFromGitHubPR}>
<RiGitPullRequestLine className="mr-1.5 h-4 w-4" />
New session from GitHub PR
</DropdownMenuItem>
)}
{showCreateButtons && isRepo && !hideDirectoryControls && (
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
<ArrowsMerge className="mr-1.5 h-4 w-4" />
@@ -604,8 +534,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
const [worktreePrByGroupKey, setWorktreePrByGroupKey] = React.useState<Map<string, GitHubPullRequestStatus>>(new Map());
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
@@ -661,7 +589,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
const worktreePrLastCheckedAtRef = React.useRef<Map<string, number>>(new Map());
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
const ignoreIntersectionUntil = React.useRef<number>(0);
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
@@ -682,8 +609,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
);
const reserveHeaderActionsSpace = activeProjectRepoState !== false;
const worktreePrInFlight = React.useRef<Set<string>>(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<string>();
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<string, Map<string, { directory: string | null }>>();
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
@@ -2273,17 +1967,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const allGroupSessions = collectGroupSessions(group.sessions);
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId));
const 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<SessionSidebarProps> = ({
<div className="oc-group">
<div
className="group/gh flex items-start justify-between gap-2 py-1 min-w-0 rounded-sm hover:bg-interactive-hover/50 cursor-pointer"
onMouseEnter={() => {
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
)}
{!group.isMain || isGitProject ? (
!group.isMain && groupPr?.url ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (groupPr?.url) {
void openExternal(groupPr.url);
}
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-interactive-hover/50 mt-1"
style={{ color: prColorVar }}
aria-label={getPrTooltipLabel(groupPrStatus)}
>
<PrStateIcon className="h-3.5 w-3.5 flex-shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{getPrTooltipLabel(groupPrStatus)}</p>
</TooltipContent>
</Tooltip>
) : (
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
)
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
) : null}
<div className="min-w-0 flex flex-col">
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
@@ -2472,9 +2120,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
hideDirectoryControls,
currentSessionDirectory,
projectRepoStatus,
worktreePrByGroupKey,
openExternal,
ensureWorktreePrLoaded,
renderSessionNode,
toggleGroupSessionLimit,
activeProjectId,
@@ -2663,19 +2308,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setPullRequestPickerOpen(true)}
className={headerActionButtonClass}
aria-label="New from PR"
>
<RiGitPullRequestLine className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New from PR</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -2787,12 +2419,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
setIssuePickerOpen(true);
}}
onNewSessionFromGitHubPR={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
}
setPullRequestPickerOpen(true);
}}
onOpenMultiRunLauncher={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
@@ -2899,17 +2525,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
}}
/>
<GitHubPullRequestPickerDialog
open={pullRequestPickerOpen}
onOpenChange={(open) => {
setPullRequestPickerOpen(open);
if (!open && mobileVariant) {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}
}}
/>
</div>
);
};
@@ -64,11 +64,12 @@ interface TerminalViewportProps {
fontSize: number;
className?: string;
enableTouchScroll?: boolean;
autoFocus?: boolean;
}
const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportProps>(
(
{ 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<HTMLDivElement>(null);
@@ -94,6 +95,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
const refocusTimeoutRef = React.useRef<number | null>(null);
const keydownProbeTimeoutRef = React.useRef<number | null>(null);
const lastObservedValueRef = React.useRef('');
const cursorBlinkStateRef = React.useRef<boolean | null>(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<TerminalController, TerminalViewportPr
});
}, [useHiddenInputOverlay]);
const setTerminalCursorBlink = React.useCallback((enabled: boolean) => {
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<TerminalController, TerminalViewportPr
}
}, [useTextInput]);
const focusTerminalInput = React.useCallback(() => {
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<TerminalController, TerminalViewportPr
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
const handleTerminalTextareaFocus = () => {
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<TerminalController, TerminalViewportPr
terminal.loadAddon(fitAddon);
terminal.open(container);
bumpTerminalReady();
cursorBlinkStateRef.current = false;
localTerminalTextarea =
(terminal as unknown as { textarea?: HTMLTextAreaElement | null }).textarea
?? container.querySelector('textarea');
if (localTerminalTextarea) {
localTerminalTextarea.addEventListener('focus', handleTerminalTextareaFocus);
localTerminalTextarea.addEventListener('blur', handleTerminalTextareaBlur);
}
disableTerminalTextareas();
@@ -880,10 +955,6 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal();
setupTouchScroll();
if (!useHiddenInputOverlay) {
terminal.focus();
}
localDisposables = [
terminal.onData((data: string) => {
inputHandlerRef.current(data);
@@ -907,12 +978,22 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
void initialize();
document.addEventListener('focusin', handleDocumentFocusIn, true);
window.addEventListener('blur', handleWindowBlur);
return () => {
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<TerminalController, TerminalViewportPr
fitAddonRef.current = null;
viewportRef.current = null;
lastReportedSizeRef.current = null;
cursorBlinkStateRef.current = null;
resetWriteState();
};
}, [disableTerminalTextareas, useHiddenInputOverlay, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
}, [disableTerminalTextareas, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState, setTerminalCursorBlink, useHiddenInputOverlay]);
React.useEffect(() => {
@@ -935,10 +1017,20 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
resetWriteState();
lastReportedSizeRef.current = null;
fitTerminal();
if (!useHiddenInputOverlay) {
terminal.focus();
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
React.useEffect(() => {
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<TerminalController, TerminalViewportPr
ref,
(): TerminalController => ({
focus: () => {
if (useHiddenInputOverlay) {
focusHiddenInput();
return;
}
terminalRef.current?.focus();
focusTerminalInput();
},
clear: () => {
const terminal = terminalRef.current;
@@ -1003,7 +1091,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal();
},
}),
[useHiddenInputOverlay, focusHiddenInput, fitTerminal, resetWriteState]
[focusTerminalInput, fitTerminal, resetWriteState]
);
const handleHiddenInputBlur = React.useCallback(
+38 -15
View File
@@ -15,6 +15,8 @@ interface AnimatedTabsProps<T extends string> {
isInteractive?: boolean;
animate?: boolean;
collapseLabelsOnSmall?: boolean;
collapseLabelsOnNarrow?: boolean;
size?: 'default' | 'sm';
}
export function AnimatedTabs<T extends string>({
@@ -25,9 +27,12 @@ export function AnimatedTabs<T extends string>({
isInteractive = true,
animate = true,
collapseLabelsOnSmall = false,
collapseLabelsOnNarrow = false,
size = 'default',
}: AnimatedTabsProps<T>) {
const containerRef = React.useRef<HTMLDivElement>(null);
const activeTabRef = React.useRef<HTMLButtonElement>(null);
const [isReadyToAnimate, setIsReadyToAnimate] = React.useState(false);
const updateClipPath = React.useCallback(() => {
const container = containerRef.current;
@@ -47,7 +52,10 @@ export function AnimatedTabs<T extends string>({
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<T extends string>({
}, [updateClipPath]);
return (
<div className={cn('relative isolate w-full', className)}>
<div className={cn('relative isolate w-full', collapseLabelsOnNarrow && '@container/animated-tabs', className)}>
<div
ref={containerRef}
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)]',
animate ? '[transition:clip-path_200ms_ease]' : null
animate && isReadyToAnimate ? '[transition:clip-path_200ms_ease]' : null
)}
>
<div className="flex h-9 items-center gap-1 rounded-lg bg-interactive-selection px-1.5 text-interactive-selection-foreground">
>
<div
className={cn(
'flex items-center gap-1 bg-interactive-selection text-interactive-selection-foreground',
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
)}
>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<div
key={tab.value}
className={cn(
'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold',
'flex flex-1 items-center justify-center font-semibold',
size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm',
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25'
)}
>
{Icon ? <Icon className="h-4 w-4" /> : null}
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{Icon ? <Icon className={cn(size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> : null}
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</div>
@@ -91,7 +105,12 @@ export function AnimatedTabs<T extends string>({
</div>
</div>
<div className="relative z-20 flex h-9 items-center gap-1 rounded-lg bg-muted/20 px-1.5">
<div
className={cn(
'relative z-20 flex items-center gap-1 bg-muted/20',
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
)}
>
{tabs.map((tab) => {
const isActive = value === tab.value;
const Icon = tab.icon;
@@ -105,8 +124,9 @@ export function AnimatedTabs<T extends string>({
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<T extends string>({
>
{Icon ? (
<Icon
className={cn('h-4 w-4', isActive ? 'text-accent-foreground' : 'text-muted-foreground')}
className={cn(
size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4',
isActive ? 'text-accent-foreground' : 'text-muted-foreground'
)}
/>
) : null}
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</button>
);
+99 -58
View File
@@ -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<string, GitViewSnapshot>();
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<GitViewProps> = ({ 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<HTMLElement | null>(null);
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
const [logMaxCountLocal, setLogMaxCountLocal] = React.useState<number>(25);
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
const { triggerFireworks } = useFireworksCelebration();
const isSidebarMode = mode === 'sidebar';
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(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<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
const [actionTab, setActionTab] = React.useState<ActionTab>('commit');
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
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<GitRemote[]>([]);
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
@@ -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 = () => {
)}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 grid grid-cols-1 xl:grid-cols-[minmax(520px,1fr)_480px]">
<div className="min-w-0 min-h-0 h-full flex flex-col">
{hasChanges ? (
<ChangesSection
variant="plain"
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
) : (
<div className="flex-1 min-h-0 flex items-center justify-center px-6">
<GitEmptyState
behind={status?.behind ?? 0}
onPull={() => {
if (effectiveRemotes.length > 0) {
handleSyncAction('pull', effectiveRemotes[0]);
} else {
toast.error('No remotes configured');
}
}}
isPulling={syncAction === 'pull'}
/>
</div>
)}
</div>
<div className="min-w-0 min-h-0 h-full border-t xl:border-t-0 xl:border-l border-border/40 bg-muted/10 flex flex-col">
<div className="h-full min-h-0 flex flex-col">
<div className={cn('min-w-0 min-h-0 h-full bg-muted/10 flex flex-col', isSidebarMode && 'border-t border-border/40')}>
<div className="px-3 py-3">
<AnimatedTabs<ActionTab>
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 = () => {
<div className="h-px bg-border/40" />
<ScrollableOverlay
as={ScrollShadow}
ref={actionPanelScrollRef}
outerClassName="flex-1 min-h-0"
className="px-4 py-4"
disableHorizontal
preventOverscroll
>
{actionTab === 'commit' ? (
<CommitSection
variant="plain"
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
<div className="space-y-4">
{(changeEntries?.length ?? 0) > 0 ? (
<>
<ChangesSection
variant="plain"
maxListHeightClassName="max-h-[40vh]"
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
<CommitSection
variant="plain"
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
</>
) : (
<GitEmptyState
behind={effectiveRemotes.length > 0 ? (status?.behind ?? 0) : 0}
isPulling={syncAction === 'pull'}
onPull={() => {
const remote = effectiveRemotes[0];
if (!remote) {
return;
}
void handleSyncAction('pull', remote);
}}
/>
)}
</div>
) : null}
{actionTab === 'branch' ? (
@@ -1711,6 +1751,7 @@ export const GitView: React.FC = () => {
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
onGeneratedDescription={scrollActionPanelToBottom}
/>
) : (
<div className="space-y-1">
+240 -236
View File
@@ -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<string | null>(null);
@@ -151,6 +145,7 @@ export const TerminalView: React.FC = () => {
const directoryRef = React.useRef<string | null>(effectiveDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const isTerminalVisibleRef = React.useRef(false);
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(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
? <RiAlertLine size={20} className="text-[color:var(--status-warning)]" />
: <RiCloseLine size={20} className="text-[color:var(--status-error)]" />
: terminalSessionId && !isConnecting && !isRestarting
? <RiCheckboxCircleLine size={20} className="text-[color:var(--status-success)]" />
: isConnecting || isRestarting
? <RiCircleLine size={20} className="text-[color:var(--status-warning)] animate-pulse" />
: <RiCircleLine size={20} className="text-[var(--surface-muted-foreground)]" />;
}, [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 = (
<>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => handleMobileKeyPress('esc')}
disabled={quickKeysDisabled}
>
Esc
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('tab')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Tab</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'ctrl' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">Ctrl</span>
<span className="sr-only">Control modifier</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'cmd' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('cmd')}
disabled={quickKeysDisabled}
>
<RiCommandLine size={16} />
<span className="sr-only">Command modifier</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-up')}
disabled={quickKeysDisabled}
>
<RiArrowUpLine size={16} />
<span className="sr-only">Arrow up</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-left')}
disabled={quickKeysDisabled}
>
<RiArrowLeftLine size={16} />
<span className="sr-only">Arrow left</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-down')}
disabled={quickKeysDisabled}
>
<RiArrowDownLine size={16} />
<span className="sr-only">Arrow down</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-right')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Arrow right</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('enter')}
disabled={quickKeysDisabled}
>
<RiArrowGoBackLine size={16} />
<span className="sr-only">Enter</span>
</Button>
</>
);
return (
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
<div className="px-3 py-2 text-xs bg-[var(--surface-background)]">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
<span className="truncate font-mono text-foreground/90">{displayDirectory}</span>
</div>
{isMobile ? (
<div className="flex items-center gap-2">
{statusIcon}
<Button
size="sm"
variant="default"
className="h-7 px-2 py-0"
onClick={handleClear}
disabled={!bufferLength}
title="Clear output"
type="button"
>
<RiDeleteBinLine size={16} />
Clear
</Button>
<Button
size="sm"
variant="default"
className="h-7 px-2 py-0"
onClick={handleRestart}
disabled={isRestarting}
title="Restart terminal"
type="button"
>
<RiRestartLine size={16} className={cn((isConnecting || isRestarting) && 'animate-spin')} />
Restart
</Button>
</div>
) : null}
</div>
<div className="px-5 py-2 text-xs bg-[var(--surface-background)]">
{enableTabs && directoryTerminalState ? (
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-1">
{directoryTerminalState.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
)}
>
<button
type="button"
onClick={() => handleSelectTab(tab.id)}
className="max-w-[10rem] truncate text-left"
title={tab.label}
>
{tab.label}
</button>
<button
type="button"
className={cn(
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
!isActive && 'opacity-0 group-hover:opacity-100'
)}
onClick={(e) => {
e.stopPropagation();
handleCloseTab(tab.id);
}}
title="Close tab"
>
<RiCloseLine size={14} />
</button>
</div>
);
})}
<div className="mt-2 pl-1 pr-1 flex items-center gap-2">
<div className="min-w-0 flex-1 overflow-x-auto pb-1">
<div className="flex w-max items-center gap-1 pr-1">
{directoryTerminalState.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
)}
>
<button
type="button"
onClick={() => handleSelectTab(tab.id)}
className="max-w-[10rem] truncate text-left"
title={tab.label}
>
{tab.label}
</button>
<button
type="button"
className={cn(
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
!isActive && 'opacity-0 group-hover:opacity-100'
)}
onClick={(e) => {
e.stopPropagation();
handleCloseTab(tab.id);
}}
title="Close tab"
>
<RiCloseLine size={14} />
</button>
</div>
);
})}
<button
type="button"
onClick={handleCreateTab}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
title="New tab"
>
<RiAddLine size={16} />
</button>
<button
type="button"
onClick={handleCreateTab}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
title="New tab"
>
<RiAddLine size={16} />
</button>
</div>
</div>
{!isMobile && showQuickKeys ? (
<div className="flex shrink-0 items-center gap-1 overflow-x-auto pb-1">
{quickKeysControls}
</div>
) : null}
</div>
) : null}
{showQuickKeys ? (
{showQuickKeys && (isMobile || !enableTabs || !directoryTerminalState) ? (
<div className="mt-2 flex flex-wrap items-center gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => handleMobileKeyPress('esc')}
disabled={quickKeysDisabled}
>
Esc
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('tab')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Tab</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'ctrl' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">Ctrl</span>
<span className="sr-only">Control modifier</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'cmd' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('cmd')}
disabled={quickKeysDisabled}
>
<RiCommandLine size={16} />
<span className="sr-only">Command modifier</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-up')}
disabled={quickKeysDisabled}
>
<RiArrowUpLine size={16} />
<span className="sr-only">Arrow up</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-left')}
disabled={quickKeysDisabled}
>
<RiArrowLeftLine size={16} />
<span className="sr-only">Arrow left</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-down')}
disabled={quickKeysDisabled}
>
<RiArrowDownLine size={16} />
<span className="sr-only">Arrow down</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-right')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Arrow right</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('enter')}
disabled={quickKeysDisabled}
>
<RiArrowGoBackLine size={16} />
<span className="sr-only">Enter</span>
</Button>
{quickKeysControls}
</div>
) : null}
</div>
@@ -962,8 +964,8 @@ export const TerminalView: React.FC = () => {
style={{ backgroundColor: xtermTheme.background }}
data-keyboard-avoid="true"
>
<div className="h-full w-full box-border px-3 pt-3 pb-4">
{isTerminalActive ? (
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
{shouldRenderViewport ? (
isMobile ? (
<TerminalViewport
key={viewportSessionKey}
@@ -978,6 +980,7 @@ export const TerminalView: React.FC = () => {
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
/>
) : (
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
@@ -994,6 +997,7 @@ export const TerminalView: React.FC = () => {
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
/>
</ScrollableOverlay>
)
@@ -36,6 +36,7 @@ interface BranchSelectorProps {
onCheckout: (branch: string) => void;
onCreate: (name: string) => Promise<void>;
disabled?: boolean;
tooltipDelayMs?: number;
}
const sanitizeBranchNameInput = (value: string): string => {
@@ -59,6 +60,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
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<BranchSelectorProps> = ({
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Tooltip delayDuration={1000}>
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8"
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
disabled={disabled}
>
<RiGitBranchLine className="size-4 text-primary" />
<span className="max-w-[140px] truncate font-medium">
<span className="min-w-0 truncate font-medium text-left">
{currentBranch || 'Detached HEAD'}
</span>
<RiArrowDownSLine className="size-4 opacity-60" />
@@ -145,7 +147,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Switch branch ({localBranches.length} local · {remoteBranches.length} remote)
Current branch
</TooltipContent>
</Tooltip>
@@ -1,8 +1,10 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { ChangeRow } from './ChangeRow';
import type { GitStatus } from '@/lib/api/types';
import { cn } from '@/lib/utils';
interface ChangesSectionProps {
changeEntries: GitStatus['files'];
@@ -15,6 +17,7 @@ interface ChangesSectionProps {
onViewDiff: (path: string) => void;
onRevertFile: (path: string) => void;
variant?: 'framed' | 'plain';
maxListHeightClassName?: string;
}
export const ChangesSection: React.FC<ChangesSectionProps> = ({
@@ -28,7 +31,9 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onViewDiff,
onRevertFile,
variant = 'framed',
maxListHeightClassName,
}) => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const selectedCount = selectedPaths.size;
const totalCount = changeEntries.length;
@@ -43,7 +48,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
const scrollOuterClassName =
variant === 'framed'
? 'flex-1 min-h-0 max-h-[30vh]'
: 'flex-1 min-h-0';
: `flex-1 min-h-0 ${maxListHeightClassName ?? ''}`.trim();
return (
<section className={containerClassName}>
@@ -76,22 +81,28 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
)}
</div>
</header>
<ScrollableOverlay outerClassName={scrollOuterClassName} className="w-full">
<ul className="divide-y divide-border/60">
{changeEntries.map((file) => (
<ChangeRow
key={file.path}
file={file}
checked={selectedPaths.has(file.path)}
stats={diffStats?.[file.path]}
onToggle={() => onToggleFile(file.path)}
onViewDiff={() => onViewDiff(file.path)}
onRevert={() => onRevertFile(file.path)}
isReverting={revertingPaths.has(file.path)}
/>
))}
</ul>
</ScrollableOverlay>
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
<ScrollShadow
ref={scrollRef}
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
>
<ul className="divide-y divide-border/60">
{changeEntries.map((file) => (
<ChangeRow
key={file.path}
file={file}
checked={selectedPaths.has(file.path)}
stats={diffStats?.[file.path]}
onToggle={() => onToggleFile(file.path)}
onViewDiff={() => onViewDiff(file.path)}
onRevert={() => onRevertFile(file.path)}
isReverting={revertingPaths.has(file.path)}
/>
))}
</ul>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
</div>
</section>
);
};
@@ -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<IdentityDropdownProps> = ({
@@ -110,18 +113,20 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
identities,
onSelect,
isApplying,
tooltipDelayMs = 1000,
iconOnly = false,
}) => {
const isDisabled = isApplying || identities.length === 0;
return (
<DropdownMenu>
<Tooltip delayDuration={1000}>
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
className="h-8 min-w-0 max-w-[15rem] justify-start gap-1.5 px-2 py-1 typography-ui-label"
style={{ color: getIdentityColor(activeProfile?.color) }}
disabled={isDisabled}
>
@@ -134,21 +139,16 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
className="size-4"
/>
)}
<span className="max-w-[120px] truncate hidden sm:inline">
{activeProfile?.name || 'No identity'}
</span>
{!iconOnly && (
<span className="git-identity-label min-w-0 flex-1 truncate text-left">
{activeProfile?.name || 'No identity'}
</span>
)}
<RiArrowDownSLine className="size-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="space-y-1">
<p className="typography-ui-label text-foreground">
{activeProfile?.userName || 'Unknown user'}
</p>
<p className="typography-meta text-muted-foreground">
{activeProfile?.userEmail || 'No email configured'}
</p>
</TooltipContent>
<TooltipContent sideOffset={8}>Git identity</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-64">
{identities.length === 0 ? (
@@ -158,25 +158,31 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
</p>
</div>
) : (
identities.map((profile) => (
<DropdownMenuItem key={profile.id} onSelect={() => onSelect(profile)}>
<span className="flex items-center gap-2">
<IdentityIcon
icon={profile.icon}
colorToken={profile.color}
className="size-4"
/>
<span className="flex flex-col">
<span className="typography-ui-label text-foreground">
{profile.name}
</span>
<span className="typography-meta text-muted-foreground">
{profile.userEmail}
identities.map((profile) => {
const isSelected = activeProfile?.id === profile.id;
return (
<DropdownMenuItem key={profile.id} onSelect={() => onSelect(profile)}>
<span className="flex items-center gap-2">
<IdentityIcon
icon={profile.icon}
colorToken={profile.color}
className="size-4"
/>
<span className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">
{profile.name}
</span>
<span className="typography-meta text-muted-foreground">
{profile.userEmail}
</span>
</span>
{isSelected ? (
<RiCheckLine className="ml-auto size-4 text-foreground" />
) : null}
</span>
</span>
</DropdownMenuItem>
))
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -201,77 +207,31 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onSelectIdentity,
isApplyingIdentity,
isWorktreeMode,
isSidebarMode = false,
onOpenHistory,
onOpenBranchPicker,
}) => {
const isMobile = useUIStore((state) => state.isMobile);
if (!status) {
return null;
}
return (
<header className="flex flex-wrap items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
/>
)}
{(Boolean(status.tracking) || status.ahead > 0 || status.behind > 0) && (
<Tooltip delayDuration={800}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 px-1.5 typography-meta text-muted-foreground">
<span className="flex items-center gap-0.5">
<RiArrowUpLine className="size-3.5 text-primary/70" />
<span className="font-semibold text-foreground">{status.ahead}</span>
</span>
{Boolean(status.tracking) && (
<span className="flex items-center gap-0.5">
<RiArrowDownLine className="size-3.5 text-primary/70" />
<span className="font-semibold text-foreground">{status.behind}</span>
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
{status.tracking
? `Upstream: ${status.tracking}`
: 'Unpublished commits (no upstream set yet)'}
</TooltipContent>
</Tooltip>
)}
<SyncActions
syncAction={syncAction}
remotes={remotes}
onFetch={onFetch}
onPull={onPull}
onPush={onPush}
disabled={!status}
/>
<div className="flex-1" />
const useTwoRowHeader = isSidebarMode || isMobile;
const managementButtons = (
<div className="flex items-center gap-1 shrink-0">
{onOpenBranchPicker ? (
<Tooltip delayDuration={1000}>
<Tooltip delayDuration={useTwoRowHeader ? 300 : 1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
className={isSidebarMode ? 'h-8 w-8 px-0' : 'gap-1.5 px-2 py-1 h-8 typography-ui-label'}
onClick={onOpenBranchPicker}
>
<RiGitRepositoryLine className="size-4" />
<span className="hidden sm:inline">Manage branches</span>
{!isSidebarMode && <span className="git-header-label">Manage branches</span>}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
@@ -279,28 +239,111 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
) : null}
{onOpenHistory ? (
<Tooltip delayDuration={1000}>
<Tooltip delayDuration={useTwoRowHeader ? 300 : 1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
className={isSidebarMode ? 'h-8 w-8 px-0' : 'gap-1.5 px-2 py-1 h-8 typography-ui-label'}
onClick={onOpenHistory}
>
<RiHistoryLine className="size-4" />
<span className="hidden sm:inline">History</span>
{!isSidebarMode && <span className="git-header-label">History</span>}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Show commit history</TooltipContent>
<TooltipContent sideOffset={8}>History</TooltipContent>
</Tooltip>
) : null}
</div>
);
<IdentityDropdown
activeProfile={activeIdentityProfile}
identities={availableIdentities}
onSelect={onSelectIdentity}
isApplying={isApplyingIdentity}
/>
const syncButtons = (
<SyncActions
syncAction={syncAction}
remotes={remotes}
onFetch={onFetch}
onPull={onPull}
onPush={onPush}
disabled={!status}
iconOnly={isSidebarMode}
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
aheadCount={status.ahead}
behindCount={status.behind}
/>
);
const identityControl = (
<IdentityDropdown
activeProfile={activeIdentityProfile}
identities={availableIdentities}
onSelect={onSelectIdentity}
isApplying={isApplyingIdentity}
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
iconOnly={false}
/>
);
if (useTwoRowHeader) {
return (
<header className="@container/git-header border-b border-border/40 px-3 py-2 bg-background">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex-1">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
/>
)}
</div>
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1">
{syncButtons}
{managementButtons}
</div>
<div className="min-w-0 max-w-[45%]">{identityControl}</div>
</div>
</header>
);
}
return (
<header className="@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
/>
)}
<div className="shrink-0">{syncButtons}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{managementButtons}
{identityControl}
</div>
</header>
);
};
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,10 @@ interface SyncActionsProps {
onPull: (remote: GitRemote) => void;
onPush: (remote: GitRemote) => void;
disabled: boolean;
iconOnly?: boolean;
tooltipDelayMs?: number;
aheadCount?: number;
behindCount?: number;
}
export const SyncActions: React.FC<SyncActionsProps> = ({
@@ -33,6 +37,10 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
onPull,
onPush,
disabled,
iconOnly = false,
tooltipDelayMs = 1000,
aheadCount = 0,
behindCount = 0,
}) => {
const hasNoRemotes = remotes.length === 0;
const isDisabled = disabled || syncAction !== null || hasNoRemotes;
@@ -65,23 +73,34 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
loadingIcon: React.ReactNode,
label: string,
onClick: () => void,
tooltipText: string
tooltipText: string,
counter?: number
) => {
const button = (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
onClick={onClick}
disabled={isDisabled}
>
{syncAction === action ? loadingIcon : icon}
<span className="hidden sm:inline">{label}</span>
{!iconOnly && <span className="git-header-label">{label}</span>}
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
{counter}
</span>
) : null}
{iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
{counter}
</span>
) : null}
</Button>
);
return (
<Tooltip delayDuration={1000}>
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
</Tooltip>
@@ -94,21 +113,32 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
loadingIcon: React.ReactNode,
label: string,
onSelect: (remote: GitRemote) => void,
tooltipText: string
tooltipText: string,
counter?: number
) => {
return (
<DropdownMenu>
<Tooltip delayDuration={1000}>
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
disabled={isDisabled}
>
{syncAction === action ? loadingIcon : icon}
<span className="hidden sm:inline">{label}</span>
{!iconOnly && <span className="git-header-label">{label}</span>}
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
{counter}
</span>
) : null}
{iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
{counter}
</span>
) : null}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -159,7 +189,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
<RiLoader4Line className="size-4 animate-spin" />,
'Pull',
onPull,
'Pull changes'
behindCount > 0 ? `Pull changes (${behindCount} behind)` : 'Pull changes',
behindCount
)
: renderButton(
'pull',
@@ -167,7 +198,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
<RiLoader4Line className="size-4 animate-spin" />,
'Pull',
handlePull,
'Pull changes'
behindCount > 0 ? `Pull changes (${behindCount} behind)` : 'Pull changes',
behindCount
)}
{hasMultipleRemotes
@@ -177,7 +209,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
onPush,
'Push changes'
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
aheadCount
)
: renderButton(
'push',
@@ -185,7 +218,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
handlePush,
'Push changes'
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
aheadCount
)}
</div>
);
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
interface WorktreeBranchDisplayProps {
currentBranch: string | null | undefined;
onRename?: (oldName: string, newName: string) => Promise<void>;
showEditButton?: boolean;
}
const sanitizeBranchNameInput = (value: string): string => {
@@ -23,6 +24,7 @@ const sanitizeBranchNameInput = (value: string): string => {
export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
currentBranch,
onRename,
showEditButton = true,
}) => {
const [isEditing, setIsEditing] = React.useState(false);
const [editBranchName, setEditBranchName] = React.useState(currentBranch || '');
@@ -117,24 +119,24 @@ export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2 py-1 h-8">
<RiGitBranchLine className="size-4 text-primary" />
<span className="max-w-[140px] truncate typography-ui-label font-normal text-foreground">
<div className="flex w-full min-w-0 items-center gap-1.5 px-2 py-1 h-8">
<RiGitBranchLine className="size-4 text-primary shrink-0" />
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="truncate typography-ui-label font-normal text-foreground">
{currentBranch || 'Detached HEAD'}
</span>
{showEditButton && onRename && currentBranch && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={handleStartEdit}
title="Rename branch"
>
<RiEditLine className="size-4" />
</Button>
)}
</div>
{onRename && currentBranch && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleStartEdit}
title="Rename branch"
>
<RiEditLine className="size-4" />
</Button>
)}
</div>
);
};
};
+19
View File
@@ -549,6 +549,25 @@ html:not(.dark) .chat-scroll {
}
}
/* Git right-sidebar header/actions collapse to icon-only when narrow. */
@container git-header (max-width: 26rem) {
.git-header-label {
display: none;
}
}
/* Animated tabs: collapse labels based on local container width. */
@container animated-tabs (max-width: 23rem) {
.animated-tabs__label {
display: none;
}
.animated-tabs__button {
padding-inline: 0.5rem;
gap: 0;
}
}
/* Text font: IBM Plex Sans */
.streamdown-content {
font-family: var(--font-sans);
+26 -1
View File
@@ -594,13 +594,30 @@ export type GitHubCheckRun = {
url?: string;
name?: string;
conclusion?: string | null;
steps?: Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>;
steps?: Array<{
name: string;
status?: string;
conclusion?: string | null;
number?: number;
startedAt?: string;
completedAt?: string;
}>;
};
annotations?: Array<{
path?: string;
startLine?: number;
endLine?: number;
level?: string;
message: string;
title?: string;
rawDetails?: string;
}>;
};
export type GitHubPullRequest = {
number: number;
title: string;
body?: string;
url: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
@@ -686,6 +703,13 @@ export type GitHubPullRequestCreateInput = {
draft?: boolean;
};
export type GitHubPullRequestUpdateInput = {
directory: string;
number: number;
title: string;
body?: string;
};
export type GitHubPullRequestMergeInput = {
directory: string;
number: number;
@@ -794,6 +818,7 @@ export interface GitHubAPI {
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>;
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
+6 -5
View File
@@ -76,11 +76,11 @@ export function getTerminalOptions(
fontFamily: augmentedFontFamily,
fontSize,
lineHeight: 1,
cursorBlink: true,
cursorStyle: 'block' as const,
cursorBlink: false,
cursorStyle: 'bar' as const,
theme,
allowTransparency: false,
scrollback: 50_000,
scrollback: 10_000,
minimumContrastRatio: 1,
fastScrollModifier: 'shift' as const,
fastScrollSensitivity: 5,
@@ -106,7 +106,8 @@ export function getGhosttyTerminalOptions(
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
return {
cursorBlink: true,
cursorBlink: false,
cursorStyle: 'bar' as const,
fontSize,
lineHeight: 1.15,
fontFamily: augmentedFontFamily,
@@ -135,7 +136,7 @@ export function getGhosttyTerminalOptions(
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
},
scrollback: 50_000,
scrollback: 10_000,
ghostty,
disableStdin,
};
+100
View File
@@ -24,6 +24,12 @@ interface UIStore {
isSidebarOpen: boolean;
sidebarWidth: number;
hasManuallyResizedLeftSidebar: boolean;
isRightSidebarOpen: boolean;
rightSidebarWidth: number;
hasManuallyResizedRightSidebar: boolean;
isBottomTerminalOpen: boolean;
bottomTerminalHeight: number;
hasManuallyResizedBottomTerminal: boolean;
isSessionSwitcherOpen: boolean;
activeMainTab: MainTab;
mainTabGuard: MainTabGuard | null;
@@ -81,6 +87,12 @@ interface UIStore {
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarWidth: (width: number) => void;
toggleRightSidebar: () => void;
setRightSidebarOpen: (open: boolean) => void;
setRightSidebarWidth: (width: number) => void;
toggleBottomTerminal: () => void;
setBottomTerminalOpen: (open: boolean) => void;
setBottomTerminalHeight: (height: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
@@ -153,6 +165,12 @@ export const useUIStore = create<UIStore>()(
isSidebarOpen: true,
sidebarWidth: 264,
hasManuallyResizedLeftSidebar: false,
isRightSidebarOpen: false,
rightSidebarWidth: 420,
hasManuallyResizedRightSidebar: false,
isBottomTerminalOpen: false,
bottomTerminalHeight: 300,
hasManuallyResizedBottomTerminal: false,
isSessionSwitcherOpen: false,
activeMainTab: 'chat',
mainTabGuard: null,
@@ -243,6 +261,76 @@ export const useUIStore = create<UIStore>()(
set({ sidebarWidth: width, hasManuallyResizedLeftSidebar: true });
},
toggleRightSidebar: () => {
set((state) => {
const newOpen = !state.isRightSidebarOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
return {
isRightSidebarOpen: newOpen,
rightSidebarWidth: proportionalWidth,
hasManuallyResizedRightSidebar: false,
};
}
return { isRightSidebarOpen: newOpen };
});
},
setRightSidebarOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
return {
isRightSidebarOpen: open,
rightSidebarWidth: proportionalWidth,
hasManuallyResizedRightSidebar: false,
};
}
return { isRightSidebarOpen: open };
});
},
setRightSidebarWidth: (width) => {
set({ rightSidebarWidth: width, hasManuallyResizedRightSidebar: true });
},
toggleBottomTerminal: () => {
set((state) => {
const newOpen = !state.isBottomTerminalOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
return {
isBottomTerminalOpen: newOpen,
bottomTerminalHeight: proportionalHeight,
hasManuallyResizedBottomTerminal: false,
};
}
return { isBottomTerminalOpen: newOpen };
});
},
setBottomTerminalOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
return {
isBottomTerminalOpen: open,
bottomTerminalHeight: proportionalHeight,
hasManuallyResizedBottomTerminal: false,
};
}
return { isBottomTerminalOpen: open };
});
},
setBottomTerminalHeight: (height) => {
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
},
setSessionSwitcherOpen: (open) => {
set({ isSessionSwitcherOpen: open });
},
@@ -623,6 +711,14 @@ export const useUIStore = create<UIStore>()(
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
}
if (state.isRightSidebarOpen && !state.hasManuallyResizedRightSidebar) {
updates.rightSidebarWidth = Math.floor(window.innerWidth * 0.28);
}
if (state.isBottomTerminalOpen && !state.hasManuallyResizedBottomTerminal) {
updates.bottomTerminalHeight = Math.floor(window.innerHeight * 0.32);
}
return updates;
});
},
@@ -702,6 +798,10 @@ export const useUIStore = create<UIStore>()(
theme: state.theme,
isSidebarOpen: state.isSidebarOpen,
sidebarWidth: state.sidebarWidth,
isRightSidebarOpen: state.isRightSidebarOpen,
rightSidebarWidth: state.rightSidebarWidth,
isBottomTerminalOpen: state.isBottomTerminalOpen,
bottomTerminalHeight: state.bottomTerminalHeight,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,
sidebarSection: state.sidebarSection,
+31
View File
@@ -32,6 +32,7 @@ import {
getPullRequestStatus,
markPullRequestReady,
mergePullRequest,
updatePullRequest,
} from './githubPr';
import {
@@ -1393,6 +1394,36 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
}
case 'api:github/pr:update': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const stored = await readGitHubAuth(context);
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
const directory = readStringField(payload, 'directory');
const number = readNumberField(payload, 'number') ?? 0;
const title = readStringField(payload, 'title');
const body = readStringField(payload, 'body');
if (!directory || !number || !title) {
return { id, type, success: false, error: 'directory, number, title are required' };
}
try {
const pr = await updatePullRequest(stored.accessToken, directory, {
directory,
number,
title,
...(typeof body === 'string' ? { body } : {}),
});
return { id, type, success: true, data: pr };
} catch (error: unknown) {
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
const message = error instanceof Error ? error.message : String(error);
if (status === 401 || message === 'unauthorized') {
await clearGitHubAuth(context);
}
return { id, type, success: false, error: message };
}
}
case 'api:github/pr:merge': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
+66
View File
@@ -20,6 +20,7 @@ type GitHubChecksSummary = {
type GitHubPullRequest = {
number: number;
title: string;
body?: string;
url: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
@@ -48,6 +49,13 @@ type GitHubPullRequestCreateInput = {
draft?: boolean;
};
type GitHubPullRequestUpdateInput = {
directory: string;
number: number;
title: string;
body?: string;
};
type GitHubPullRequestMergeInput = {
directory: string;
number: number;
@@ -208,6 +216,7 @@ export const getPullRequestStatus = async (
const pr: GitHubPullRequest = {
number: typeof prJson.number === 'number' ? prJson.number : 0,
title: readString(prJson.title) || '',
body: readString(prJson.body) || '',
url: readString(prJson.html_url) || '',
state,
draft: Boolean(prJson.draft),
@@ -326,6 +335,7 @@ export const createPullRequest = async (
return {
number: typeof json.number === 'number' ? json.number : 0,
title: readString(json.title) || '',
body: readString(json.body) || '',
url: readString(json.html_url) || '',
state: readString(json.state) === 'closed' ? 'closed' : 'open',
draft: Boolean(json.draft),
@@ -337,6 +347,62 @@ export const createPullRequest = async (
};
};
export const updatePullRequest = async (
accessToken: string,
directory: string,
payload: GitHubPullRequestUpdateInput,
): Promise<GitHubPullRequest> => {
const repo = await resolveRepoFromDirectory(directory);
if (!repo) {
throw new Error('Unable to resolve GitHub repo from git remote');
}
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}`, accessToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
}),
});
if (resp.status === 403) {
throw new Error('Not authorized to edit this PR');
}
if (resp.status === 401) {
const error = new Error('unauthorized');
(error as unknown as { status?: number }).status = 401;
throw error;
}
const json = await jsonOrNull<JsonRecord>(resp);
if (!resp.ok || !json) {
const message = readString(json?.message);
const firstError = Array.isArray(json?.errors) && json.errors.length > 0
? readString((json.errors[0] as JsonRecord)?.message || (json.errors[0] as JsonRecord)?.code)
: '';
const details = [message, firstError].filter(Boolean).join(' · ');
throw new Error(details || 'Failed to update PR');
}
const merged = Boolean(json.merged || json.merged_at);
const state = merged ? 'merged' : (readString(json.state) === 'closed' ? 'closed' : 'open');
return {
number: typeof json.number === 'number' ? json.number : payload.number,
title: readString(json.title) || payload.title,
body: readString(json.body) || '',
url: readString(json.html_url) || '',
state,
draft: Boolean(json.draft),
base: readString((json.base as JsonRecord | undefined)?.ref) || '',
head: readString((json.head as JsonRecord | undefined)?.ref) || '',
headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined,
mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null,
mergeableState: readString(json.mergeable_state) || undefined,
};
};
export const mergePullRequest = async (
accessToken: string,
directory: string,
+98 -2
View File
@@ -37,8 +37,24 @@ type GitHubCheckRun = {
url?: string;
name?: string;
conclusion?: string | null;
steps?: Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>;
steps?: Array<{
name: string;
status?: string;
conclusion?: string | null;
number?: number;
startedAt?: string;
completedAt?: string;
}>;
};
annotations?: Array<{
path?: string;
startLine?: number;
endLine?: number;
level?: string;
message: string;
title?: string;
rawDetails?: string;
}>;
};
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string };
@@ -456,7 +472,77 @@ export const getPullRequestContext = async (
jobsByRunId.set(runId, jobs.filter((j) => j && typeof j === 'object') as JsonRecord[]);
}
const annotationsByRunId = new Map<number, Array<{
path?: string;
startLine?: number;
endLine?: number;
level?: string;
message: string;
title?: string;
rawDetails?: string;
}>>();
for (const run of checkRuns) {
const runId = typeof run.id === 'number' ? run.id : 0;
const conclusion = (run.conclusion || '').toLowerCase();
const shouldLoadAnnotations = Boolean(
runId > 0
&& conclusion
&& !['success', 'neutral', 'skipped'].includes(conclusion),
);
if (!shouldLoadAnnotations) {
continue;
}
const annotations: Array<{
path?: string;
startLine?: number;
endLine?: number;
level?: string;
message: string;
title?: string;
rawDetails?: string;
}> = [];
for (let page = 1; page <= 3; page += 1) {
const annotationsResp = await githubFetch(
`${API_BASE}/repos/${repo.owner}/${repo.repo}/check-runs/${runId}/annotations?per_page=50&page=${page}`,
accessToken,
);
if (annotationsResp.status === 401) {
return { connected: false };
}
const annotationsJson = await jsonOrNull<unknown[]>(annotationsResp);
const chunk = Array.isArray(annotationsJson) ? annotationsJson : [];
chunk.forEach((entry) => {
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
const message = readString(rec?.message);
if (!message) return;
annotations.push({
path: readString(rec?.path) || undefined,
startLine: typeof rec?.start_line === 'number' ? rec.start_line : undefined,
endLine: typeof rec?.end_line === 'number' ? rec.end_line : undefined,
level: readString(rec?.annotation_level) || undefined,
message,
title: readString(rec?.title) || undefined,
rawDetails: readString(rec?.raw_details) || undefined,
});
});
if (chunk.length < 50) {
break;
}
}
if (annotations.length > 0) {
annotationsByRunId.set(runId, annotations);
}
}
for (const run of checkRuns) {
if (run.id && annotationsByRunId.has(run.id)) {
run.annotations = annotationsByRunId.get(run.id);
}
const ids = parseIds(run.detailsUrl);
if (!ids.runId) continue;
const jobs = jobsByRunId.get(ids.runId) ?? [];
@@ -480,9 +566,18 @@ export const getPullRequestContext = async (
? (rec?.conclusion as string | null)
: undefined,
number: typeof rec?.number === 'number' ? rec.number : undefined,
startedAt: readString(rec?.started_at) || undefined,
completedAt: readString(rec?.completed_at) || undefined,
};
})
.filter(Boolean) as Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>;
.filter(Boolean) as Array<{
name: string;
status?: string;
conclusion?: string | null;
number?: number;
startedAt?: string;
completedAt?: string;
}>;
run.job = {
runId: ids.runId,
@@ -494,6 +589,7 @@ export const getPullRequestContext = async (
: undefined,
steps: steps.length > 0 ? steps : undefined,
};
}
}
+3
View File
@@ -12,6 +12,7 @@ import type {
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestUpdateInput,
GitHubPullRequestStatus,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
@@ -34,6 +35,8 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
sendBridgeMessage<GitHubPullRequestStatus>('api:github/pr:status', { directory, branch }),
prCreate: async (payload: GitHubPullRequestCreateInput) =>
sendBridgeMessage<GitHubPullRequest>('api:github/pr:create', payload),
prUpdate: async (payload: GitHubPullRequestUpdateInput) =>
sendBridgeMessage<GitHubPullRequest>('api:github/pr:update', payload),
prMerge: async (payload: GitHubPullRequestMergeInput) =>
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
prReady: async (payload: GitHubPullRequestReadyInput) =>
+154 -14
View File
@@ -6215,6 +6215,7 @@ async function main(options = {}) {
pr: {
number: prData.number,
title: prData.title,
body: prData.body || '',
url: prData.html_url,
state: mergedState,
draft: Boolean(prData.draft),
@@ -6280,6 +6281,7 @@ async function main(options = {}) {
return res.json({
number: pr.number,
title: pr.title,
body: pr.body || '',
url: pr.html_url,
state: pr.state === 'closed' ? 'closed' : 'open',
draft: Boolean(pr.draft),
@@ -6295,6 +6297,82 @@ async function main(options = {}) {
}
});
app.post('/api/github/pr/update', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
if (!directory || !number || !title) {
return res.status(400).json({ error: 'directory, number, title are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
let updated;
try {
updated = await octokit.rest.pulls.update({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
title,
...(typeof body === 'string' ? { body } : {}),
});
} catch (error) {
if (error?.status === 401) {
return res.status(401).json({ error: 'GitHub not connected' });
}
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to edit this PR' });
}
if (error?.status === 404) {
return res.status(404).json({ error: 'PR not found in this repository' });
}
if (error?.status === 422) {
const apiMessage = error?.response?.data?.message;
const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0
? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code)
: null;
const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload';
return res.status(422).json({ error: message });
}
throw error;
}
const pr = updated?.data;
if (!pr) {
return res.status(500).json({ error: 'Failed to update PR' });
}
return res.json({
number: pr.number,
title: pr.title,
body: pr.body || '',
url: pr.html_url,
state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'),
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
});
} catch (error) {
console.error('Failed to update GitHub PR:', error);
return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' });
}
});
app.post('/api/github/pr/merge', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
@@ -6739,6 +6817,7 @@ async function main(options = {}) {
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
if (checkRuns.length > 0) {
const parsedJobs = new Map();
const parsedAnnotations = new Map();
if (includeCheckDetails) {
// Prefetch actions jobs per runId.
const runIds = new Set();
@@ -6774,6 +6853,47 @@ async function main(options = {}) {
parsedJobs.set(runId, []);
}
}
for (const run of checkRuns) {
const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : '';
const shouldLoadAnnotations = Boolean(
run?.id
&& runConclusion
&& !['success', 'neutral', 'skipped'].includes(runConclusion)
);
if (!shouldLoadAnnotations) {
continue;
}
const checkRunId = Number(run.id);
if (!Number.isFinite(checkRunId) || checkRunId <= 0) {
continue;
}
const annotations = [];
for (let page = 1; page <= 3; page += 1) {
try {
const annotationsResp = await octokit.rest.checks.listAnnotations({
owner: repo.owner,
repo: repo.repo,
check_run_id: checkRunId,
per_page: 50,
page,
});
const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : [];
annotations.push(...chunk);
if (chunk.length < 50) {
break;
}
} catch {
break;
}
}
if (annotations.length > 0) {
parsedAnnotations.set(checkRunId, annotations);
}
}
}
checkRunsOut = checkRuns.map((run) => {
@@ -6796,14 +6916,16 @@ async function main(options = {}) {
url: picked.html_url,
name: picked.name,
conclusion: picked.conclusion,
steps: Array.isArray(picked.steps)
? picked.steps.map((s) => ({
name: s.name,
status: s.status,
conclusion: s.conclusion,
number: s.number,
}))
: undefined,
steps: Array.isArray(picked.steps)
? picked.steps.map((s) => ({
name: s.name,
status: s.status,
conclusion: s.conclusion,
number: s.number,
startedAt: s.started_at || undefined,
completedAt: s.completed_at || undefined,
}))
: undefined,
};
} else {
job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl };
@@ -6831,6 +6953,19 @@ async function main(options = {}) {
}
: undefined,
...(job ? { job } : {}),
...(run.id && parsedAnnotations.has(run.id)
? {
annotations: parsedAnnotations.get(run.id).map((a) => ({
path: a.path || undefined,
startLine: typeof a.start_line === 'number' ? a.start_line : undefined,
endLine: typeof a.end_line === 'number' ? a.end_line : undefined,
level: a.annotation_level || undefined,
message: a.message || '',
title: a.title || undefined,
rawDetails: a.raw_details || undefined,
})).filter((a) => a.message),
}
: {}),
};
});
const counts = { success: 0, failure: 0, pending: 0 };
@@ -7440,12 +7575,17 @@ async function main(options = {}) {
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
let prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
- Testing: bullet list ("- Not tested" allowed)
- Notes: bullet list; include breaking/rollout notes only when relevant
let prompt = `You are drafting a GitHub Pull Request title + description for a squash-merge workflow.
Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
- Title format: conventional, outcome-first, <= 90 chars, no trailing punctuation.
- Use: <type>(<scope>): <summary>. Types: feat, fix, refactor, perf, docs, test, chore.
- Pick the most important user-facing outcome first; include a second major outcome only when needed.
- Body: GitHub-flavored markdown with sections in this exact order: ## Summary, ## Why, ## Testing.
- Summary: 3-6 bullets, concrete product/workflow impact, no vague filler, no internal helper names.
- Why: 1-3 bullets explaining motivation/tradeoff (what problem this solves for users/devs).
- Testing: checkbox list using "- [ ]"; include realistic manual/automated checks inferred from the diff.
- If tests were not run, include "- [ ] Not run locally" as first testing item.
- Keep language crisp and specific; avoid generic boilerplate.
Context:
- base branch: ${base}
+14
View File
@@ -12,6 +12,7 @@ import type {
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestUpdateInput,
GitHubPullRequestStatus,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
@@ -114,6 +115,19 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
return body;
},
async prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest> {
const response = await fetch('/api/github/pr/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to update PR');
}
return body;
},
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
const response = await fetch('/api/github/pr/merge', {
method: 'POST',