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
+2
View File
@@ -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:
+30
View File
@@ -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 = (
<ActivityPanel
activity={activityForSession}
onDiscard={handleDiscardRunChanges}
onJumpToMessage={jumpToMessage}
onOpenCommitComposer={handleOpenCommitComposer}
pattern={patternForSession}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
@@ -887,6 +908,15 @@ export default function App() {
}}
/>
)}
{commitComposerCtx && (
<CommitComposer
onClose={() => setCommitComposerCtx(undefined)}
projectId={commitComposerCtx.projectId}
runId={commitComposerCtx.runId}
sessionId={commitComposerCtx.sessionId}
/>
)}
</>
);
}
+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>
);
}