refactor: move git panel to tabbed bottom panel alongside terminal

Replace the narrow 256px sidebar git section with a shared tabbed
bottom panel that hosts both Terminal and Git as peer tabs. This gives
git operations full horizontal width for file paths, diff previews,
branch lists, and commit history.

- Create BottomPanel with shared resize handle, tab bar, and content
  switching (terminal stays mounted via CSS visibility when inactive)
- Simplify TerminalPanel by extracting resize/close to BottomPanel
- Add InlineGitPill toggle in composer footer next to Terminal pill
- Wire tab switching: clicking active tab closes panel, clicking
  inactive tab switches to it
- Remove GitPanel from ActivityPanel sidebar
- Update ARCHITECTURE.md to describe tabbed bottom panel layout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-31 16:51:51 +01:00
co-authored by Copilot
parent 5a71539705
commit fb3e80ec47
9 changed files with 297 additions and 149 deletions
+1 -15
View File
@@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from 'react';
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, GitBranch as GitBranchIcon, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import {
buildAgentActivityRows,
@@ -16,10 +16,8 @@ import {
type TurnEventLog,
} from '@renderer/lib/sessionActivity';
import { RunTimeline } from '@renderer/components/RunTimeline';
import { GitPanel } from '@renderer/components/GitPanel';
import { inferProvider } from '@shared/domain/models';
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject } from '@shared/domain/project';
import type { ProjectGitFileReference } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons';
@@ -395,18 +393,6 @@ export function ActivityPanel({
</div>
)}
{/* ── Git panel section ────────────────────────────── */}
{!isScratchpadProject(session.projectId) && (
<div className="mb-4">
<SectionHeader>
<GitBranchIcon className="size-3" />
<span>Git</span>
</SectionHeader>
<GitPanel projectId={session.projectId} />
</div>
)}
</div>
</div>
);
+4 -4
View File
@@ -4,11 +4,11 @@ interface AppShellProps {
sidebar: ReactNode;
content: ReactNode;
detailPanel?: ReactNode;
terminalPanel?: ReactNode;
bottomPanel?: ReactNode;
overlay?: ReactNode;
}
export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay }: AppShellProps) {
export function AppShell({ sidebar, content, detailPanel, bottomPanel, overlay }: AppShellProps) {
return (
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-[var(--color-text-primary)]">
{/* Full-width drag region matching the title bar overlay height */}
@@ -19,7 +19,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
{sidebar}
</aside>
{/* Main content + terminal */}
{/* Main content + bottom panel */}
<main className="relative flex min-w-0 flex-1 flex-col">
{/* Ambient glow behind active content area */}
<div
@@ -27,7 +27,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
style={{ background: 'var(--gradient-glow)' }}
/>
<div className="relative min-h-0 flex-1">{content}</div>
{terminalPanel}
{bottomPanel}
</main>
{/* Detail panel */}
+168
View File
@@ -0,0 +1,168 @@
import { useCallback, useRef, useState, type ReactNode } from 'react';
import { GitBranch, Minus, TerminalSquare, X } from 'lucide-react';
/* ── Constants ────────────────────────────────────────────── */
const MIN_HEIGHT = 120;
const MAX_HEIGHT_FRACTION = 0.7;
const DEFAULT_HEIGHT = 280;
/* ── Types ────────────────────────────────────────────────── */
export type BottomPanelTab = 'terminal' | 'git';
/* ── BottomPanel ──────────────────────────────────────────── */
interface BottomPanelProps {
activeTab: BottomPanelTab;
onTabChange: (tab: BottomPanelTab) => void;
onClose: () => void;
height: number;
onHeightChange: (height: number) => void;
terminalContent: ReactNode;
gitContent: ReactNode;
showGitTab: boolean;
terminalRunning?: boolean;
gitDirty?: boolean;
}
export function BottomPanel({
activeTab,
onTabChange,
onClose,
height,
onHeightChange,
terminalContent,
gitContent,
showGitTab,
terminalRunning,
gitDirty,
}: BottomPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragStartRef.current = { y: e.clientY, height };
setIsDragging(true);
const handleDragMove = (moveEvent: MouseEvent) => {
if (!dragStartRef.current) return;
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
const delta = dragStartRef.current.y - moveEvent.clientY;
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
onHeightChange(nextHeight);
};
const handleDragEnd = () => {
setIsDragging(false);
dragStartRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
};
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
}, [height, onHeightChange]);
return (
<div
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
style={{ height, minHeight: MIN_HEIGHT }}
>
{/* Resize handle */}
<div
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
onMouseDown={handleDragStart}
role="separator"
aria-orientation="horizontal"
aria-label="Resize panel"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
}
}}
/>
{/* Tab bar */}
<div className="flex h-8 shrink-0 items-center border-b border-[var(--color-border)] px-1">
{/* Terminal tab */}
<button
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
activeTab === 'terminal'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onTabChange('terminal')}
type="button"
role="tab"
aria-selected={activeTab === 'terminal'}
>
{terminalRunning && <span className="size-1.5 shrink-0 rounded-full bg-emerald-400" />}
<TerminalSquare className="size-3" />
Terminal
</button>
{/* Git tab */}
{showGitTab && (
<button
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
activeTab === 'git'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onTabChange('git')}
type="button"
role="tab"
aria-selected={activeTab === 'git'}
>
{gitDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
<GitBranch className="size-3" />
Git
</button>
)}
<div className="flex-1" />
{/* Minimize */}
<button
aria-label="Minimize panel"
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onClose}
type="button"
>
<Minus className="size-3" />
</button>
{/* Close */}
<button
aria-label="Close panel"
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
onClick={onClose}
type="button"
>
<X className="size-3" />
</button>
</div>
{/* Tab content — both always mounted, only active one visible */}
<div className="relative min-h-0 flex-1">
<div className={`absolute inset-0 flex flex-col ${activeTab === 'terminal' ? '' : 'invisible'}`}>
{terminalContent}
</div>
{showGitTab && (
<div className={`absolute inset-0 flex flex-col overflow-y-auto ${activeTab === 'git' ? '' : 'hidden'}`}>
{gitContent}
</div>
)}
</div>
</div>
);
}
export { DEFAULT_HEIGHT, MIN_HEIGHT };
+14 -1
View File
@@ -9,7 +9,7 @@ import { MessageEditComposer } from '@renderer/components/chat/MessageEditCompos
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlineApprovalPill, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
@@ -53,6 +53,8 @@ interface ChatPaneProps {
activeSubagents?: ReadonlyArray<ActiveSubagent>;
terminalOpen?: boolean;
terminalRunning?: boolean;
gitPanelOpen?: boolean;
gitDirty?: boolean;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
@@ -62,6 +64,7 @@ interface ChatPaneProps {
onDismissMcpAuth?: () => void;
onAuthenticateMcp?: () => void;
onTerminalToggle?: () => void;
onGitToggle?: () => void;
onUpdateSessionModelConfig?: (config: {
model: string;
reasoningEffort?: ReasoningEffort;
@@ -87,6 +90,8 @@ export function ChatPane({
activeSubagents,
terminalOpen,
terminalRunning,
gitPanelOpen,
gitDirty,
onSend,
onCancelTurn,
onResolveApproval,
@@ -96,6 +101,7 @@ export function ChatPane({
onDismissMcpAuth,
onAuthenticateMcp,
onTerminalToggle,
onGitToggle,
onUpdateSessionModelConfig,
onUpdateSessionTooling,
onUpdateSessionApprovalSettings,
@@ -751,6 +757,13 @@ export function ChatPane({
onToggle={onTerminalToggle}
/>
)}
{onGitToggle && !isScratchpad && (
<InlineGitPill
isDirty={!!gitDirty}
isOpen={!!gitPanelOpen}
onToggle={onGitToggle}
/>
)}
{!isScratchpad && promptFiles.length > 0 && (
<InlinePromptPill
disabled={isComposerDisabled}
+3 -1
View File
@@ -310,9 +310,10 @@ function CreateBranchForm({
interface GitPanelProps {
projectId: string;
onDirtyChange?: (isDirty: boolean) => void;
}
export function GitPanel({ projectId }: GitPanelProps) {
export function GitPanel({ projectId, onDirtyChange }: GitPanelProps) {
const api = getElectronApi();
const [details, setDetails] = useState<ProjectGitDetails>();
@@ -334,6 +335,7 @@ export function GitPanel({ projectId }: GitPanelProps) {
const result = await api.getProjectGitDetails({ projectId });
setDetails(result);
setOperationError(undefined);
onDirtyChange?.(result.context.isDirty ?? false);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
+17 -103
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RotateCcw, Minus, X } from 'lucide-react';
import { RotateCcw } from 'lucide-react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
@@ -44,17 +44,11 @@ const DEFAULT_HEIGHT = 280;
/* ── TerminalPanel ────────────────────────────────────────── */
interface TerminalPanelProps {
height: number;
onHeightChange: (height: number) => void;
onClose: () => void;
onMinimize: () => void;
onRunningChange?: (running: boolean) => void;
}
export function TerminalPanel({
height,
onHeightChange,
onClose,
onMinimize,
onRunningChange,
}: TerminalPanelProps) {
const api = getElectronApi();
const containerRef = useRef<HTMLDivElement>(null);
@@ -62,8 +56,6 @@ export function TerminalPanel({
const fitAddonRef = useRef<FitAddon | null>(null);
const [snapshot, setSnapshot] = useState<TerminalSnapshot>();
const [isRunning, setIsRunning] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
// Create or recover terminal on mount
useEffect(() => {
@@ -74,11 +66,13 @@ export function TerminalPanel({
if (existing) {
setSnapshot(existing);
setIsRunning(true);
onRunningChange?.(true);
} else {
void api.createTerminal().then((created) => {
if (disposed) return;
setSnapshot(created);
setIsRunning(true);
onRunningChange?.(true);
});
}
});
@@ -135,6 +129,7 @@ export function TerminalPanel({
});
const offExit = api.onTerminalExit((_info: TerminalExitInfo) => {
setIsRunning(false);
onRunningChange?.(false);
terminalRef.current?.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
});
@@ -144,19 +139,7 @@ export function TerminalPanel({
};
}, [api]);
// Refit on height changes
useEffect(() => {
if (!fitAddonRef.current || !terminalRef.current) return;
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
const terminal = terminalRef.current;
if (terminal) {
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
}
});
}, [height, api]);
// ResizeObserver for container width changes
// ResizeObserver for container size changes (width or height from parent)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -174,100 +157,31 @@ export function TerminalPanel({
return () => observer.disconnect();
}, [api]);
// Drag-to-resize
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragStartRef.current = { y: e.clientY, height };
setIsDragging(true);
const handleDragMove = (moveEvent: MouseEvent) => {
if (!dragStartRef.current) return;
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
const delta = dragStartRef.current.y - moveEvent.clientY;
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
onHeightChange(nextHeight);
};
const handleDragEnd = () => {
setIsDragging(false);
dragStartRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
};
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
}, [height, onHeightChange]);
const handleRestart = useCallback(() => {
void api.restartTerminal().then((restarted) => {
setSnapshot(restarted);
setIsRunning(true);
onRunningChange?.(true);
terminalRef.current?.clear();
});
}, [api]);
const handleClose = useCallback(() => {
void api.killTerminal();
onClose();
}, [api, onClose]);
return (
<div
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
style={{ height, minHeight: MIN_HEIGHT }}
>
{/* Resize handle */}
<div
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
onMouseDown={handleDragStart}
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
}
}}
/>
<div className="flex min-h-0 flex-1 flex-col">
{/* Header bar */}
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
<span className={`size-1.5 shrink-0 rounded-full ${isRunning ? 'bg-emerald-400' : 'bg-[var(--color-text-muted)]'}`} />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-[var(--color-text-muted)]">
{snapshot ? `${snapshot.shell}${snapshot.cwd}` : 'Terminal'}
</span>
<div className="flex items-center gap-0.5">
<button
aria-label="Restart terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={handleRestart}
type="button"
>
<RotateCcw className="size-3" />
</button>
<button
aria-label="Minimize terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onMinimize}
type="button"
>
<Minus className="size-3" />
</button>
<button
aria-label="Close terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
onClick={handleClose}
type="button"
>
<X className="size-3" />
</button>
</div>
<button
aria-label="Restart terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={handleRestart}
type="button"
>
<RotateCcw className="size-3" />
</button>
</div>
{/* Terminal body */}
+30 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
import { ChevronDown, ChevronRight, GitBranch, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { PopoverToggleRow } from '@renderer/components/ui';
@@ -757,3 +757,32 @@ export function InlineTerminalPill({
</button>
);
}
/* ── InlineGitPill ─────────────────────────────────────────── */
export function InlineGitPill({
isDirty,
isOpen,
onToggle,
}: {
isDirty: boolean;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<button
aria-pressed={isOpen}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium transition-all duration-200 ${
isOpen
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] hover:bg-[var(--color-accent)]/20'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
}`}
onClick={onToggle}
type="button"
>
{isDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
<GitBranch className="size-3" />
<span>Git</span>
</button>
);
}