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.`;
|
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 { buildRefinePrompt } from './refine.js';
|
||||||
export { buildChatPrompt } from './chat.js';
|
export { buildChatPrompt } from './chat.js';
|
||||||
export type { ChatHistoryEntry } 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 { buildWorkspaceLayout } from './workspace.js';
|
||||||
export { buildPreviewInstructions } from './preview.js';
|
export { buildPreviewInstructions } from './preview.js';
|
||||||
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Errand Router
|
* 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.
|
* Errands are small isolated changes that spawn a dedicated agent in a git worktree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
requireBranchManager,
|
requireBranchManager,
|
||||||
} from './_helpers.js';
|
} from './_helpers.js';
|
||||||
import { writeErrandManifest } from '../../agent/file-io.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 { join } from 'node:path';
|
||||||
|
import { existsSync, rmSync } from 'node:fs';
|
||||||
import { SimpleGitWorktreeManager } from '../../git/manager.js';
|
import { SimpleGitWorktreeManager } from '../../git/manager.js';
|
||||||
import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js';
|
import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js';
|
||||||
import type { TRPCContext } from '../context.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' });
|
const updated = await repo.update(input.id, { status: 'abandoned' });
|
||||||
return updated;
|
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 { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { StatusBadge } from '@/components/StatusBadge';
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
import { AgentOutputViewer } from '@/components/AgentOutputViewer';
|
import { AgentOutputViewer } from '@/components/AgentOutputViewer';
|
||||||
|
import { ErrandDiffView } from '@/components/ErrandDiffView';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { formatRelativeTime } from '@/lib/utils';
|
import { formatRelativeTime } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type ActiveTab = 'output' | 'changes';
|
||||||
|
|
||||||
interface ErrandDetailPanelProps {
|
interface ErrandDetailPanelProps {
|
||||||
errandId: string;
|
errandId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -16,13 +19,15 @@ interface ErrandDetailPanelProps {
|
|||||||
|
|
||||||
export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) {
|
export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>('output');
|
||||||
|
const [feedback, setFeedback] = useState('');
|
||||||
|
|
||||||
const errandQuery = trpc.errand.get.useQuery({ id: errandId });
|
const errandQuery = trpc.errand.get.useQuery({ id: errandId });
|
||||||
const errand = errandQuery.data;
|
const errand = errandQuery.data;
|
||||||
|
|
||||||
const diffQuery = trpc.errand.diff.useQuery(
|
const diffQuery = trpc.errand.diff.useQuery(
|
||||||
{ id: errandId },
|
{ id: errandId },
|
||||||
{ enabled: errand?.status !== 'active' },
|
{ enabled: !!errand },
|
||||||
);
|
);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
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
|
// Escape key closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
@@ -167,13 +182,62 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
|||||||
{/* View: Active */}
|
{/* View: Active */}
|
||||||
{errand.status === '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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{errand.agentId && (
|
{activeTab === 'output' ? (
|
||||||
<AgentOutputViewer
|
errand.agentId ? (
|
||||||
agentId={errand.agentId}
|
<AgentOutputViewer
|
||||||
agentName={errand.agentAlias ?? undefined}
|
agentId={errand.agentId}
|
||||||
status={undefined}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -258,9 +322,7 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
|||||||
{diffQuery.isLoading ? (
|
{diffQuery.isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading diff…</p>
|
<p className="text-sm text-muted-foreground">Loading diff…</p>
|
||||||
) : diffQuery.data?.diff ? (
|
) : diffQuery.data?.diff ? (
|
||||||
<pre className="overflow-x-auto rounded border border-border bg-muted/50 p-4 text-xs font-mono whitespace-pre">
|
<ErrandDiffView diff={diffQuery.data.diff} />
|
||||||
{diffQuery.data.diff}
|
|
||||||
</pre>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No changes — branch has no commits.
|
No changes — branch has no commits.
|
||||||
@@ -269,6 +331,27 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -338,9 +421,7 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
|||||||
{/* Read-only diff */}
|
{/* Read-only diff */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
{diffQuery.data?.diff ? (
|
{diffQuery.data?.diff ? (
|
||||||
<pre className="overflow-x-auto rounded border border-border bg-muted/50 p-4 text-xs font-mono whitespace-pre">
|
<ErrandDiffView diff={diffQuery.data.diff} />
|
||||||
{diffQuery.data.diff}
|
|
||||||
</pre>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No changes — branch has no commits.
|
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