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:
committed by
GitHub
parent
3f29b2c6a2
commit
5b0a97d170
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user