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
+99 -58
View File
@@ -15,6 +15,7 @@ import {
useIsGitRepo,
} from '@/stores/useGitStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import {
RiGitBranchLine,
RiGitMergeLine,
@@ -47,9 +48,9 @@ import { useUIStore } from '@/stores/useUIStore';
import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
import { GitHeader } from './git/GitHeader';
import { GitEmptyState } from './git/GitEmptyState';
import { ChangesSection } from './git/ChangesSection';
import { CommitSection } from './git/CommitSection';
import { GitEmptyState } from './git/GitEmptyState';
import { HistorySection } from './git/HistorySection';
import { PullRequestSection } from './git/PullRequestSection';
import { ConflictDialog } from './git/ConflictDialog';
@@ -59,12 +60,18 @@ import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIn
import type { GitRemote } from '@/lib/gitApi';
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { cn } from '@/lib/utils';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
type BranchOperation = 'merge' | 'rebase' | null;
type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree';
const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab';
const isActionTab = (value: unknown): value is ActionTab =>
value === 'commit' || value === 'branch' || value === 'pr' || value === 'worktree';
type GitViewSnapshot = {
directory?: string;
@@ -206,7 +213,11 @@ const gitViewSnapshots = new Map<string, GitViewSnapshot>();
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
export const GitView: React.FC = () => {
interface GitViewProps {
mode?: 'full' | 'sidebar';
}
export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const { currentSessionId, worktreeMetadata: worktreeMap } = useSessionStore();
@@ -294,11 +305,13 @@ export const GitView: React.FC = () => {
initialSnapshot?.commitMessage ?? ''
);
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
const actionPanelScrollRef = React.useRef<HTMLElement | null>(null);
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
const [logMaxCountLocal, setLogMaxCountLocal] = React.useState<number>(25);
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
const { triggerFireworks } = useFireworksCelebration();
const isSidebarMode = mode === 'sidebar';
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(new Map());
const identityApplyCountRef = React.useRef(0);
@@ -326,6 +339,17 @@ export const GitView: React.FC = () => {
initialSnapshot?.generatedHighlights ?? []
);
const scrollActionPanelToBottom = React.useCallback(() => {
const scrollTarget = actionPanelScrollRef.current;
if (!scrollTarget) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollTarget.scrollTo({ top: scrollTarget.scrollHeight, behavior: 'smooth' });
});
});
}, []);
const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null;
const sourceBranchForIntegrate = status?.current || null;
const shouldShowIntegrateCommits = React.useMemo(() => {
@@ -372,7 +396,13 @@ export const GitView: React.FC = () => {
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
const [actionTab, setActionTab] = React.useState<ActionTab>('commit');
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
if (typeof window === 'undefined') {
return 'commit';
}
const stored = window.localStorage.getItem(GIT_ACTION_TAB_STORAGE_KEY);
return isActionTab(stored) ? stored : 'commit';
});
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
@@ -403,6 +433,13 @@ export const GitView: React.FC = () => {
window.localStorage.removeItem(conflictStorageKey);
}, [conflictStorageKey]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(GIT_ACTION_TAB_STORAGE_KEY, actionTab);
}, [actionTab]);
// Restore conflict state from localStorage on mount
React.useEffect(() => {
if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return;
@@ -797,7 +834,7 @@ export const GitView: React.FC = () => {
}
setGeneratedHighlights(highlights);
toast.success('Commit message generated');
scrollActionPanelToBottom();
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to generate commit message';
@@ -805,7 +842,7 @@ export const GitView: React.FC = () => {
} finally {
setIsGeneratingMessage(false);
}
}, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis]);
}, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]);
const handleCreateBranch = async (branchName: string) => {
if (!currentDirectory || !status) return;
@@ -1048,10 +1085,8 @@ export const GitView: React.FC = () => {
return globalIdentity ?? null;
}, [currentIdentity, profiles, globalIdentity]);
const uniqueChangeCount = changeEntries.length;
const selectedCount = selectedPaths.size;
const isBusy = isLoading || syncAction !== null || commitAction !== null;
const hasChanges = uniqueChangeCount > 0;
const canShowIntegrateCommitsSection = Boolean(
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
);
@@ -1561,6 +1596,7 @@ export const GitView: React.FC = () => {
onSelectIdentity={handleApplyIdentity}
isApplyingIdentity={isSettingIdentity}
isWorktreeMode={!!worktreeMetadata}
isSidebarMode={isSidebarMode}
onOpenHistory={() => setIsHistoryDialogOpen(true)}
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
/>
@@ -1582,47 +1618,17 @@ export const GitView: React.FC = () => {
)}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 grid grid-cols-1 xl:grid-cols-[minmax(520px,1fr)_480px]">
<div className="min-w-0 min-h-0 h-full flex flex-col">
{hasChanges ? (
<ChangesSection
variant="plain"
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
) : (
<div className="flex-1 min-h-0 flex items-center justify-center px-6">
<GitEmptyState
behind={status?.behind ?? 0}
onPull={() => {
if (effectiveRemotes.length > 0) {
handleSyncAction('pull', effectiveRemotes[0]);
} else {
toast.error('No remotes configured');
}
}}
isPulling={syncAction === 'pull'}
/>
</div>
)}
</div>
<div className="min-w-0 min-h-0 h-full border-t xl:border-t-0 xl:border-l border-border/40 bg-muted/10 flex flex-col">
<div className="h-full min-h-0 flex flex-col">
<div className={cn('min-w-0 min-h-0 h-full bg-muted/10 flex flex-col', isSidebarMode && 'border-t border-border/40')}>
<div className="px-3 py-3">
<AnimatedTabs<ActionTab>
value={actionTab}
onValueChange={setActionTab}
collapseLabelsOnSmall
collapseLabelsOnNarrow={isSidebarMode}
tabs={[
{ value: 'commit', label: 'Commit', icon: RiGitCommitLine },
{ value: 'branch', label: 'Update branch', icon: RiGitMergeLine },
{ value: 'branch', label: 'Update', icon: RiGitMergeLine },
{ value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
{ value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
]}
@@ -1631,29 +1637,63 @@ export const GitView: React.FC = () => {
<div className="h-px bg-border/40" />
<ScrollableOverlay
as={ScrollShadow}
ref={actionPanelScrollRef}
outerClassName="flex-1 min-h-0"
className="px-4 py-4"
disableHorizontal
preventOverscroll
>
{actionTab === 'commit' ? (
<CommitSection
variant="plain"
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
<div className="space-y-4">
{(changeEntries?.length ?? 0) > 0 ? (
<>
<ChangesSection
variant="plain"
maxListHeightClassName="max-h-[40vh]"
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
<CommitSection
variant="plain"
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
</>
) : (
<GitEmptyState
behind={effectiveRemotes.length > 0 ? (status?.behind ?? 0) : 0}
isPulling={syncAction === 'pull'}
onPull={() => {
const remote = effectiveRemotes[0];
if (!remote) {
return;
}
void handleSyncAction('pull', remote);
}}
/>
)}
</div>
) : null}
{actionTab === 'branch' ? (
@@ -1711,6 +1751,7 @@ export const GitView: React.FC = () => {
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
onGeneratedDescription={scrollActionPanelToBottom}
/>
) : (
<div className="space-y-1">