Merge branch 'cw/small-change-flow-task-xkTnL_zsa-dgZP4H4txnl' into cw-merge-1772810891213

This commit is contained in:
Lukas May
2026-03-06 16:28:11 +01:00
3 changed files with 281 additions and 5 deletions

View 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>
);
}

View File

@@ -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 (

View 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 "&lt;description&gt;" --project &lt;id&gt;
</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>
);
}