feat: errand review & request changes
Add errand.requestChanges procedure that re-spawns an agent in the existing worktree with user feedback. Replace raw <pre> diff blocks with syntax-highlighted ErrandDiffView using FileCard components. Add Output/Changes tabs to the active errand view.
This commit is contained in:
@@ -14,3 +14,24 @@ If you cannot complete the change:
|
||||
|
||||
Do not create any other output files.`;
|
||||
}
|
||||
|
||||
export function buildErrandRevisionPrompt(description: string, feedback: string): string {
|
||||
return `You are revising a previous change in an isolated worktree. The worktree already contains your prior work.
|
||||
|
||||
Original description: ${description}
|
||||
|
||||
The user reviewed your changes and requested revisions:
|
||||
|
||||
${feedback}
|
||||
|
||||
Make only the changes needed to address the feedback. Do not undo prior work unless the feedback specifically asks for it.
|
||||
When you are done, write .cw/output/signal.json:
|
||||
|
||||
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
|
||||
|
||||
If you cannot complete the change:
|
||||
|
||||
{ "status": "error", "error": "<explanation>" }
|
||||
|
||||
Do not create any other output files.`;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export { buildDetailPrompt } from './detail.js';
|
||||
export { buildRefinePrompt } from './refine.js';
|
||||
export { buildChatPrompt } from './chat.js';
|
||||
export type { ChatHistoryEntry } from './chat.js';
|
||||
export { buildErrandPrompt } from './errand.js';
|
||||
export { buildErrandPrompt, buildErrandRevisionPrompt } from './errand.js';
|
||||
export { buildWorkspaceLayout } from './workspace.js';
|
||||
export { buildPreviewInstructions } from './preview.js';
|
||||
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Errand Router
|
||||
*
|
||||
* All 9 errand procedures: create, list, get, diff, complete, merge, delete, sendMessage, abandon.
|
||||
* All 10 errand procedures: create, list, get, diff, complete, merge, delete, sendMessage, abandon, requestChanges.
|
||||
* Errands are small isolated changes that spawn a dedicated agent in a git worktree.
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
requireBranchManager,
|
||||
} from './_helpers.js';
|
||||
import { writeErrandManifest } from '../../agent/file-io.js';
|
||||
import { buildErrandPrompt } from '../../agent/prompts/index.js';
|
||||
import { buildErrandPrompt, buildErrandRevisionPrompt } from '../../agent/prompts/index.js';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { SimpleGitWorktreeManager } from '../../git/manager.js';
|
||||
import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
@@ -441,6 +442,96 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
const updated = await repo.update(input.id, { status: 'abandoned' });
|
||||
return updated;
|
||||
}),
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// errand.requestChanges
|
||||
// -----------------------------------------------------------------------
|
||||
requestChanges: publicProcedure
|
||||
.input(z.object({
|
||||
id: z.string().min(1),
|
||||
feedback: z.string().min(1),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireErrandRepository(ctx);
|
||||
const errand = await repo.findById(input.id);
|
||||
if (!errand) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
|
||||
}
|
||||
|
||||
if (errand.status !== 'pending_review' && errand.status !== 'conflict') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Cannot request changes on an errand with status '${errand.status}'`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!errand.projectId) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' });
|
||||
}
|
||||
const project = await requireProjectRepository(ctx).findById(errand.projectId);
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' });
|
||||
}
|
||||
|
||||
// Resolve clone path and verify worktree still exists
|
||||
const clonePath = await resolveClonePath(project, ctx);
|
||||
const worktreeManager = new SimpleGitWorktreeManager(clonePath);
|
||||
let worktree;
|
||||
try {
|
||||
worktree = await worktreeManager.get(errand.id);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Worktree no longer exists — cannot request changes. Delete and re-create the errand.',
|
||||
});
|
||||
}
|
||||
if (!worktree) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Worktree no longer exists — cannot request changes. Delete and re-create the errand.',
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up stale signal.json to prevent false completion detection
|
||||
const signalPath = join(worktree.path, '.cw', 'output', 'signal.json');
|
||||
if (existsSync(signalPath)) {
|
||||
rmSync(signalPath);
|
||||
}
|
||||
|
||||
// Build revision prompt and spawn new agent in existing worktree
|
||||
const prompt = buildErrandRevisionPrompt(errand.description, input.feedback);
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
let agent;
|
||||
try {
|
||||
agent = await agentManager.spawn({
|
||||
prompt,
|
||||
mode: 'errand',
|
||||
cwd: worktree.path,
|
||||
provider: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
// Update manifest files
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: worktree.path,
|
||||
errandId: errand.id,
|
||||
description: errand.description,
|
||||
branch: errand.branch,
|
||||
projectName: project.name,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
});
|
||||
|
||||
// Transition back to active with new agent
|
||||
await repo.update(errand.id, { status: 'active', agentId: agent.id });
|
||||
|
||||
return { id: errand.id, agentId: agent.id };
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
import { X, RefreshCw } 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 { ErrandDiffView } from '@/components/ErrandDiffView';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type ActiveTab = 'output' | 'changes';
|
||||
|
||||
interface ErrandDetailPanelProps {
|
||||
errandId: string;
|
||||
onClose: () => void;
|
||||
@@ -16,13 +19,15 @@ interface ErrandDetailPanelProps {
|
||||
|
||||
export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('output');
|
||||
const [feedback, setFeedback] = 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' },
|
||||
{ enabled: !!errand },
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
@@ -66,6 +71,16 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
||||
},
|
||||
});
|
||||
|
||||
const requestChangesMutation = trpc.errand.requestChanges.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.errand.list.invalidate();
|
||||
errandQuery.refetch();
|
||||
setFeedback('');
|
||||
setActiveTab('output');
|
||||
toast.success('Agent re-spawned with your feedback');
|
||||
},
|
||||
});
|
||||
|
||||
// Escape key closes
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -167,13 +182,62 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
||||
{/* View: Active */}
|
||||
{errand.status === 'active' && (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-0 border-b border-border px-5">
|
||||
<button
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'output'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => setActiveTab('output')}
|
||||
>
|
||||
Output
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'changes'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => setActiveTab('changes')}
|
||||
>
|
||||
Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{errand.agentId && (
|
||||
{activeTab === 'output' ? (
|
||||
errand.agentId ? (
|
||||
<AgentOutputViewer
|
||||
agentId={errand.agentId}
|
||||
agentName={errand.agentAlias ?? undefined}
|
||||
status={undefined}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-muted-foreground">Committed changes</span>
|
||||
<button
|
||||
onClick={() => diffQuery.refetch()}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Refresh diff"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${diffQuery.isFetching ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{diffQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading diff…</p>
|
||||
) : diffQuery.data?.diff ? (
|
||||
<ErrandDiffView diff={diffQuery.data.diff} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No changes yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -258,9 +322,7 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
||||
{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>
|
||||
<ErrandDiffView diff={diffQuery.data.diff} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No changes — branch has no commits.
|
||||
@@ -269,6 +331,27 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request changes input */}
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="Describe what needs to change…"
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!feedback.trim() || requestChangesMutation.isPending}
|
||||
onClick={() => requestChangesMutation.mutate({ id: errandId, feedback })}
|
||||
>
|
||||
{requestChangesMutation.isPending ? 'Sending…' : 'Request Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
||||
<div className="flex gap-2">
|
||||
@@ -338,9 +421,7 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
||||
{/* 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>
|
||||
<ErrandDiffView diff={diffQuery.data.diff} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No changes — branch has no commits.
|
||||
|
||||
68
apps/web/src/components/ErrandDiffView.tsx
Normal file
68
apps/web/src/components/ErrandDiffView.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Plus, Minus } from 'lucide-react';
|
||||
import { parseUnifiedDiff } from '@/components/review/parse-diff';
|
||||
import { FileCard } from '@/components/review/FileCard';
|
||||
import type { ReviewComment, DiffLine } from '@/components/review/types';
|
||||
|
||||
const emptyComments = new Map<string, ReviewComment[]>();
|
||||
const noop = () => {};
|
||||
|
||||
interface ErrandDiffViewProps {
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export function ErrandDiffView({ diff }: ErrandDiffViewProps) {
|
||||
const files = useMemo(() => parseUnifiedDiff(diff), [diff]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const f of files) {
|
||||
additions += f.additions;
|
||||
deletions += f.deletions;
|
||||
}
|
||||
return { additions, deletions };
|
||||
}, [files]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">No changes — branch has no commits.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{files.length} file{files.length !== 1 ? 's' : ''} changed</span>
|
||||
{totals.additions > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-diff-add-fg">
|
||||
<Plus className="h-3 w-3" />
|
||||
{totals.additions}
|
||||
</span>
|
||||
)}
|
||||
{totals.deletions > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-diff-remove-fg">
|
||||
<Minus className="h-3 w-3" />
|
||||
{totals.deletions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File cards */}
|
||||
{files.map((file) => (
|
||||
<FileCard
|
||||
key={file.newPath}
|
||||
file={file}
|
||||
detail={file}
|
||||
phaseId=""
|
||||
commitMode={true}
|
||||
commentsByLine={emptyComments}
|
||||
onAddComment={noop as (filePath: string, lineNumber: number, lineType: DiffLine['type'], body: string) => void}
|
||||
onResolveComment={noop}
|
||||
onUnresolveComment={noop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user