Phase 3 of v2.0. React + Vite SPA at apps/coder/web/ served by the coder Fastify server via @fastify/static with SPA fallback. Chat pane: message list via WS streaming (useSessionStream hook), input bar, POST /api/sessions/:id/messages on submit, markdown rendering via react-markdown + remark-gfm, inline tool-call display. Diff pane: fetches GET /api/sessions/:id/pending, shows pending changes with file path + operation badge (create/edit/delete), before/after diff for edits, Approve/Reject per change and Approve All/Reject All buttons. Layout: fixed two-pane split (chat 60%, diff 40%). Dark theme (bg-zinc-900). Desktop-first for v2.0.0. Session picker (Home page): lists projects and sessions from the shared DB. No CRUD — use BooChat's UI for that. Dockerfile updated: builds web app in builder stage, copies dist to runtime. index.ts registers fastifyStatic + SPA fallback route. Tailwind v4, React 18, TypeScript strict. ~20 new files, ~370KB built output. Functional developer tool UI, not polished consumer product — Phase 7 (v2.0.3) handles polish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Check, X, RotateCcw, FileText, FilePlus, Trash2, RefreshCw } from 'lucide-react';
|
|
import type { PendingChange } from '@/api/types';
|
|
import { api } from '@/api/client';
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
|
}
|
|
|
|
export function DiffPane({ sessionId, onPendingChange }: Props) {
|
|
const [changes, setChanges] = useState<PendingChange[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
const fetchPending = useCallback(async () => {
|
|
try {
|
|
const result = await api.pending.list(sessionId);
|
|
setChanges(result);
|
|
} catch (err) {
|
|
console.error('fetch pending failed:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
fetchPending();
|
|
}, [fetchPending]);
|
|
|
|
// Listen for WS pending change events
|
|
useEffect(() => {
|
|
const unsub = onPendingChange((change) => {
|
|
setChanges((prev) => {
|
|
const idx = prev.findIndex((c) => c.id === change.id);
|
|
if (idx >= 0) {
|
|
const next = [...prev];
|
|
next[idx] = change;
|
|
return next;
|
|
}
|
|
return [...prev, change];
|
|
});
|
|
});
|
|
return unsub;
|
|
}, [onPendingChange]);
|
|
|
|
const pendingChanges = changes.filter((c) => c.status === 'pending');
|
|
const resolvedChanges = changes.filter((c) => c.status !== 'pending');
|
|
|
|
const handleApplyOne = async (id: string) => {
|
|
try {
|
|
await api.pending.applyOne(id);
|
|
setChanges((prev) =>
|
|
prev.map((c) => (c.id === id ? { ...c, status: 'applied' as const } : c)),
|
|
);
|
|
} catch (err) {
|
|
console.error('apply failed:', err);
|
|
}
|
|
};
|
|
|
|
const handleRejectOne = async (id: string) => {
|
|
try {
|
|
await api.pending.rejectOne(id);
|
|
setChanges((prev) =>
|
|
prev.map((c) => (c.id === id ? { ...c, status: 'rejected' as const } : c)),
|
|
);
|
|
} catch (err) {
|
|
console.error('reject failed:', err);
|
|
}
|
|
};
|
|
|
|
const handleRewindOne = async (id: string) => {
|
|
try {
|
|
await api.pending.rewindOne(id);
|
|
setChanges((prev) =>
|
|
prev.map((c) => (c.id === id ? { ...c, status: 'reverted' as const } : c)),
|
|
);
|
|
} catch (err) {
|
|
console.error('rewind failed:', err);
|
|
}
|
|
};
|
|
|
|
const handleApplyAll = async () => {
|
|
try {
|
|
const result = await api.pending.applyAll(sessionId);
|
|
const appliedIds = new Set(
|
|
result.results.filter((r) => r.success).map((r) => r.id),
|
|
);
|
|
setChanges((prev) =>
|
|
prev.map((c) =>
|
|
appliedIds.has(c.id) ? { ...c, status: 'applied' as const } : c,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
console.error('apply all failed:', err);
|
|
}
|
|
};
|
|
|
|
const handleRejectAll = async () => {
|
|
// Reject each pending change individually (no batch reject endpoint)
|
|
for (const c of pendingChanges) {
|
|
await handleRejectOne(c.id);
|
|
}
|
|
};
|
|
|
|
const OpIcon = ({ op }: { op: PendingChange['operation'] }) => {
|
|
switch (op) {
|
|
case 'create':
|
|
return <FilePlus size={14} className="text-green-400" />;
|
|
case 'edit':
|
|
return <FileText size={14} className="text-blue-400" />;
|
|
case 'delete':
|
|
return <Trash2 size={14} className="text-red-400" />;
|
|
}
|
|
};
|
|
|
|
const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
|
|
const colors: Record<PendingChange['status'], string> = {
|
|
pending: 'bg-yellow-500/20 text-yellow-400',
|
|
applied: 'bg-green-500/20 text-green-400',
|
|
rejected: 'bg-zinc-500/20 text-zinc-400',
|
|
reverted: 'bg-orange-500/20 text-orange-400',
|
|
};
|
|
return (
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colors[status]}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
|
<h2 className="text-sm font-medium text-zinc-300">
|
|
Pending Changes
|
|
{pendingChanges.length > 0 && (
|
|
<span className="ml-1.5 text-xs text-zinc-500">
|
|
({pendingChanges.length})
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={fetchPending}
|
|
className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200"
|
|
title="Refresh"
|
|
>
|
|
<RefreshCw size={14} />
|
|
</button>
|
|
{pendingChanges.length > 0 && (
|
|
<>
|
|
<button
|
|
onClick={handleApplyAll}
|
|
className="text-xs px-2 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white"
|
|
>
|
|
Apply All
|
|
</button>
|
|
<button
|
|
onClick={handleRejectAll}
|
|
className="text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300"
|
|
>
|
|
Reject All
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Changes list */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading && (
|
|
<div className="text-center text-zinc-500 text-sm py-8">Loading...</div>
|
|
)}
|
|
|
|
{!loading && changes.length === 0 && (
|
|
<div className="text-center text-zinc-500 text-sm py-8">
|
|
No pending changes yet.
|
|
</div>
|
|
)}
|
|
|
|
{/* Pending changes first */}
|
|
{pendingChanges.map((change) => (
|
|
<ChangeItem
|
|
key={change.id}
|
|
change={change}
|
|
expanded={expandedId === change.id}
|
|
onToggle={() =>
|
|
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
|
}
|
|
onApply={() => handleApplyOne(change.id)}
|
|
onReject={() => handleRejectOne(change.id)}
|
|
OpIcon={OpIcon}
|
|
StatusBadge={StatusBadge}
|
|
/>
|
|
))}
|
|
|
|
{/* Resolved changes */}
|
|
{resolvedChanges.length > 0 && pendingChanges.length > 0 && (
|
|
<div className="border-t border-zinc-800 my-1" />
|
|
)}
|
|
{resolvedChanges.map((change) => (
|
|
<ChangeItem
|
|
key={change.id}
|
|
change={change}
|
|
expanded={expandedId === change.id}
|
|
onToggle={() =>
|
|
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
|
}
|
|
onRewind={
|
|
change.status === 'applied'
|
|
? () => handleRewindOne(change.id)
|
|
: undefined
|
|
}
|
|
OpIcon={OpIcon}
|
|
StatusBadge={StatusBadge}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ChangeItemProps {
|
|
change: PendingChange;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
onApply?: () => void;
|
|
onReject?: () => void;
|
|
onRewind?: () => void;
|
|
OpIcon: React.ComponentType<{ op: PendingChange['operation'] }>;
|
|
StatusBadge: React.ComponentType<{ status: PendingChange['status'] }>;
|
|
}
|
|
|
|
function ChangeItem({
|
|
change,
|
|
expanded,
|
|
onToggle,
|
|
onApply,
|
|
onReject,
|
|
onRewind,
|
|
OpIcon,
|
|
StatusBadge,
|
|
}: ChangeItemProps) {
|
|
const fileName = change.file_path.split('/').pop() || change.file_path;
|
|
const dirPath = change.file_path.split('/').slice(0, -1).join('/');
|
|
|
|
return (
|
|
<div className="border-b border-zinc-800/50">
|
|
<div
|
|
className="flex items-center gap-2 px-4 py-2 hover:bg-zinc-800/50 cursor-pointer"
|
|
onClick={onToggle}
|
|
>
|
|
<OpIcon op={change.operation} />
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm font-mono text-zinc-200 truncate block">
|
|
{fileName}
|
|
</span>
|
|
{dirPath && (
|
|
<span className="text-[11px] text-zinc-500 truncate block">
|
|
{dirPath}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<StatusBadge status={change.status} />
|
|
{change.status === 'pending' && (
|
|
<div className="flex items-center gap-1 ml-1">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onApply?.();
|
|
}}
|
|
className="p-1 rounded hover:bg-green-600/30 text-green-400"
|
|
title="Apply"
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onReject?.();
|
|
}}
|
|
className="p-1 rounded hover:bg-red-600/30 text-red-400"
|
|
title="Reject"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{change.status === 'applied' && onRewind && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRewind();
|
|
}}
|
|
className="p-1 rounded hover:bg-orange-600/30 text-orange-400"
|
|
title="Rewind"
|
|
>
|
|
<RotateCcw size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="px-4 pb-3">
|
|
{change.operation === 'edit' && (
|
|
<div className="space-y-2">
|
|
{change.old_string && (
|
|
<div className="rounded bg-red-950/30 border border-red-900/30 p-2">
|
|
<div className="text-[10px] text-red-400 mb-1 font-medium">
|
|
Remove
|
|
</div>
|
|
<pre className="text-xs text-red-200 whitespace-pre-wrap break-all font-mono">
|
|
{change.old_string}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{change.new_string && (
|
|
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
|
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
|
Add
|
|
</div>
|
|
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono">
|
|
{change.new_string}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{change.operation === 'create' && change.content && (
|
|
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
|
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
|
New file
|
|
</div>
|
|
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono max-h-60 overflow-y-auto">
|
|
{change.content.length > 2000
|
|
? change.content.slice(0, 2000) + '\n... (truncated)'
|
|
: change.content}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{change.operation === 'delete' && (
|
|
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
|
|
This file will be deleted.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|