diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts index e94b950..f995aae 100644 --- a/apps/server/agent/prompts/errand.ts +++ b/apps/server/agent/prompts/errand.ts @@ -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": "" } } + +If you cannot complete the change: + +{ "status": "error", "error": "" } + +Do not create any other output files.`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index c7167db..32b8ac5 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -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'; diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts index 3185f75..3d550c5 100644 --- a/apps/server/trpc/routers/errand.ts +++ b/apps/server/trpc/routers/errand.ts @@ -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 }; + }), }), }; } diff --git a/apps/web/src/components/ErrandDetailPanel.tsx b/apps/web/src/components/ErrandDetailPanel.tsx index fe74258..301473c 100644 --- a/apps/web/src/components/ErrandDetailPanel.tsx +++ b/apps/web/src/components/ErrandDetailPanel.tsx @@ -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('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 */} +
+ + +
+
- {errand.agentId && ( - + {activeTab === 'output' ? ( + errand.agentId ? ( + + ) : null + ) : ( +
+
+ Committed changes + +
+ {diffQuery.isLoading ? ( +

Loading diff…

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

+ No changes yet. +

+ )} +
)}
@@ -258,9 +322,7 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) {diffQuery.isLoading ? (

Loading diff…

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

No changes — branch has no commits. @@ -269,6 +331,27 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) + {/* Request changes input */} +

+