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>
This commit is contained in:
David Kaya
2026-03-31 16:43:45 +01:00
co-authored by Copilot
parent 906433f408
commit 5a71539705
7 changed files with 1672 additions and 4 deletions
+27 -2
View File
@@ -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<unknown>;
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({
)}
</SectionHeader>
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
<RunTimeline
onDiscard={onDiscard}
onJumpToMessage={onJumpToMessage}
onOpenCommitComposer={onOpenCommitComposer}
runs={session.runs}
sessionId={session.id}
/>
</div>
{/* ── Turn events section ─────────────────────────── */}
@@ -382,6 +395,18 @@ 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>
);
+614
View File
@@ -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 (
<button
className="flex w-full items-center gap-2 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors duration-100 hover:bg-[var(--color-surface-2)]/30"
onClick={onToggle}
type="button"
aria-expanded={expanded}
>
{expanded
? <ChevronDown className="size-2.5 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-2.5 text-[var(--color-text-muted)]" />}
{icon}
<span className="font-display text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
{label}
</span>
{count !== undefined && count > 0 && (
<span className="rounded-full bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[8px] font-semibold tabular-nums text-[var(--color-text-muted)]">
{count}
</span>
)}
</button>
);
}
/* ── File status icon ──────────────────────────────────────── */
function fileIcon(file: ProjectGitWorkingTreeFile) {
if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') {
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
}
if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') {
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
}
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
/* ── Changed file row ──────────────────────────────────────── */
function ChangedFileEntry({
file,
projectId,
}: {
file: ProjectGitWorkingTreeFile;
projectId: string;
}) {
const api = getElectronApi();
const [expanded, setExpanded] = useState(false);
const [preview, setPreview] = useState<ProjectGitDiffPreview>();
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 (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<button
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-left text-[10px] transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
onClick={() => void handleToggle()}
type="button"
aria-expanded={expanded}
>
<ChevronRight
className={`size-2 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
{fileIcon(file)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{status}
</span>
</button>
{expanded && preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-40 overflow-auto bg-[var(--color-surface-0)] px-3 py-1 font-mono text-[9px] leading-relaxed">
{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 <div key={i} className={cls}>{line || '\u00A0'}</div>;
})
: preview.newFileContents
? preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: <div className="text-[var(--color-text-muted)] italic">Binary file</div>}
</pre>
</div>
)}
</div>
);
}
/* ── Branch row ────────────────────────────────────────────── */
function BranchRow({
branch,
onSwitch,
onDelete,
isSwitching,
}: {
branch: ProjectGitBranchSummary;
onSwitch: () => void;
onDelete: () => void;
isSwitching: boolean;
}) {
return (
<div className="flex items-center gap-1.5 px-3 py-[5px] text-[10px]">
<GitBranch className={`size-3 shrink-0 ${branch.isCurrent ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)]'}`} />
<span className={`min-w-0 flex-1 truncate font-mono ${branch.isCurrent ? 'font-medium text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
{branch.name}
</span>
{branch.isCurrent && (
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]">
current
</span>
)}
{!branch.isCurrent && (
<>
<button
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onSwitch}
type="button"
title={`Switch to ${branch.name}`}
disabled={isSwitching}
>
{isSwitching ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
</button>
<button
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={onDelete}
type="button"
title={`Delete ${branch.name}`}
>
<Trash2 className="size-2.5" />
</button>
</>
)}
</div>
);
}
/* ── Commit log row ────────────────────────────────────────── */
function CommitRow({ commit }: { commit: ProjectGitCommitLogEntry }) {
return (
<div className="flex items-start gap-2 px-3 py-[5px] text-[10px]">
<GitCommitHorizontal className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<p className="truncate text-[var(--color-text-secondary)]">
{commit.subject}
</p>
<p className="flex items-center gap-1.5 text-[9px] text-[var(--color-text-muted)]">
<span className="font-mono">{commit.shortHash}</span>
<span>·</span>
<span>{commit.authorName}</span>
<span>·</span>
<span>{relativeTime(commit.committedAt)}</span>
</p>
</div>
</div>
);
}
/* ── Create branch dialog ──────────────────────────────────── */
function CreateBranchForm({
onSubmit,
onCancel,
}: {
onSubmit: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
return (
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5">
<input
autoFocus
className="min-w-0 flex-1 rounded border border-[var(--color-border)] bg-[var(--color-surface-0)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
onChange={(e) => 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}
/>
<button
className="rounded p-1 text-[var(--color-status-success)] transition-colors duration-100 hover:bg-[var(--color-status-success)]/10 disabled:opacity-40"
disabled={!name.trim()}
onClick={() => onSubmit(name.trim())}
type="button"
>
<Check className="size-3" />
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={onCancel}
type="button"
>
<X className="size-3" />
</button>
</div>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface GitPanelProps {
projectId: string;
}
export function GitPanel({ projectId }: GitPanelProps) {
const api = getElectronApi();
const [details, setDetails] = useState<ProjectGitDetails>();
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<string>();
const [operationError, setOperationError] = useState<string>();
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 (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-4 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git status" />
</div>
);
}
if (!ctx || ctx.status !== 'ready') {
return (
<div className="px-3 py-4 text-center text-[11px] text-[var(--color-text-muted)]">
{ctx?.status === 'not-repository'
? 'Not a git repository'
: ctx?.status === 'git-missing'
? 'Git is not installed'
: ctx?.errorMessage ?? 'Unable to read git status'}
</div>
);
}
return (
<div className="flex flex-col">
{/* Branch header + network actions */}
<div className="border-b border-[var(--color-border-subtle)] px-3 py-2">
<div className="flex items-center gap-1.5">
<GitBranch className="size-3 text-[var(--color-accent-sky)]" />
<span className="font-mono text-[11px] font-medium text-[var(--color-text-primary)]">
{ctx.branch ?? 'detached HEAD'}
</span>
{ctx.upstream && (
<span className="text-[9px] text-[var(--color-text-muted)]">
{ctx.ahead !== undefined && ctx.ahead > 0 && <span className="text-[var(--color-status-success)]">{ctx.ahead}</span>}
{ctx.behind !== undefined && ctx.behind > 0 && <span className="ml-0.5 text-[var(--color-status-warning)]">{ctx.behind}</span>}
</span>
)}
<div className="flex-1" />
{/* Network buttons */}
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
disabled={!!networkBusy}
onClick={() => void handleFetch()}
title="Fetch"
type="button"
>
{networkBusy === 'fetch' ? <Loader2 className="size-3 animate-spin" /> : <Cloud className="size-3" />}
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
disabled={!!networkBusy}
onClick={() => void handlePull()}
title="Pull"
type="button"
>
{networkBusy === 'pull' ? <Loader2 className="size-3 animate-spin" /> : <ArrowDownToLine className="size-3" />}
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
disabled={!!networkBusy}
onClick={() => void handlePush()}
title="Push"
type="button"
>
{networkBusy === 'push' ? <Loader2 className="size-3 animate-spin" /> : <ArrowUpFromLine className="size-3" />}
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={() => void loadDetails(true)}
title="Refresh"
type="button"
>
<RefreshCw className={`size-3 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
{ctx.isDirty && (
<div className="mt-1 text-[9px] text-[var(--color-status-warning)]">
{ctx.changedFileCount} uncommitted {ctx.changedFileCount === 1 ? 'change' : 'changes'}
</div>
)}
</div>
{/* Error */}
{operationError && (
<div className="flex items-start gap-1.5 border-b border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-3 py-1.5 text-[9px] text-[var(--color-status-error)]" role="alert">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 flex-1">{operationError}</span>
<button
className="shrink-0 rounded p-0.5 transition-colors duration-100 hover:bg-[var(--color-status-error)]/10"
onClick={() => setOperationError(undefined)}
type="button"
aria-label="Dismiss error"
>
<X className="size-2.5" />
</button>
</div>
)}
{/* Changed files */}
<SectionHeader
count={changedFiles.length}
expanded={showChanges}
icon={<FileCode2 className="size-3 text-[var(--color-accent-sky)]" />}
label="Changes"
onToggle={() => setShowChanges(!showChanges)}
/>
{showChanges && (
<div>
{changedFiles.length === 0 ? (
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">Working tree clean</p>
) : (
changedFiles.map((file) => (
<ChangedFileEntry
file={file}
key={file.path}
projectId={projectId}
/>
))
)}
</div>
)}
{/* Branches */}
<SectionHeader
count={branches.length}
expanded={showBranches}
icon={<GitBranch className="size-3 text-[var(--color-status-success)]" />}
label="Branches"
onToggle={() => setShowBranches(!showBranches)}
/>
{showBranches && (
<div>
{/* New branch button */}
{!showCreateBranch && (
<button
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-[10px] text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-2)]/40 hover:text-[var(--color-text-accent)]"
onClick={() => setShowCreateBranch(true)}
type="button"
>
<Plus className="size-3" />
New branch
</button>
)}
{showCreateBranch && (
<CreateBranchForm
onCancel={() => setShowCreateBranch(false)}
onSubmit={(name) => void handleCreateBranch(name)}
/>
)}
{branches.map((branch) => (
<BranchRow
branch={branch}
isSwitching={switchingBranch === branch.name}
key={branch.name}
onDelete={() => void handleDeleteBranch(branch.name)}
onSwitch={() => void handleSwitchBranch(branch.name)}
/>
))}
</div>
)}
{/* Commit history */}
<SectionHeader
count={commits.length}
expanded={showHistory}
icon={<History className="size-3 text-[var(--color-text-muted)]" />}
label="Recent Commits"
onToggle={() => setShowHistory(!showHistory)}
/>
{showHistory && (
<div>
{commits.length === 0 ? (
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">No commit history</p>
) : (
commits.map((commit) => (
<CommitRow commit={commit} key={commit.hash} />
))
)}
</div>
)}
</div>
);
}
+28 -2
View File
@@ -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<unknown>;
onOpenCommitComposer?: () => void;
}) {
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
const statusStyle = runStatusStyles[run.status];
@@ -352,6 +359,19 @@ function RunCard({
Duration: {duration}
</div>
)}
{/* Post-run git changes */}
{run.postRunGitSummary && run.status !== 'running' && onDiscard && (
<div className="mt-2">
<RunChangeSummaryCard
onDiscard={onDiscard}
onOpenCommitComposer={onOpenCommitComposer}
runId={run.requestId}
sessionId={sessionId}
summary={run.postRunGitSummary}
/>
</div>
)}
</div>
)}
</div>
@@ -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<unknown>;
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<string | undefined>(latestRunId);
@@ -394,9 +417,12 @@ export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) {
<RunCard
expanded={expandedRunId === run.id}
key={run.id}
onDiscard={onDiscard}
onJumpToMessage={onJumpToMessage}
onOpenCommitComposer={onOpenCommitComposer}
onToggle={() => setExpandedRunId(expandedRunId === run.id ? undefined : run.id)}
run={run}
sessionId={sessionId}
/>
))}
</div>
@@ -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 <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
}
if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') {
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
}
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
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 <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
}
/* ── 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 (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<div className="flex items-center gap-1 px-2.5 py-[5px] text-[10px]">
{/* Staging checkbox */}
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
isStaged
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={onToggle}
type="button"
aria-label={`${isStaged ? 'Unstage' : 'Stage'} ${file.path}`}
aria-pressed={isStaged}
>
{isStaged && <Check className="size-2" />}
</button>
{/* File path + expand */}
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:text-[var(--color-text-primary)]"
onClick={onPreview}
type="button"
aria-expanded={previewExpanded}
>
{hasPreview || previewExpanded ? (
<ChevronRight
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${previewExpanded ? 'rotate-90' : ''}`}
/>
) : (
<span className="w-2.5 shrink-0" />
)}
{fileIcon(file)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{statusLabel(file)}
</span>
</button>
</div>
{/* Diff preview */}
{previewExpanded && preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-52 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
{preview.diff
? preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
: preview.newFileContents
? preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: preview.isBinary
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
: <div className="text-[var(--color-text-muted)] italic">Loading</div>}
</pre>
</div>
)}
</div>
);
}
/* ── 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<ProjectGitDetails>();
const [loading, setLoading] = useState(true);
const [commitMessage, setCommitMessage] = useState('');
const [commitType, setCommitType] = useState<ProjectGitConventionalCommitType>('feat');
const [stagedPaths, setStagedPaths] = useState<Set<string>>(new Set());
const [previews, setPreviews] = useState<Record<string, ProjectGitDiffPreview>>({});
const [expandedPath, setExpandedPath] = useState<string>();
const [committing, setCommitting] = useState(false);
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const [commitError, setCommitError] = useState<string>();
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<string>();
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 (
<div
className="fixed inset-0 z-50 flex items-stretch justify-end"
role="dialog"
aria-modal="true"
aria-labelledby="commit-composer-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
onClick={onClose}
/>
{/* Panel */}
<div className="relative z-10 flex w-[420px] max-w-full flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
{/* Header */}
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-4 py-3">
<GitBranch className="size-4 text-[var(--color-accent-sky)]" />
<h2 id="commit-composer-title" className="font-display text-sm font-semibold text-[var(--color-text-primary)]">
Commit Changes
</h2>
{details?.context.branch && (
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
on {details.context.branch}
</span>
)}
<div className="flex-1" />
<button
className="rounded-md p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onClose}
type="button"
aria-label="Close commit composer"
>
<X className="size-4" />
</button>
</div>
{/* Loading */}
{loading && (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-5 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git details" />
</div>
)}
{/* Success state */}
{commitSuccess && (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6">
<div className="flex size-12 items-center justify-center rounded-full bg-[var(--color-status-success)]/10">
<Check className="size-6 text-[var(--color-status-success)]" />
</div>
<p className="text-sm font-medium text-[var(--color-status-success)]">
Changes committed{pushAfterCommit ? ' and pushed' : ''}
</p>
</div>
)}
{/* Content */}
{!loading && !commitSuccess && (
<>
{/* Commit message */}
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
{/* Type selector */}
<div className="mb-2 flex items-center gap-1">
{CONVENTIONAL_TYPES.map((ct) => (
<button
key={ct.value}
className={`rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors duration-100 ${
commitType === ct.value
? 'bg-[var(--color-accent)]/15 text-[var(--color-text-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setCommitType(ct.value)}
type="button"
>
{ct.label}
</button>
))}
<div className="flex-1" />
<button
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-accent)]"
onClick={() => void handleSuggestMessage()}
type="button"
title="Suggest commit message"
>
<Sparkles className="size-3" />
Suggest
</button>
</div>
<textarea
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface-0)] px-3 py-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
placeholder="Commit message…"
rows={3}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</div>
{/* File list */}
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Select all header */}
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-2.5 py-1.5">
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
stagedPaths.size === files.length && files.length > 0
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={toggleAll}
type="button"
aria-label={stagedPaths.size === files.length ? 'Unstage all' : 'Stage all'}
>
{stagedPaths.size === files.length && files.length > 0 && <Check className="size-2" />}
</button>
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">
{stagedPaths.size} of {files.length} staged
</span>
</div>
{files.length === 0 ? (
<p className="px-4 py-6 text-center text-[11px] text-[var(--color-text-muted)]">
Working tree is clean nothing to commit.
</p>
) : (
files.map((file) => (
<CommitFileRow
file={file}
isStaged={stagedPaths.has(file.path)}
key={file.path}
onPreview={() => void handlePreview(file)}
onToggle={() => toggleStaged(file.path)}
preview={previews[file.path]}
previewExpanded={expandedPath === file.path}
/>
))
)}
</div>
{/* Error */}
{commitError && (
<div className="border-t border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-4 py-2 text-[10px] text-[var(--color-status-error)]" role="alert">
{commitError}
</div>
)}
{/* Footer actions */}
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-4 py-3">
{/* Push toggle */}
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
<input
type="checkbox"
checked={pushAfterCommit}
onChange={(e) => setPushAfterCommit(e.target.checked)}
className="accent-[var(--color-accent)]"
/>
Push after commit
</label>
<div className="flex-1" />
<button
className="rounded-md px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-[11px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={committing || !commitMessage.trim() || stagedFiles.length === 0}
onClick={() => void handleCommit()}
type="button"
>
{committing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ArrowUpFromLine className="size-3" />
)}
{committing ? 'Committing…' : pushAfterCommit ? 'Commit & Push' : 'Commit'}
</button>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,420 @@
import { useCallback, useMemo, useState } from 'react';
import {
AlertTriangle,
ArrowDownToLine,
Check,
ChevronRight,
FileCode2,
FileMinus2,
FilePlus2,
GitBranch,
Loader2,
RotateCcw,
Trash2,
} from 'lucide-react';
import type {
ProjectGitFileReference,
ProjectGitRunChangedFile,
ProjectGitRunChangeSummary,
} 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 kindLabel(kind: ProjectGitRunChangedFile['kind']): string {
switch (kind) {
case 'added': return 'added';
case 'modified': return 'modified';
case 'deleted': return 'deleted';
case 'renamed': return 'renamed';
case 'copied': return 'copied';
case 'type-changed': return 'type changed';
case 'unmerged': return 'conflict';
case 'untracked': return 'new';
case 'cleaned': return 'cleaned';
}
}
function kindIcon(kind: ProjectGitRunChangedFile['kind']) {
switch (kind) {
case 'added':
case 'untracked':
case 'copied':
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
case 'deleted':
case 'cleaned':
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
default:
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
}
function originBadge(origin: ProjectGitRunChangedFile['origin']) {
if (origin === 'pre-existing') {
return (
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]">
pre-existing
</span>
);
}
return null;
}
/* ── Mini diff-stats bar ───────────────────────────────────── */
function DiffStatsBar({ additions, deletions }: { additions: number; deletions: number }) {
const total = additions + deletions;
if (total === 0) return null;
const blocks = 5;
const addBlocks = Math.max(additions > 0 ? 1 : 0, Math.round((additions / total) * blocks));
const delBlocks = blocks - addBlocks;
return (
<span className="inline-flex gap-px" aria-label={`${additions} additions, ${deletions} deletions`}>
{Array.from({ length: addBlocks }, (_, i) => (
<span key={`a${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-success)]" />
))}
{Array.from({ length: delBlocks }, (_, i) => (
<span key={`d${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-error)]" />
))}
</span>
);
}
/* ── 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 <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
}
/* ── Single file row ───────────────────────────────────────── */
function ChangedFileRow({
file,
isSelected,
onToggleSelect,
canSelect,
}: {
file: ProjectGitRunChangedFile;
isSelected: boolean;
onToggleSelect: () => void;
canSelect: boolean;
}) {
const [diffExpanded, setDiffExpanded] = useState(false);
const hasPreview = !!file.preview?.diff || !!file.preview?.newFileContents;
const dir = fileDir(file.path);
const base = fileBaseName(file.path);
return (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<div className="flex items-center gap-1 px-2 py-[5px] text-[10px]">
{/* Select checkbox */}
{canSelect && (
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
isSelected
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={onToggleSelect}
type="button"
aria-label={`${isSelected ? 'Deselect' : 'Select'} ${file.path}`}
aria-pressed={isSelected}
>
{isSelected && <Check className="size-2" />}
</button>
)}
{/* Expand diff button */}
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:bg-[var(--color-surface-3)]/40 disabled:cursor-default"
disabled={!hasPreview}
onClick={hasPreview ? () => setDiffExpanded(!diffExpanded) : undefined}
type="button"
aria-expanded={hasPreview ? diffExpanded : undefined}
>
{hasPreview ? (
<ChevronRight
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${diffExpanded ? 'rotate-90' : ''}`}
/>
) : (
<span className="w-2.5 shrink-0" />
)}
{kindIcon(file.kind)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{kindLabel(file.kind)}
</span>
{originBadge(file.origin)}
{(file.additions > 0 || file.deletions > 0) && (
<span className="flex items-center gap-1.5 shrink-0 font-mono">
{file.additions > 0 && <span className="text-[var(--color-status-success)]">+{file.additions}</span>}
{file.deletions > 0 && <span className="text-[var(--color-status-error)]">{file.deletions}</span>}
<DiffStatsBar additions={file.additions} deletions={file.deletions} />
</span>
)}
</button>
</div>
{/* Diff preview */}
{diffExpanded && file.preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-64 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
{file.preview.diff
? file.preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
: file.preview.newFileContents
? file.preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: file.preview.isBinary
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
: null}
</pre>
</div>
)}
</div>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface RunChangeSummaryCardProps {
summary: ProjectGitRunChangeSummary;
sessionId: string;
runId: string;
onDiscard: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
}
export function RunChangeSummaryCard({
summary,
sessionId,
runId,
onDiscard,
onOpenCommitComposer,
}: RunChangeSummaryCardProps) {
const [expanded, setExpanded] = useState(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [discarding, setDiscarding] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<'bulk' | 'selected' | undefined>();
const revertableFiles = useMemo(
() => summary.files.filter((file) => file.canRevert),
[summary.files],
);
const hasRevertable = revertableFiles.length > 0;
const toggleSelect = useCallback((path: string) => {
setSelectedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleDiscard = useCallback(async (mode: 'bulk' | 'selected') => {
setDiscarding(true);
try {
if (mode === 'selected') {
const files: ProjectGitFileReference[] = summary.files
.filter((f) => selectedPaths.has(f.path))
.map((f) => ({ path: f.path, previousPath: f.previousPath }));
await onDiscard(sessionId, runId, files);
setSelectedPaths(new Set());
} else {
await onDiscard(sessionId, runId);
}
} finally {
setDiscarding(false);
setConfirmDiscard(undefined);
}
}, [onDiscard, sessionId, runId, selectedPaths, summary.files]);
const selectedRevertableCount = useMemo(
() => revertableFiles.filter((f) => selectedPaths.has(f.path)).length,
[revertableFiles, selectedPaths],
);
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]/80">
{/* Header */}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
onClick={() => setExpanded(!expanded)}
type="button"
aria-expanded={expanded}
aria-label={`${summary.fileCount} files changed by this run`}
>
<ChevronRight
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
<GitBranch className="size-3 shrink-0 text-[var(--color-accent-sky)]" />
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
{summary.fileCount} {summary.fileCount === 1 ? 'file' : 'files'} changed
</span>
{(summary.additions > 0 || summary.deletions > 0) && (
<span className="flex items-center gap-1.5 font-mono text-[10px]">
{summary.additions > 0 && (
<span className="text-[var(--color-status-success)]">+{summary.additions}</span>
)}
{summary.deletions > 0 && (
<span className="text-[var(--color-status-error)]">{summary.deletions}</span>
)}
<DiffStatsBar additions={summary.additions} deletions={summary.deletions} />
</span>
)}
{summary.branchChanged && (
<span className="ml-auto flex items-center gap-1 text-[9px] text-[var(--color-status-warning)]">
<AlertTriangle className="size-2.5" />
branch changed
</span>
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-[var(--color-border-subtle)]">
{/* Branch info */}
{summary.branchChanged && summary.branchAtStart && summary.branchAtEnd && (
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5 text-[9px] text-[var(--color-text-muted)]">
<GitBranch className="size-2.5" />
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtStart}</span>
<span></span>
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtEnd}</span>
</div>
)}
{/* File list */}
<div>
{summary.files.map((file) => (
<ChangedFileRow
canSelect={hasRevertable && file.canRevert}
file={file}
isSelected={selectedPaths.has(file.path)}
key={file.path}
onToggleSelect={() => toggleSelect(file.path)}
/>
))}
</div>
{/* Actions */}
{(hasRevertable || onOpenCommitComposer) && (
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-3 py-2">
{/* Commit button */}
{onOpenCommitComposer && (
<button
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1 text-[10px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)]"
onClick={onOpenCommitComposer}
type="button"
>
<ArrowDownToLine className="size-3" />
Commit changes
</button>
)}
<div className="flex-1" />
{/* Discard actions */}
{hasRevertable && !confirmDiscard && (
<>
{selectedRevertableCount > 0 && (
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-status-error)] transition-colors duration-150 hover:bg-[var(--color-status-error)]/10"
disabled={discarding}
onClick={() => setConfirmDiscard('selected')}
type="button"
>
<Trash2 className="size-3" />
Discard {selectedRevertableCount} selected
</button>
)}
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-status-error)]"
disabled={discarding}
onClick={() => setConfirmDiscard('bulk')}
type="button"
>
<RotateCcw className="size-3" />
Discard all
</button>
</>
)}
{/* Confirmation */}
{confirmDiscard && (
<div className="flex items-center gap-1.5 rounded-md border border-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/5 px-2.5 py-1" role="alert">
<AlertTriangle className="size-3 text-[var(--color-status-error)]" />
<span className="text-[10px] text-[var(--color-status-error)]">
{confirmDiscard === 'bulk'
? `Revert all ${revertableFiles.length} files to pre-run state?`
: `Revert ${selectedRevertableCount} selected files?`}
</span>
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-status-error)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/15"
disabled={discarding}
onClick={() => void handleDiscard(confirmDiscard)}
type="button"
>
{discarding ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
Yes
</button>
<button
className="rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={() => setConfirmDiscard(undefined)}
type="button"
>
Cancel
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}