diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 96c264a..38af4c7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0b0d314..62aa7a7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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('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 ? ( - + ) : ( +
+ Git is not available for scratchpad sessions +
+ ) + } + gitDirty={gitDirty} + height={bottomPanelHeight} + onClose={handleBottomPanelClose} + onHeightChange={handleBottomPanelHeightChange} + onTabChange={setBottomPanelTab} + showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)} + terminalContent={} + terminalRunning={terminalRunning} /> ) : undefined } diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index 1fc5757..771a659 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -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({ )} - {/* ── Git panel section ────────────────────────────── */} - {!isScratchpadProject(session.projectId) && ( -
- - - Git - - - -
- )} - ); diff --git a/src/renderer/components/AppShell.tsx b/src/renderer/components/AppShell.tsx index ae0b002..862e588 100644 --- a/src/renderer/components/AppShell.tsx +++ b/src/renderer/components/AppShell.tsx @@ -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 (
{/* Full-width drag region matching the title bar overlay height */} @@ -19,7 +19,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay {sidebar} - {/* Main content + terminal */} + {/* Main content + bottom panel */}
{/* Ambient glow behind active content area */}
{content}
- {terminalPanel} + {bottomPanel}
{/* Detail panel */} diff --git a/src/renderer/components/BottomPanel.tsx b/src/renderer/components/BottomPanel.tsx new file mode 100644 index 0000000..d5ef4a3 --- /dev/null +++ b/src/renderer/components/BottomPanel.tsx @@ -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 ( +
+ {/* Resize handle */} +
{ + 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 */} +
+ {/* Terminal tab */} + + + {/* Git tab */} + {showGitTab && ( + + )} + +
+ + {/* Minimize */} + + + {/* Close */} + +
+ + {/* Tab content — both always mounted, only active one visible */} +
+
+ {terminalContent} +
+ {showGitTab && ( +
+ {gitContent} +
+ )} +
+
+ ); +} + +export { DEFAULT_HEIGHT, MIN_HEIGHT }; diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 867dfb0..ca8e2ca 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -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; terminalOpen?: boolean; terminalRunning?: boolean; + gitPanelOpen?: boolean; + gitDirty?: boolean; onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise; onCancelTurn?: () => void; onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise; @@ -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 && ( + + )} {!isScratchpad && promptFiles.length > 0 && ( void; } -export function GitPanel({ projectId }: GitPanelProps) { +export function GitPanel({ projectId, onDirtyChange }: GitPanelProps) { const api = getElectronApi(); const [details, setDetails] = useState(); @@ -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 { diff --git a/src/renderer/components/TerminalPanel.tsx b/src/renderer/components/TerminalPanel.tsx index 52699b0..b5747aa 100644 --- a/src/renderer/components/TerminalPanel.tsx +++ b/src/renderer/components/TerminalPanel.tsx @@ -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(null); @@ -62,8 +56,6 @@ export function TerminalPanel({ const fitAddonRef = useRef(null); const [snapshot, setSnapshot] = useState(); 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 ( -
- {/* Resize handle */} -
{ - 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)); - } - }} - /> - +
{/* Header bar */}
{snapshot ? `${snapshot.shell} — ${snapshot.cwd}` : 'Terminal'} -
- - - -
+
{/* Terminal body */} diff --git a/src/renderer/components/chat/InlinePills.tsx b/src/renderer/components/chat/InlinePills.tsx index d068cb0..d1892ed 100644 --- a/src/renderer/components/chat/InlinePills.tsx +++ b/src/renderer/components/chat/InlinePills.tsx @@ -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({ ); } + +/* ── InlineGitPill ─────────────────────────────────────────── */ + +export function InlineGitPill({ + isDirty, + isOpen, + onToggle, +}: { + isDirty: boolean; + isOpen: boolean; + onToggle: () => void; +}) { + return ( + + ); +}