diff --git a/apps/web/src/components/ErrandDetailPanel.tsx b/apps/web/src/components/ErrandDetailPanel.tsx new file mode 100644 index 0000000..04182a8 --- /dev/null +++ b/apps/web/src/components/ErrandDetailPanel.tsx @@ -0,0 +1,379 @@ +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 ( + + <> + {/* Backdrop */} + + + {/* Panel */} + + {/* Loading state */} + {errandQuery.isLoading && ( + <> +
+ Loading… + +
+
+

Loading errand…

+
+ + )} + + {/* Error state */} + {errandQuery.error && ( + <> +
+ Error + +
+
+

Failed to load errand.

+ +
+ + )} + + {/* Loaded state */} + {errand && ( + <> + {/* Header */} +
+
+

+ {errand.description} +

+

+ {errand.branch} +

+
+ + {errand.agentAlias && ( + + {errand.agentAlias} + + )} + +
+ + {/* View: Active */} + {errand.status === 'active' && ( + <> +
+ {errand.agentId && ( + + )} +
+ + {/* Chat input */} +
+
{ + e.preventDefault(); + if (!message.trim()) return; + sendMutation.mutate({ id: errandId, message }); + }} + > +
+ setMessage(e.target.value)} + placeholder="Send a message to the agent…" + disabled={chatDisabled} + title={ + chatDisabled && errand.status !== 'active' + ? 'Agent is not running' + : undefined + } + /> + +
+
+
+ + {/* Footer */} +
+ + +
+ + )} + + {/* View: Pending Review / Conflict */} + {(errand.status === 'pending_review' || errand.status === 'conflict') && ( + <> +
+ {/* Conflict notice */} + {errand.status === 'conflict' && + (errand.conflictFiles?.length ?? 0) > 0 && ( +
+ Merge conflict in {errand.conflictFiles!.length} file(s):{' '} + {errand.conflictFiles!.join(', ')} — resolve manually in the + worktree then re-merge. +
+ )} + + {/* Diff block */} +
+ {diffQuery.isLoading ? ( +

Loading diff…

+ ) : diffQuery.data?.diff ? ( +
+                          {diffQuery.data.diff}
+                        
+ ) : ( +

+ No changes — branch has no commits. +

+ )} +
+
+ + {/* Footer */} +
+
+ + +
+ +
+ + )} + + {/* View: Merged / Abandoned */} + {(errand.status === 'merged' || errand.status === 'abandoned') && ( + <> +
+ {/* Info line */} +
+ {errand.status === 'merged' + ? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}` + : `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`} +
+ + {/* Read-only diff */} +
+ {diffQuery.data?.diff ? ( +
+                          {diffQuery.data.diff}
+                        
+ ) : ( +

+ No changes — branch has no commits. +

+ )} +
+
+ + {/* Footer */} +
+ +
+ + )} + + )} +
+ +
+ ); +}