From 5a715397050363008942fadf1663c9f8fc2ba4a1 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 31 Mar 2026 16:43:45 +0100 Subject: [PATCH] feat: add git workflow frontend components - RunChangeSummaryCard: post-run change review card with expandable file diffs, origin badges, selective discard with confirmation, and commit composer launch button - CommitComposer: slide-over panel for staging files, editing AI- suggested commit messages, conventional commit type selection, and commit with optional push - GitPanel: embedded activity panel section with branch management, push/pull/fetch operations, working tree inspection with inline diffs, and recent commit history - Wire RunChangeSummaryCard into RunTimeline after completed runs - Wire CommitComposer as overlay in App.tsx with state management - Wire GitPanel into ActivityPanel for non-scratchpad sessions - Update ARCHITECTURE.md with frontend git integration description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 2 + src/renderer/App.tsx | 30 + src/renderer/components/ActivityPanel.tsx | 29 +- src/renderer/components/GitPanel.tsx | 614 ++++++++++++++++++ src/renderer/components/RunTimeline.tsx | 30 +- .../components/chat/CommitComposer.tsx | 551 ++++++++++++++++ .../components/chat/RunChangeSummaryCard.tsx | 420 ++++++++++++ 7 files changed, 1672 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/GitPanel.tsx create mode 100644 src/renderer/components/chat/CommitComposer.tsx create mode 100644 src/renderer/components/chat/RunChangeSummaryCard.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fbedd7f..96c264a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -302,6 +302,8 @@ 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. + ### Execution observability The architecture treats execution as observable by design: diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2ec201b..0b0d314 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { CommitComposer } from '@renderer/components/chat/CommitComposer'; import { AppShell } from '@renderer/components/AppShell'; import { ActivityPanel } from '@renderer/components/ActivityPanel'; import { ChatPane } from '@renderer/components/ChatPane'; @@ -43,6 +44,7 @@ import { createDefaultToolApprovalPolicy } from '@shared/domain/approval'; import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; +import type { ProjectGitFileReference } from '@shared/domain/project'; import { applySessionModelConfig } from '@shared/domain/session'; import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; @@ -124,6 +126,9 @@ export default function App() { const [showSearch, setShowSearch] = useState(false); const [showBookmarks, setShowBookmarks] = useState(false); + // Commit composer state + const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>(); + // Terminal state const [terminalOpen, setTerminalOpen] = useState(false); const [terminalHeight, setTerminalHeight] = useState( @@ -487,6 +492,20 @@ export default function App() { } }, []); + const handleDiscardRunChanges = useCallback( + (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => + api.discardSessionRunGitChanges({ sessionId, runId, files }), + [api], + ); + + const handleOpenCommitComposer = useCallback(() => { + if (!selectedSession) return; + setCommitComposerCtx({ + projectId: selectedSession.projectId, + sessionId: selectedSession.id, + }); + }, [selectedSession]); + const handleCreateScratchpad = useCallback(() => { if (!workspace) return; const singlePatterns = workspace.patterns @@ -632,7 +651,9 @@ export default function App() { detailPanel = ( )} + + {commitComposerCtx && ( + setCommitComposerCtx(undefined)} + projectId={commitComposerCtx.projectId} + runId={commitComposerCtx.runId} + sessionId={commitComposerCtx.sessionId} + /> + )} ); } diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index 39ec1cf..1fc5757 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, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react'; +import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, GitBranch as GitBranchIcon, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react'; import { buildAgentActivityRows, @@ -16,8 +16,11 @@ 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'; @@ -208,6 +211,8 @@ function formatTurnEventTimestamp(iso: string): string { interface ActivityPanelProps { activity?: SessionActivityState; onJumpToMessage?: (messageId: string) => void; + onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; + onOpenCommitComposer?: () => void; pattern: PatternDefinition; session: SessionRecord; sessionRequestUsage?: SessionRequestUsageState; @@ -217,6 +222,8 @@ interface ActivityPanelProps { export function ActivityPanel({ activity, onJumpToMessage, + onDiscard, + onOpenCommitComposer, pattern, session, sessionRequestUsage, @@ -345,7 +352,13 @@ export function ActivityPanel({ )} - + {/* ── Turn events section ─────────────────────────── */} @@ -382,6 +395,18 @@ export function ActivityPanel({ )} + {/* ── Git panel section ────────────────────────────── */} + {!isScratchpadProject(session.projectId) && ( +
+ + + Git + + + +
+ )} + ); diff --git a/src/renderer/components/GitPanel.tsx b/src/renderer/components/GitPanel.tsx new file mode 100644 index 0000000..c81ceda --- /dev/null +++ b/src/renderer/components/GitPanel.tsx @@ -0,0 +1,614 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + AlertTriangle, + ArrowDownToLine, + ArrowUpFromLine, + Check, + ChevronDown, + ChevronRight, + Cloud, + FileCode2, + FileMinus2, + FilePlus2, + GitBranch, + GitCommitHorizontal, + History, + Loader2, + Plus, + RefreshCw, + Trash2, + X, +} from 'lucide-react'; + +import { getElectronApi } from '@renderer/lib/electronApi'; +import type { + ProjectGitBranchSummary, + ProjectGitCommitLogEntry, + ProjectGitDetails, + ProjectGitDiffPreview, + ProjectGitWorkingTreeFile, +} from '@shared/domain/project'; + +/* ── Helpers ───────────────────────────────────────────────── */ + +function fileBaseName(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; +} + +function fileDir(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : ''; +} + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const secs = Math.floor(diff / 1000); + if (secs < 60) return 'just now'; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days === 1) return 'yesterday'; + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +/* ── Section header ────────────────────────────────────────── */ + +function SectionHeader({ + icon, + label, + count, + expanded, + onToggle, +}: { + icon: React.ReactNode; + label: string; + count?: number; + expanded: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +/* ── File status icon ──────────────────────────────────────── */ + +function fileIcon(file: ProjectGitWorkingTreeFile) { + if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') { + return ; + } + if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') { + return ; + } + return ; +} + +/* ── Changed file row ──────────────────────────────────────── */ + +function ChangedFileEntry({ + file, + projectId, +}: { + file: ProjectGitWorkingTreeFile; + projectId: string; +}) { + const api = getElectronApi(); + const [expanded, setExpanded] = useState(false); + const [preview, setPreview] = useState(); + + const dir = fileDir(file.path); + const base = fileBaseName(file.path); + const status = file.stagedStatus ?? file.unstagedStatus ?? 'modified'; + + const handleToggle = useCallback(async () => { + if (expanded) { + setExpanded(false); + return; + } + setExpanded(true); + if (!preview) { + try { + const result = await api.getProjectGitFilePreview({ + projectId, + file: { path: file.path, previousPath: file.previousPath }, + }); + if (result) setPreview(result); + } catch { + // Preview is best-effort + } + } + }, [api, expanded, file.path, file.previousPath, preview, projectId]); + + return ( +
+ + + {expanded && preview && ( +
+
+            {preview.diff
+              ? preview.diff.split('\n').map((line, i) => {
+                  let cls = 'text-[var(--color-text-secondary)]';
+                  if (line.startsWith('+') && !line.startsWith('+++')) cls = 'text-[var(--color-status-success)]';
+                  else if (line.startsWith('-') && !line.startsWith('---')) cls = 'text-[var(--color-status-error)]';
+                  else if (line.startsWith('@@')) cls = 'text-[var(--color-accent-sky)]';
+                  return 
{line || '\u00A0'}
; + }) + : preview.newFileContents + ? preview.newFileContents.split('\n').map((line, i) => ( +
{line || '\u00A0'}
+ )) + :
Binary file
} +
+
+ )} +
+ ); +} + +/* ── Branch row ────────────────────────────────────────────── */ + +function BranchRow({ + branch, + onSwitch, + onDelete, + isSwitching, +}: { + branch: ProjectGitBranchSummary; + onSwitch: () => void; + onDelete: () => void; + isSwitching: boolean; +}) { + return ( +
+ + + {branch.name} + + {branch.isCurrent && ( + + current + + )} + {!branch.isCurrent && ( + <> + + + + )} +
+ ); +} + +/* ── Commit log row ────────────────────────────────────────── */ + +function CommitRow({ commit }: { commit: ProjectGitCommitLogEntry }) { + return ( +
+ +
+

+ {commit.subject} +

+

+ {commit.shortHash} + · + {commit.authorName} + · + {relativeTime(commit.committedAt)} +

+
+
+ ); +} + +/* ── Create branch dialog ──────────────────────────────────── */ + +function CreateBranchForm({ + onSubmit, + onCancel, +}: { + onSubmit: (name: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(''); + + return ( +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && name.trim()) { + e.preventDefault(); + onSubmit(name.trim()); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }} + placeholder="Branch name…" + value={name} + /> + + +
+ ); +} + +/* ── Main export ───────────────────────────────────────────── */ + +interface GitPanelProps { + projectId: string; +} + +export function GitPanel({ projectId }: GitPanelProps) { + const api = getElectronApi(); + + const [details, setDetails] = useState(); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [showChanges, setShowChanges] = useState(true); + const [showBranches, setShowBranches] = useState(false); + const [showHistory, setShowHistory] = useState(false); + const [showCreateBranch, setShowCreateBranch] = useState(false); + const [switchingBranch, setSwitchingBranch] = useState(); + const [operationError, setOperationError] = useState(); + const [networkBusy, setNetworkBusy] = useState<'push' | 'pull' | 'fetch'>(); + + const loadDetails = useCallback(async (quiet = false) => { + if (!quiet) setLoading(true); + else setRefreshing(true); + + try { + const result = await api.getProjectGitDetails({ projectId }); + setDetails(result); + setOperationError(undefined); + } catch (error) { + setOperationError(error instanceof Error ? error.message : String(error)); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [api, projectId]); + + useEffect(() => { + void loadDetails(); + }, [loadDetails]); + + const ctx = details?.context; + const workingTree = details?.workingTree; + const branches = details?.branches ?? []; + const commits = details?.recentCommits ?? []; + const changedFiles = workingTree?.files ?? []; + + const handlePush = useCallback(async () => { + setNetworkBusy('push'); + setOperationError(undefined); + try { + await api.pushProjectGit({ projectId }); + await loadDetails(true); + } catch (error) { + setOperationError(error instanceof Error ? error.message : String(error)); + } finally { + setNetworkBusy(undefined); + } + }, [api, loadDetails, projectId]); + + const handlePull = useCallback(async () => { + setNetworkBusy('pull'); + setOperationError(undefined); + try { + await api.pullProjectGit({ projectId }); + await loadDetails(true); + } catch (error) { + setOperationError(error instanceof Error ? error.message : String(error)); + } finally { + setNetworkBusy(undefined); + } + }, [api, loadDetails, projectId]); + + const handleFetch = useCallback(async () => { + setNetworkBusy('fetch'); + setOperationError(undefined); + try { + await api.fetchProjectGit({ projectId }); + await loadDetails(true); + } catch (error) { + setOperationError(error instanceof Error ? error.message : String(error)); + } finally { + setNetworkBusy(undefined); + } + }, [api, loadDetails, projectId]); + + const handleSwitchBranch = useCallback(async (name: string) => { + setSwitchingBranch(name); + setOperationError(undefined); + try { + await api.switchProjectGitBranch({ projectId, name }); + await loadDetails(true); + } catch (error) { + setOperationError(error instanceof Error ? error.message : String(error)); + } finally { + setSwitchingBranch(undefined); + } + }, [api, loadDetails, projectId]); + + const handleDeleteBranch = useCallback(async (name: string) => { + setOperationError(undefined); + try { + await api.deleteProjectGitBranch({ projectId, name }); + await loadDetails(true); + } catch (error) { + setOperationError(error instanceof Error ? error.message : String(error)); + } + }, [api, loadDetails, projectId]); + + const handleCreateBranch = useCallback(async (name: string) => { + setShowCreateBranch(false); + setOperationError(undefined); + try { + await api.createProjectGitBranch({ projectId, name, checkout: true }); + await loadDetails(true); + } catch (error) { + setOperationError(error instanceof Error ? error.message : String(error)); + } + }, [api, loadDetails, projectId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!ctx || ctx.status !== 'ready') { + return ( +
+ {ctx?.status === 'not-repository' + ? 'Not a git repository' + : ctx?.status === 'git-missing' + ? 'Git is not installed' + : ctx?.errorMessage ?? 'Unable to read git status'} +
+ ); + } + + return ( +
+ {/* Branch header + network actions */} +
+
+ + + {ctx.branch ?? 'detached HEAD'} + + + {ctx.upstream && ( + + {ctx.ahead !== undefined && ctx.ahead > 0 && ↑{ctx.ahead}} + {ctx.behind !== undefined && ctx.behind > 0 && ↓{ctx.behind}} + + )} + +
+ + {/* Network buttons */} + + + + +
+ + {ctx.isDirty && ( +
+ {ctx.changedFileCount} uncommitted {ctx.changedFileCount === 1 ? 'change' : 'changes'} +
+ )} +
+ + {/* Error */} + {operationError && ( +
+ + {operationError} + +
+ )} + + {/* Changed files */} + } + label="Changes" + onToggle={() => setShowChanges(!showChanges)} + /> + {showChanges && ( +
+ {changedFiles.length === 0 ? ( +

Working tree clean

+ ) : ( + changedFiles.map((file) => ( + + )) + )} +
+ )} + + {/* Branches */} + } + label="Branches" + onToggle={() => setShowBranches(!showBranches)} + /> + {showBranches && ( +
+ {/* New branch button */} + {!showCreateBranch && ( + + )} + + {showCreateBranch && ( + setShowCreateBranch(false)} + onSubmit={(name) => void handleCreateBranch(name)} + /> + )} + + {branches.map((branch) => ( + void handleDeleteBranch(branch.name)} + onSwitch={() => void handleSwitchBranch(branch.name)} + /> + ))} +
+ )} + + {/* Commit history */} + } + label="Recent Commits" + onToggle={() => setShowHistory(!showHistory)} + /> + {showHistory && ( +
+ {commits.length === 0 ? ( +

No commit history

+ ) : ( + commits.map((commit) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/src/renderer/components/RunTimeline.tsx b/src/renderer/components/RunTimeline.tsx index f084591..4ca7608 100644 --- a/src/renderer/components/RunTimeline.tsx +++ b/src/renderer/components/RunTimeline.tsx @@ -26,9 +26,10 @@ import { type CollapsedTimelineEvent, } from '@renderer/lib/runTimelineFormatting'; import type { OrchestrationMode } from '@shared/domain/pattern'; -import type { ProjectGitWorkingTreeSnapshot } from '@shared/domain/project'; +import type { ProjectGitFileReference, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project'; import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline'; import { FileChangePreview } from '@renderer/components/chat/FileChangePreview'; +import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard'; /* ── Mode accent colours (shared with ActivityPanel) ───────── */ @@ -271,11 +272,17 @@ function RunCard({ expanded, onToggle, onJumpToMessage, + sessionId, + onDiscard, + onOpenCommitComposer, }: { run: SessionRunRecord; expanded: boolean; onToggle: () => void; onJumpToMessage?: (messageId: string) => void; + sessionId: string; + onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; + onOpenCommitComposer?: () => void; }) { const accent = modeAccent[run.patternMode] ?? modeAccent.single; const statusStyle = runStatusStyles[run.status]; @@ -352,6 +359,19 @@ function RunCard({ Duration: {duration}
)} + + {/* Post-run git changes */} + {run.postRunGitSummary && run.status !== 'running' && onDiscard && ( +
+ +
+ )} )} @@ -372,10 +392,13 @@ function EmptyTimeline() { interface RunTimelineProps { runs: readonly SessionRunRecord[]; + sessionId: string; onJumpToMessage?: (messageId: string) => void; + onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; + onOpenCommitComposer?: () => void; } -export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) { +export function RunTimeline({ runs, sessionId, onJumpToMessage, onDiscard, onOpenCommitComposer }: RunTimelineProps) { const latestRunId = runs.length > 0 ? runs[0].id : undefined; const [expandedRunId, setExpandedRunId] = useState(latestRunId); @@ -394,9 +417,12 @@ export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) { setExpandedRunId(expandedRunId === run.id ? undefined : run.id)} run={run} + sessionId={sessionId} /> ))} diff --git a/src/renderer/components/chat/CommitComposer.tsx b/src/renderer/components/chat/CommitComposer.tsx new file mode 100644 index 0000000..e44a7de --- /dev/null +++ b/src/renderer/components/chat/CommitComposer.tsx @@ -0,0 +1,551 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ArrowUpFromLine, + Check, + ChevronRight, + FileCode2, + FileMinus2, + FilePlus2, + GitBranch, + Loader2, + Sparkles, + X, +} from 'lucide-react'; + +import { getElectronApi } from '@renderer/lib/electronApi'; +import type { + ProjectGitCommitMessageSuggestion, + ProjectGitConventionalCommitType, + ProjectGitDetails, + ProjectGitDiffPreview, + ProjectGitFileReference, + ProjectGitWorkingTreeFile, +} from '@shared/domain/project'; + +/* ── Helpers ───────────────────────────────────────────────── */ + +function fileBaseName(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; +} + +function fileDir(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : ''; +} + +function fileIcon(file: ProjectGitWorkingTreeFile) { + if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') { + return ; + } + if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') { + return ; + } + return ; +} + +function statusLabel(file: ProjectGitWorkingTreeFile): string { + return file.stagedStatus ?? file.unstagedStatus ?? 'modified'; +} + +const CONVENTIONAL_TYPES: { value: ProjectGitConventionalCommitType; label: string }[] = [ + { value: 'feat', label: 'feat' }, + { value: 'fix', label: 'fix' }, + { value: 'refactor', label: 'refactor' }, + { value: 'docs', label: 'docs' }, + { value: 'test', label: 'test' }, + { value: 'chore', label: 'chore' }, +]; + +/* ── Diff line renderer ────────────────────────────────────── */ + +function DiffLine({ line }: { line: string }) { + let textClass = 'text-[var(--color-text-secondary)]'; + let bgClass = ''; + + if (line.startsWith('+') && !line.startsWith('+++')) { + textClass = 'text-[var(--color-status-success)]'; + bgClass = 'bg-[var(--color-status-success)]/[0.06]'; + } else if (line.startsWith('-') && !line.startsWith('---')) { + textClass = 'text-[var(--color-status-error)]'; + bgClass = 'bg-[var(--color-status-error)]/[0.06]'; + } else if (line.startsWith('@@')) { + textClass = 'text-[var(--color-accent-sky)]'; + } else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) { + textClass = 'text-[var(--color-text-muted)]'; + } + + return
{line || '\u00A0'}
; +} + +/* ── File row with staging checkbox ────────────────────────── */ + +function CommitFileRow({ + file, + isStaged, + onToggle, + onPreview, + preview, + previewExpanded, +}: { + file: ProjectGitWorkingTreeFile; + isStaged: boolean; + onToggle: () => void; + onPreview: () => void; + preview?: ProjectGitDiffPreview; + previewExpanded: boolean; +}) { + const dir = fileDir(file.path); + const base = fileBaseName(file.path); + const hasPreview = !!preview?.diff || !!preview?.newFileContents; + + return ( +
+
+ {/* Staging checkbox */} + + + {/* File path + expand */} + +
+ + {/* Diff preview */} + {previewExpanded && preview && ( +
+
+            {preview.diff
+              ? preview.diff.split('\n').map((line, i) => )
+              : preview.newFileContents
+                ? preview.newFileContents.split('\n').map((line, i) => (
+                    
{line || '\u00A0'}
+ )) + : preview.isBinary + ?
Binary file
+ :
Loading…
} +
+
+ )} +
+ ); +} + +/* ── Main export ───────────────────────────────────────────── */ + +interface CommitComposerProps { + projectId: string; + sessionId: string; + runId?: string; + onClose: () => void; +} + +export function CommitComposer({ + projectId, + sessionId, + runId, + onClose, +}: CommitComposerProps) { + const api = getElectronApi(); + + const [details, setDetails] = useState(); + const [loading, setLoading] = useState(true); + const [commitMessage, setCommitMessage] = useState(''); + const [commitType, setCommitType] = useState('feat'); + const [stagedPaths, setStagedPaths] = useState>(new Set()); + const [previews, setPreviews] = useState>({}); + const [expandedPath, setExpandedPath] = useState(); + const [committing, setCommitting] = useState(false); + const [pushAfterCommit, setPushAfterCommit] = useState(false); + const [commitError, setCommitError] = useState(); + const [commitSuccess, setCommitSuccess] = useState(false); + + // Load git details on mount + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + try { + const result = await api.getProjectGitDetails({ projectId }); + if (!cancelled) { + setDetails(result); + + // Pre-stage files that are already in the index + if (result.workingTree) { + const preStaged = new Set(); + for (const file of result.workingTree.files) { + if (file.stagedStatus && file.stagedStatus !== 'unmerged') { + preStaged.add(file.path); + } + } + setStagedPaths(preStaged); + } + } + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { cancelled = true; }; + }, [api, projectId]); + + // Load commit message suggestion + useEffect(() => { + let cancelled = false; + + async function suggest() { + try { + const suggestion = await api.suggestProjectGitCommitMessage({ + sessionId, + runId, + conventionalType: commitType, + }); + if (!cancelled && suggestion) { + setCommitMessage(suggestion.message); + setCommitType(suggestion.type); + } + } catch { + // Suggestion is best-effort + } + } + + if (!commitMessage) { + void suggest(); + } + + return () => { cancelled = true; }; + }, [api, sessionId, runId]); // Deliberately omitting commitType/commitMessage to avoid re-triggering + + const files = useMemo( + () => details?.workingTree?.files ?? [], + [details?.workingTree?.files], + ); + + const stagedFiles = useMemo( + () => files.filter((f) => stagedPaths.has(f.path)), + [files, stagedPaths], + ); + + const toggleStaged = useCallback((path: string) => { + setStagedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + const toggleAll = useCallback(() => { + if (stagedPaths.size === files.length) { + setStagedPaths(new Set()); + } else { + setStagedPaths(new Set(files.map((f) => f.path))); + } + }, [files, stagedPaths.size]); + + const handlePreview = useCallback(async (file: ProjectGitWorkingTreeFile) => { + if (expandedPath === file.path) { + setExpandedPath(undefined); + return; + } + + setExpandedPath(file.path); + + if (!previews[file.path]) { + try { + const preview = await api.getProjectGitFilePreview({ + projectId, + file: { path: file.path, previousPath: file.previousPath }, + }); + if (preview) { + setPreviews((prev) => ({ ...prev, [file.path]: preview })); + } + } catch { + // Preview is best-effort + } + } + }, [api, expandedPath, previews, projectId]); + + const handleSuggestMessage = useCallback(async () => { + try { + const suggestion = await api.suggestProjectGitCommitMessage({ + sessionId, + runId, + conventionalType: commitType, + }); + if (suggestion) { + setCommitMessage(suggestion.message); + setCommitType(suggestion.type); + } + } catch { + // Best-effort + } + }, [api, sessionId, runId, commitType]); + + const handleCommit = useCallback(async () => { + if (!commitMessage.trim() || stagedFiles.length === 0) return; + + setCommitting(true); + setCommitError(undefined); + try { + const filesToCommit: ProjectGitFileReference[] = stagedFiles.map((f) => ({ + path: f.path, + previousPath: f.previousPath, + })); + await api.commitProjectGitChanges({ + projectId, + message: commitMessage.trim(), + files: filesToCommit, + push: pushAfterCommit, + }); + setCommitSuccess(true); + setTimeout(() => onClose(), 1200); + } catch (error) { + setCommitError(error instanceof Error ? error.message : String(error)); + } finally { + setCommitting(false); + } + }, [api, commitMessage, onClose, projectId, pushAfterCommit, stagedFiles]); + + // Keyboard: Escape to close + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + + window.addEventListener('keydown', handleKey, true); + return () => window.removeEventListener('keydown', handleKey, true); + }, [onClose]); + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+ +

+ Commit Changes +

+ {details?.context.branch && ( + + on {details.context.branch} + + )} +
+ +
+ + {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Success state */} + {commitSuccess && ( +
+
+ +
+

+ Changes committed{pushAfterCommit ? ' and pushed' : ''} +

+
+ )} + + {/* Content */} + {!loading && !commitSuccess && ( + <> + {/* Commit message */} +
+ {/* Type selector */} +
+ {CONVENTIONAL_TYPES.map((ct) => ( + + ))} +
+ +
+ +