From 6482960c6f420fcc1ee6540a49b094035d2b4469 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 22:09:01 +0100 Subject: [PATCH] feat: errand review & request changes Add errand.requestChanges procedure that re-spawns an agent in the existing worktree with user feedback. Replace raw
 diff blocks
with syntax-highlighted ErrandDiffView using FileCard components.
Add Output/Changes tabs to the active errand view.
---
 apps/server/agent/prompts/errand.ts           |  21 ++++
 apps/server/agent/prompts/index.ts            |   2 +-
 apps/server/trpc/routers/errand.ts            |  95 ++++++++++++++-
 apps/web/src/components/ErrandDetailPanel.tsx | 109 +++++++++++++++---
 apps/web/src/components/ErrandDiffView.tsx    |  68 +++++++++++
 5 files changed, 278 insertions(+), 17 deletions(-)
 create mode 100644 apps/web/src/components/ErrandDiffView.tsx

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 */} +

+