feat: add Errands nav item, /errands route, and CreateErrandDialog
- AppLayout: add Errands nav entry with pending_review badge count - /errands route: list table with ID, description, branch, status, agent, created columns; empty state with CLI hint; slide-over integration - CreateErrandDialog: description (max 200 chars with counter), project select, optional base branch; no optimistic UI due to agent spawn latency - ErrandDetailPanel: checkout from completed dependency commit (4j3ZfR_ZX_4rw7j9uj6DV) TypeScript compiles clean. Route uses TanStack Router file-based routing; routeTree.gen.ts auto-regenerated on build. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
142
apps/web/src/components/CreateErrandDialog.tsx
Normal file
142
apps/web/src/components/CreateErrandDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
interface CreateErrandDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateErrandDialog({ open, onOpenChange }: CreateErrandDialogProps) {
|
||||
const [description, setDescription] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [baseBranch, setBaseBranch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const projectsQuery = trpc.listProjects.useQuery();
|
||||
|
||||
const createMutation = trpc.errand.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success('Errand started');
|
||||
onOpenChange(false);
|
||||
utils.errand.list.invalidate();
|
||||
navigate({ to: '/errands', search: { selected: data.id } });
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDescription('');
|
||||
setProjectId('');
|
||||
setBaseBranch('');
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
createMutation.mutate({
|
||||
description: description.trim(),
|
||||
projectId,
|
||||
baseBranch: baseBranch.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
description.trim().length > 0 &&
|
||||
description.length <= 200 &&
|
||||
projectId !== '' &&
|
||||
!createMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Errand</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a small isolated change with a dedicated agent.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="errand-description">Description</Label>
|
||||
<Textarea
|
||||
id="errand-description"
|
||||
placeholder="Describe the small change to make…"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs text-right',
|
||||
description.length >= 190 ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{description.length} / 200
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="errand-project">Project</Label>
|
||||
<select
|
||||
id="errand-project"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="">Select a project…</option>
|
||||
{(projectsQuery.data ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="errand-base">
|
||||
Base Branch{' '}
|
||||
<span className="text-muted-foreground font-normal">(optional — defaults to main)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="errand-base"
|
||||
placeholder="main"
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{createMutation.isPending ? 'Starting…' : 'New Errand'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
379
apps/web/src/components/ErrandDetailPanel.tsx
Normal file
379
apps/web/src/components/ErrandDetailPanel.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import type { ConnectionState } from '@/hooks/useConnectionStatus'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
|
||||
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
||||
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
|
||||
{ label: 'Settings', to: '/settings', badgeKey: null },
|
||||
{ label: 'Errands', to: '/errands', badgeKey: 'pendingErrands' as const },
|
||||
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
||||
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
|
||||
{ label: 'Settings', to: '/settings', badgeKey: null },
|
||||
] as const
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -24,9 +25,12 @@ export function AppLayout({ children, onOpenCommandPalette, connectionState }: A
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
const errandsData = trpc.errand.list.useQuery()
|
||||
|
||||
const badgeCounts = {
|
||||
running: agents.data?.filter((a) => a.status === 'running').length ?? 0,
|
||||
questions: agents.data?.filter((a) => a.status === 'waiting_for_input').length ?? 0,
|
||||
running: agents.data?.filter((a) => a.status === 'running').length ?? 0,
|
||||
questions: agents.data?.filter((a) => a.status === 'waiting_for_input').length ?? 0,
|
||||
pendingErrands: errandsData.data?.filter((e) => e.status === 'pending_review').length ?? 0,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
130
apps/web/src/routes/errands/index.tsx
Normal file
130
apps/web/src/routes/errands/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
import { CreateErrandDialog } from '@/components/CreateErrandDialog';
|
||||
import { ErrandDetailPanel } from '@/components/ErrandDetailPanel';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export const Route = createFileRoute('/errands/')({
|
||||
component: ErrandsPage,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
selected: typeof search.selected === 'string' ? search.selected : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function ErrandsPage() {
|
||||
const { selected } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [selectedErrandId, setSelectedErrandId] = useState<string | null>(selected ?? null);
|
||||
|
||||
const errandsQuery = trpc.errand.list.useQuery();
|
||||
const errands = errandsQuery.data ?? [];
|
||||
|
||||
function selectErrand(id: string | null) {
|
||||
setSelectedErrandId(id);
|
||||
navigate({
|
||||
to: '/errands',
|
||||
search: id ? { selected: id } : {},
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
|
||||
className="mx-auto max-w-6xl space-y-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl font-semibold">Errands</h1>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
New Errand
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List / Empty state */}
|
||||
{errands.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No errands yet. Click{' '}
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
New Errand
|
||||
</button>{' '}
|
||||
or run:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">
|
||||
cw errand start "<description>" --project <id>
|
||||
</code>
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">ID</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Description</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Branch</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Agent</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{errands.map((e) => (
|
||||
<tr
|
||||
key={e.id}
|
||||
className="cursor-pointer hover:bg-muted/40 transition-colors"
|
||||
onClick={() => selectErrand(e.id)}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{e.id.slice(0, 8)}
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[280px] truncate">
|
||||
{e.description.length > 60
|
||||
? e.description.slice(0, 57) + '…'
|
||||
: e.description}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[180px]">
|
||||
{e.branch}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={e.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{e.agentAlias ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
|
||||
{formatRelativeTime(e.createdAt.toISOString())}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<CreateErrandDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Detail slide-over */}
|
||||
{selectedErrandId && (
|
||||
<ErrandDetailPanel
|
||||
errandId={selectedErrandId}
|
||||
onClose={() => selectErrand(null)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user