feat(ui): add desktop git sidebar + terminal dock and improve in-app PR workflow (#362)

* feat: add unified dropdown with services content in header

* feat: add right Git sidebar with resizable panel

* feat: implement responsive panel auto-toggle and terminal rehydration

- Auto-close the right sidebar when width is below a threshold and auto-open it when space permits
- Auto-close the bottom terminal when height is below a threshold and auto-open it when enough space
- Apply a dedicated rehydrated streaming configuration for terminal sessions to optimize reconnect behavior

* feat: enhance PR view with status caching and annotations

* feat(ui): enable chat dispatch in PullRequestSection

* feat(TerminalView): adjust layout

* feat: refine chat input layout and text selection menu

* fix(ui): show empty state in GitView when no changes

* feat(git): update PR actions styling and create PR button
This commit is contained in:
Bohdan Triapitsyn
2026-02-09 03:13:34 +02:00
committed by GitHub
parent 3f29b2c6a2
commit 5b0a97d170
30 changed files with 3216 additions and 1443 deletions
+240 -236
View File
@@ -1,8 +1,7 @@
import React from 'react';
import { RiAddLine, RiAlertLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCheckboxCircleLine, RiCircleLine, RiCloseLine, RiCommandLine, RiDeleteBinLine, RiRestartLine } from '@remixicon/react';
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { type TerminalStreamEvent } from '@/lib/api/types';
@@ -54,6 +53,15 @@ const STREAM_OPTIONS = {
connectionTimeoutMs: 10_000,
};
const REHYDRATED_STREAM_OPTIONS = {
retry: {
maxRetries: 0,
initialDelayMs: 200,
maxDelayMs: 500,
},
connectionTimeoutMs: 1_500,
};
const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string | null => {
if (modifier) {
switch (key) {
@@ -88,18 +96,6 @@ export const TerminalView: React.FC = () => {
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
const effectiveDirectory = useEffectiveDirectory() ?? null;
const { homeDirectory } = useDirectoryStore();
const displayDirectory = React.useMemo(() => {
if (!effectiveDirectory) return '';
if (!homeDirectory) return effectiveDirectory;
if (effectiveDirectory === homeDirectory) return '~';
if (effectiveDirectory.startsWith(homeDirectory + '/')) {
return '~' + effectiveDirectory.slice(homeDirectory.length);
}
return effectiveDirectory;
}, [effectiveDirectory, homeDirectory]);
const terminalStore = useTerminalStore();
const terminalSessions = terminalStore.sessions;
const terminalHydrated = terminalStore.hasHydrated;
@@ -110,7 +106,6 @@ export const TerminalView: React.FC = () => {
const setTabSessionId = terminalStore.setTabSessionId;
const setConnecting = terminalStore.setConnecting;
const appendToBuffer = terminalStore.appendToBuffer;
const clearBuffer = terminalStore.clearBuffer;
const directoryTerminalState = React.useMemo(() => {
if (!effectiveDirectory) return undefined;
@@ -136,7 +131,6 @@ export const TerminalView: React.FC = () => {
const terminalSessionId = activeTab?.terminalSessionId ?? null;
const bufferChunks = activeTab?.bufferChunks ?? [];
const bufferLength = activeTab?.bufferLength ?? 0;
const isConnecting = activeTab?.isConnecting ?? false;
const [connectionError, setConnectionError] = React.useState<string | null>(null);
@@ -151,6 +145,7 @@ export const TerminalView: React.FC = () => {
const directoryRef = React.useRef<string | null>(effectiveDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const isTerminalVisibleRef = React.useRef(false);
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
const rehydratedSnapshotTakenRef = React.useRef(false);
@@ -177,15 +172,28 @@ export const TerminalView: React.FC = () => {
}, [terminalHydrated]);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
const isTerminalActive = activeMainTab === 'terminal';
const isTerminalVisible = isTerminalActive || isBottomTerminalOpen;
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
React.useEffect(() => {
if (!isTerminalActive || runtime.platform === 'vscode') {
if (!isTerminalVisible || runtime.platform === 'vscode') {
return;
}
primeTerminalInputTransport();
}, [isTerminalActive, runtime.platform]);
}, [isTerminalVisible, runtime.platform]);
React.useEffect(() => {
if (isTerminalVisible) {
setHasOpenedTerminalViewport(true);
}
}, [isTerminalVisible]);
React.useEffect(() => {
isTerminalVisibleRef.current = isTerminalVisible;
}, [isTerminalVisible]);
React.useEffect(() => {
terminalIdRef.current = terminalSessionId;
@@ -226,7 +234,12 @@ export const TerminalView: React.FC = () => {
);
const startStream = React.useCallback(
(directory: string, tabId: string, terminalId: string) => {
(
directory: string,
tabId: string,
terminalId: string,
streamOptions = STREAM_OPTIONS
) => {
if (activeTerminalIdRef.current === terminalId) {
return;
}
@@ -313,7 +326,7 @@ export const TerminalView: React.FC = () => {
}
},
},
STREAM_OPTIONS
streamOptions
);
streamCleanupRef.current = () => {
@@ -327,7 +340,7 @@ export const TerminalView: React.FC = () => {
React.useEffect(() => {
let cancelled = false;
if (!terminalHydrated) {
if (!terminalHydrated || !hasOpenedTerminalViewport) {
return;
}
@@ -368,6 +381,9 @@ export const TerminalView: React.FC = () => {
(tab?.bufferLength ?? 0) === 0 &&
(tab?.bufferChunks?.length ?? 0) === 0;
const isRehydratedSession =
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
if (!terminalId) {
setConnectionError(null);
setIsFatalError(false);
@@ -412,11 +428,19 @@ export const TerminalView: React.FC = () => {
terminalIdRef.current = terminalId;
if (shouldNudgeExisting) {
nudgeOnConnectTerminalIdRef.current = terminalId;
if (isRehydratedSession) {
rehydratedTerminalIdsRef.current.delete(terminalId);
}
startStream(directory, tabId, terminalId);
if (shouldNudgeExisting) {
nudgeOnConnectTerminalIdRef.current = terminalId;
}
startStream(
directory,
tabId,
terminalId,
isRehydratedSession ? REHYDRATED_STREAM_OPTIONS : STREAM_OPTIONS
);
};
void ensureSession();
@@ -431,6 +455,7 @@ export const TerminalView: React.FC = () => {
effectiveDirectory,
terminalSessionId,
activeTabId,
hasOpenedTerminalViewport,
enableTabs,
terminalHydrated,
ensureDirectory,
@@ -441,6 +466,25 @@ export const TerminalView: React.FC = () => {
terminal,
]);
React.useEffect(() => {
if (!isTerminalVisible) {
return;
}
if (typeof window === 'undefined') {
terminalControllerRef.current?.focus();
return;
}
const rafId = window.requestAnimationFrame(() => {
terminalControllerRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [activeTabId, isTerminalVisible]);
const handleRestart = React.useCallback(async () => {
if (!effectiveDirectory) return;
if (isRestarting) return;
@@ -472,21 +516,6 @@ export const TerminalView: React.FC = () => {
await handleRestart();
}, [handleRestart]);
const handleClear = React.useCallback(() => {
if (!effectiveDirectory) return;
if (!activeTabId) return;
clearBuffer(effectiveDirectory, activeTabId);
terminalControllerRef.current?.clear();
terminalControllerRef.current?.focus();
const terminalId = terminalIdRef.current;
if (terminalId) {
void terminal.sendInput(terminalId, '\u000c').catch((error) => {
setConnectionError(error instanceof Error ? error.message : 'Failed to refresh prompt');
});
}
}, [activeTabId, clearBuffer, effectiveDirectory, setConnectionError, terminal]);
const handleCreateTab = React.useCallback(() => {
if (!effectiveDirectory) return;
const tabId = createTab(effectiveDirectory);
@@ -565,6 +594,9 @@ export const TerminalView: React.FC = () => {
const handleViewportResize = React.useCallback(
(cols: number, rows: number) => {
lastViewportSizeRef.current = { cols, rows };
if (!isTerminalVisibleRef.current) {
return;
}
const terminalId = terminalIdRef.current;
if (!terminalId) return;
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
@@ -705,7 +737,7 @@ export const TerminalView: React.FC = () => {
const viewportSessionKey = terminalSessionId ?? terminalSessionKey;
React.useEffect(() => {
if (!isTerminalActive) {
if (!isTerminalVisible) {
return;
}
const controller = terminalControllerRef.current;
@@ -727,19 +759,7 @@ export const TerminalView: React.FC = () => {
};
}
fitOnce();
}, [isTerminalActive, terminalSessionKey, terminalSessionId]);
const isReconnecting = connectionError?.includes('Reconnecting');
const statusIcon = connectionError
? isReconnecting
? <RiAlertLine size={20} className="text-[color:var(--status-warning)]" />
: <RiCloseLine size={20} className="text-[color:var(--status-error)]" />
: terminalSessionId && !isConnecting && !isRestarting
? <RiCheckboxCircleLine size={20} className="text-[color:var(--status-success)]" />
: isConnecting || isRestarting
? <RiCircleLine size={20} className="text-[color:var(--status-warning)] animate-pulse" />
: <RiCircleLine size={20} className="text-[var(--surface-muted-foreground)]" />;
}, [isTerminalVisible, terminalSessionKey, terminalSessionId]);
if (!hasActiveContext) {
return (
@@ -764,195 +784,177 @@ export const TerminalView: React.FC = () => {
}
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
const quickKeysControls = (
<>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => handleMobileKeyPress('esc')}
disabled={quickKeysDisabled}
>
Esc
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('tab')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Tab</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'ctrl' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">Ctrl</span>
<span className="sr-only">Control modifier</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'cmd' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('cmd')}
disabled={quickKeysDisabled}
>
<RiCommandLine size={16} />
<span className="sr-only">Command modifier</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-up')}
disabled={quickKeysDisabled}
>
<RiArrowUpLine size={16} />
<span className="sr-only">Arrow up</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-left')}
disabled={quickKeysDisabled}
>
<RiArrowLeftLine size={16} />
<span className="sr-only">Arrow left</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-down')}
disabled={quickKeysDisabled}
>
<RiArrowDownLine size={16} />
<span className="sr-only">Arrow down</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-right')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Arrow right</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('enter')}
disabled={quickKeysDisabled}
>
<RiArrowGoBackLine size={16} />
<span className="sr-only">Enter</span>
</Button>
</>
);
return (
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
<div className="px-3 py-2 text-xs bg-[var(--surface-background)]">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
<span className="truncate font-mono text-foreground/90">{displayDirectory}</span>
</div>
{isMobile ? (
<div className="flex items-center gap-2">
{statusIcon}
<Button
size="sm"
variant="default"
className="h-7 px-2 py-0"
onClick={handleClear}
disabled={!bufferLength}
title="Clear output"
type="button"
>
<RiDeleteBinLine size={16} />
Clear
</Button>
<Button
size="sm"
variant="default"
className="h-7 px-2 py-0"
onClick={handleRestart}
disabled={isRestarting}
title="Restart terminal"
type="button"
>
<RiRestartLine size={16} className={cn((isConnecting || isRestarting) && 'animate-spin')} />
Restart
</Button>
</div>
) : null}
</div>
<div className="px-5 py-2 text-xs bg-[var(--surface-background)]">
{enableTabs && directoryTerminalState ? (
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-1">
{directoryTerminalState.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
)}
>
<button
type="button"
onClick={() => handleSelectTab(tab.id)}
className="max-w-[10rem] truncate text-left"
title={tab.label}
>
{tab.label}
</button>
<button
type="button"
className={cn(
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
!isActive && 'opacity-0 group-hover:opacity-100'
)}
onClick={(e) => {
e.stopPropagation();
handleCloseTab(tab.id);
}}
title="Close tab"
>
<RiCloseLine size={14} />
</button>
</div>
);
})}
<div className="mt-2 pl-1 pr-1 flex items-center gap-2">
<div className="min-w-0 flex-1 overflow-x-auto pb-1">
<div className="flex w-max items-center gap-1 pr-1">
{directoryTerminalState.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
)}
>
<button
type="button"
onClick={() => handleSelectTab(tab.id)}
className="max-w-[10rem] truncate text-left"
title={tab.label}
>
{tab.label}
</button>
<button
type="button"
className={cn(
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
!isActive && 'opacity-0 group-hover:opacity-100'
)}
onClick={(e) => {
e.stopPropagation();
handleCloseTab(tab.id);
}}
title="Close tab"
>
<RiCloseLine size={14} />
</button>
</div>
);
})}
<button
type="button"
onClick={handleCreateTab}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
title="New tab"
>
<RiAddLine size={16} />
</button>
<button
type="button"
onClick={handleCreateTab}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
title="New tab"
>
<RiAddLine size={16} />
</button>
</div>
</div>
{!isMobile && showQuickKeys ? (
<div className="flex shrink-0 items-center gap-1 overflow-x-auto pb-1">
{quickKeysControls}
</div>
) : null}
</div>
) : null}
{showQuickKeys ? (
{showQuickKeys && (isMobile || !enableTabs || !directoryTerminalState) ? (
<div className="mt-2 flex flex-wrap items-center gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => handleMobileKeyPress('esc')}
disabled={quickKeysDisabled}
>
Esc
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('tab')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Tab</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'ctrl' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">Ctrl</span>
<span className="sr-only">Control modifier</span>
</Button>
<Button
type="button"
size="sm"
variant={activeModifier === 'cmd' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
onClick={() => handleModifierToggle('cmd')}
disabled={quickKeysDisabled}
>
<RiCommandLine size={16} />
<span className="sr-only">Command modifier</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-up')}
disabled={quickKeysDisabled}
>
<RiArrowUpLine size={16} />
<span className="sr-only">Arrow up</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-left')}
disabled={quickKeysDisabled}
>
<RiArrowLeftLine size={16} />
<span className="sr-only">Arrow left</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-down')}
disabled={quickKeysDisabled}
>
<RiArrowDownLine size={16} />
<span className="sr-only">Arrow down</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('arrow-right')}
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Arrow right</span>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 w-9 p-0"
onClick={() => handleMobileKeyPress('enter')}
disabled={quickKeysDisabled}
>
<RiArrowGoBackLine size={16} />
<span className="sr-only">Enter</span>
</Button>
{quickKeysControls}
</div>
) : null}
</div>
@@ -962,8 +964,8 @@ export const TerminalView: React.FC = () => {
style={{ backgroundColor: xtermTheme.background }}
data-keyboard-avoid="true"
>
<div className="h-full w-full box-border px-3 pt-3 pb-4">
{isTerminalActive ? (
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
{shouldRenderViewport ? (
isMobile ? (
<TerminalViewport
key={viewportSessionKey}
@@ -978,6 +980,7 @@ export const TerminalView: React.FC = () => {
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
/>
) : (
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
@@ -994,6 +997,7 @@ export const TerminalView: React.FC = () => {
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
/>
</ScrollableOverlay>
)