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 -1
View File
@@ -302,7 +302,7 @@ This lets the application treat tooling as reusable workspace capability while s
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the activity panel and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
### Execution observability
+59 -23
View File
@@ -13,7 +13,9 @@ import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
import { BottomPanel, DEFAULT_HEIGHT as DEFAULT_BOTTOM_HEIGHT, MIN_HEIGHT as MIN_BOTTOM_HEIGHT, type BottomPanelTab } from '@renderer/components/BottomPanel';
import { GitPanel } from '@renderer/components/GitPanel';
import { TerminalPanel } from '@renderer/components/TerminalPanel';
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
import {
applySessionEventActivity,
@@ -129,12 +131,14 @@ export default function App() {
// Commit composer state
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
// Terminal state
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalHeight, setTerminalHeight] = useState(
() => workspace?.settings.terminalHeight ?? DEFAULT_TERMINAL_HEIGHT,
// Bottom panel state (terminal + git)
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
const [bottomPanelHeight, setBottomPanelHeight] = useState(
() => workspace?.settings.terminalHeight ?? DEFAULT_BOTTOM_HEIGHT,
);
const [terminalRunning, setTerminalRunning] = useState(false);
const [gitDirty, setGitDirty] = useState(false);
// Load workspace on mount
useEffect(() => {
@@ -315,7 +319,7 @@ export default function App() {
// ── Ctrl+` — Toggle terminal ──
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
setTerminalOpen((prev) => !prev);
handleTerminalToggle();
return;
}
@@ -462,26 +466,38 @@ export default function App() {
};
}, [api]);
// Sync terminalHeight from workspace settings when workspace loads
// Sync bottom panel height from workspace settings when workspace loads
useEffect(() => {
if (workspace?.settings.terminalHeight) {
setTerminalHeight(workspace.settings.terminalHeight);
setBottomPanelHeight(workspace.settings.terminalHeight);
}
}, [workspace?.settings.terminalHeight]);
const handleTerminalHeightChange = useCallback((newHeight: number) => {
const clamped = Math.max(MIN_TERMINAL_HEIGHT, Math.round(newHeight));
setTerminalHeight(clamped);
const handleBottomPanelHeightChange = useCallback((newHeight: number) => {
const clamped = Math.max(MIN_BOTTOM_HEIGHT, Math.round(newHeight));
setBottomPanelHeight(clamped);
void api.setTerminalHeight({ height: clamped });
}, [api]);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
const handleBottomPanelClose = useCallback(() => {
setBottomPanelOpen(false);
}, []);
const handleTerminalToggle = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
setBottomPanelOpen((prev) => {
if (prev && bottomPanelTab === 'terminal') return false;
return true;
});
setBottomPanelTab('terminal');
}, [bottomPanelTab]);
const handleGitToggle = useCallback(() => {
setBottomPanelOpen((prev) => {
if (prev && bottomPanelTab === 'git') return false;
return true;
});
setBottomPanelTab('git');
}, [bottomPanelTab]);
const jumpToMessage = useCallback((messageId: string) => {
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
@@ -637,14 +653,17 @@ export default function App() {
availableModels={availableModels}
mcpProbingServerIds={workspace.mcpProbingServerIds}
onTerminalToggle={handleTerminalToggle}
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
pattern={patternForSession}
project={projectForSession}
runtimeTools={sidecarCapabilities?.runtimeTools}
session={selectedSession}
sessionUsage={usageForSession}
activeSubagents={subagentsForSession}
terminalOpen={terminalOpen}
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
terminalRunning={terminalRunning}
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
gitDirty={gitDirty}
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
/>
);
@@ -740,13 +759,30 @@ export default function App() {
content={content}
detailPanel={detailPanel}
overlay={overlay}
terminalPanel={
terminalOpen ? (
<TerminalPanel
height={terminalHeight}
onHeightChange={handleTerminalHeightChange}
onClose={handleTerminalClose}
onMinimize={handleTerminalClose}
bottomPanel={
bottomPanelOpen ? (
<BottomPanel
activeTab={bottomPanelTab}
gitContent={
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
<GitPanel
onDirtyChange={setGitDirty}
projectId={selectedSession.projectId}
/>
) : (
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
Git is not available for scratchpad sessions
</div>
)
}
gitDirty={gitDirty}
height={bottomPanelHeight}
onClose={handleBottomPanelClose}
onHeightChange={handleBottomPanelHeightChange}
onTabChange={setBottomPanelTab}
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
terminalRunning={terminalRunning}
/>
) : undefined
}
+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>
);
}