Files
Codewalkers/apps/web/src/components/ErrandDetailPanel.tsx
Lukas May 09e4e3d4f0 feat: add ErrandDetailPanel slide-over component
Implements the errand detail panel with three context-aware views:
- Active: live agent output via AgentOutputViewer, chat input, Mark Done/Abandon
- Pending review/conflict: diff block, conflict notice with file list, Merge/Delete/Abandon
- Merged/abandoned: read-only diff, info line with relative date, Delete only

Follows TaskSlideOver.tsx patterns: Framer Motion slide-in, backdrop, Escape key close.
Shift+click skips window.confirm on all destructive actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:23:49 +01:00

380 lines
14 KiB
TypeScript

import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusBadge } from '@/components/StatusBadge';
import { AgentOutputViewer } from '@/components/AgentOutputViewer';
import { trpc } from '@/lib/trpc';
import { formatRelativeTime } from '@/lib/utils';
import { toast } from 'sonner';
interface ErrandDetailPanelProps {
errandId: string;
onClose: () => void;
}
export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) {
const [message, setMessage] = useState('');
const errandQuery = trpc.errand.get.useQuery({ id: errandId });
const errand = errandQuery.data;
const diffQuery = trpc.errand.diff.useQuery(
{ id: errandId },
{ enabled: errand?.status !== 'active' },
);
const utils = trpc.useUtils();
const completeMutation = trpc.errand.complete.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
errandQuery.refetch();
},
});
const mergeMutation = trpc.errand.merge.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
toast.success(`Merged into ${errand?.baseBranch ?? 'base'}`);
onClose();
},
onError: () => {
errandQuery.refetch();
},
});
const deleteMutation = trpc.errand.delete.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
onClose();
},
});
const abandonMutation = trpc.errand.abandon.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
errandQuery.refetch();
},
});
const sendMutation = trpc.errand.sendMessage.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
setMessage('');
},
});
// Escape key closes
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
const chatDisabled = errand?.status !== 'active' || sendMutation.isPending;
return (
<AnimatePresence>
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-40 bg-background/60 backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-2xl flex-col border-l border-border bg-background shadow-xl"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0, 0, 0.2, 1] }}
>
{/* Loading state */}
{errandQuery.isLoading && (
<>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<span className="text-sm text-muted-foreground">Loading</span>
<button
onClick={onClose}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading errand</p>
</div>
</>
)}
{/* Error state */}
{errandQuery.error && (
<>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<span className="text-sm text-muted-foreground">Error</span>
<button
onClick={onClose}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Failed to load errand.</p>
<Button size="sm" variant="outline" onClick={() => errandQuery.refetch()}>
Retry
</Button>
</div>
</>
)}
{/* Loaded state */}
{errand && (
<>
{/* Header */}
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold leading-snug truncate">
{errand.description}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground font-mono">
{errand.branch}
</p>
</div>
<StatusBadge status={errand.status} />
{errand.agentAlias && (
<span className="text-xs text-muted-foreground shrink-0">
{errand.agentAlias}
</span>
)}
<button
onClick={onClose}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* View: Active */}
{errand.status === 'active' && (
<>
<div className="flex-1 overflow-y-auto">
{errand.agentId && (
<AgentOutputViewer
agentId={errand.agentId}
agentName={errand.agentAlias ?? undefined}
status={undefined}
/>
)}
</div>
{/* Chat input */}
<div className="border-t border-border px-5 py-3">
<form
onSubmit={(e) => {
e.preventDefault();
if (!message.trim()) return;
sendMutation.mutate({ id: errandId, message });
}}
>
<div className="flex gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Send a message to the agent…"
disabled={chatDisabled}
title={
chatDisabled && errand.status !== 'active'
? 'Agent is not running'
: undefined
}
/>
<Button
type="submit"
size="sm"
disabled={chatDisabled || !message.trim()}
>
Send
</Button>
</div>
</form>
</div>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
<Button
variant="outline"
size="sm"
disabled={completeMutation.isPending}
onClick={() => completeMutation.mutate({ id: errandId })}
>
Mark Done
</Button>
<Button
variant="destructive"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Abandon this errand? The record will be kept for reference but the branch and worktree will be removed.',
)
) {
abandonMutation.mutate({ id: errandId });
}
}}
>
Abandon
</Button>
</div>
</>
)}
{/* View: Pending Review / Conflict */}
{(errand.status === 'pending_review' || errand.status === 'conflict') && (
<>
<div className="flex flex-col flex-1 overflow-hidden">
{/* Conflict notice */}
{errand.status === 'conflict' &&
(errand.conflictFiles?.length ?? 0) > 0 && (
<div className="mx-5 mt-4 rounded-md border border-destructive bg-destructive/10 px-4 py-3 text-sm text-destructive">
Merge conflict in {errand.conflictFiles!.length} file(s):{' '}
{errand.conflictFiles!.join(', ')} resolve manually in the
worktree then re-merge.
</div>
)}
{/* Diff block */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{diffQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading diff</p>
) : diffQuery.data?.diff ? (
<pre className="overflow-x-auto rounded border border-border bg-muted/50 p-4 text-xs font-mono whitespace-pre">
{diffQuery.data.diff}
</pre>
) : (
<p className="text-sm text-muted-foreground">
No changes branch has no commits.
</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-5 py-3">
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Delete this errand? Branch and worktree will be removed and the record deleted.',
)
) {
deleteMutation.mutate({ id: errandId });
}
}}
>
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Abandon this errand? The record will be kept for reference but the branch and worktree will be removed.',
)
) {
abandonMutation.mutate({ id: errandId });
}
}}
>
Abandon
</Button>
</div>
<Button
size="sm"
disabled={mergeMutation.isPending}
onClick={(e) => {
const target = errand.baseBranch;
if (
e.shiftKey ||
window.confirm(`Merge this errand into ${target}?`)
) {
mergeMutation.mutate({ id: errandId });
}
}}
>
{mergeMutation.isPending ? 'Merging…' : 'Merge'}
</Button>
</div>
</>
)}
{/* View: Merged / Abandoned */}
{(errand.status === 'merged' || errand.status === 'abandoned') && (
<>
<div className="flex flex-col flex-1 overflow-hidden">
{/* Info line */}
<div className="px-5 pt-4 text-sm text-muted-foreground">
{errand.status === 'merged'
? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}`
: `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`}
</div>
{/* Read-only diff */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{diffQuery.data?.diff ? (
<pre className="overflow-x-auto rounded border border-border bg-muted/50 p-4 text-xs font-mono whitespace-pre">
{diffQuery.data.diff}
</pre>
) : (
<p className="text-sm text-muted-foreground">
No changes branch has no commits.
</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center border-t border-border px-5 py-3">
<Button
variant="destructive"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Delete this errand? Branch and worktree will be removed and the record deleted.',
)
) {
deleteMutation.mutate({ id: errandId });
}
}}
>
Delete
</Button>
</div>
</>
)}
</>
)}
</motion.div>
</>
</AnimatePresence>
);
}