diff --git a/apps/server/agent/file-io.ts b/apps/server/agent/file-io.ts index e6da8e1..84b9c3a 100644 --- a/apps/server/agent/file-io.ts +++ b/apps/server/agent/file-io.ts @@ -397,6 +397,34 @@ export async function readDecisionFiles(agentWorkdir: string): Promise { + const filePath = join(agentWorkdir, '.cw', 'output', 'comment-responses.json'); + try { + const raw = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((entry: unknown) => { + if (typeof entry !== 'object' || entry === null) return false; + const e = entry as Record; + return typeof e.commentId === 'string' && typeof e.body === 'string'; + }) + .map((entry: Record) => ({ + commentId: String(entry.commentId), + body: String(entry.body), + resolved: typeof entry.resolved === 'boolean' ? entry.resolved : undefined, + })); + } catch { + return []; + } +} + export async function readPageFiles(agentWorkdir: string): Promise { const dirPath = join(agentWorkdir, '.cw', 'output', 'pages'); return readFrontmatterDir(dirPath, (data, body, filename) => { diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 3bde16a..0572edb 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -27,6 +27,7 @@ import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js'; import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; +import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; import { generateUniqueAlias } from './alias.js'; import type { EventBus, @@ -84,11 +85,12 @@ export class MultiProviderAgentManager implements AgentManager { private debug: boolean = false, processManagerOverride?: ProcessManager, private chatSessionRepository?: ChatSessionRepository, + private reviewCommentRepository?: ReviewCommentRepository, ) { this.signalManager = new FileSystemSignalManager(); this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository); this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager); - this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository); + this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository, reviewCommentRepository); this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager); this.lifecycleController = createLifecycleController({ repository, diff --git a/apps/server/agent/output-handler.ts b/apps/server/agent/output-handler.ts index 9c85b2c..a43f09e 100644 --- a/apps/server/agent/output-handler.ts +++ b/apps/server/agent/output-handler.ts @@ -15,6 +15,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; +import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; import type { EventBus, AgentStoppedEvent, @@ -37,6 +38,7 @@ import { readDecisionFiles, readPageFiles, readFrontmatterFile, + readCommentResponses, } from './file-io.js'; import { getProvider } from './providers/registry.js'; import { markdownToTiptapJson } from './markdown-to-tiptap.js'; @@ -92,6 +94,7 @@ export class OutputHandler { private pageRepository?: PageRepository, private signalManager?: SignalManager, private chatSessionRepository?: ChatSessionRepository, + private reviewCommentRepository?: ReviewCommentRepository, ) {} /** @@ -851,6 +854,28 @@ export class OutputHandler { } } + // Process comment responses from agent (for review/execute tasks) + if (this.reviewCommentRepository) { + try { + const commentResponses = await readCommentResponses(agentWorkdir); + for (const resp of commentResponses) { + try { + await this.reviewCommentRepository.createReply(resp.commentId, resp.body, 'agent'); + if (resp.resolved) { + await this.reviewCommentRepository.resolve(resp.commentId); + } + } catch (err) { + log.warn({ agentId, commentId: resp.commentId, err: err instanceof Error ? err.message : String(err) }, 'failed to process comment response'); + } + } + if (commentResponses.length > 0) { + log.info({ agentId, count: commentResponses.length }, 'processed agent comment responses'); + } + } catch (err) { + log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to read comment responses'); + } + } + const resultPayload: AgentResult = { success: true, message: resultMessage, diff --git a/apps/server/agent/prompts/execute.ts b/apps/server/agent/prompts/execute.ts index 03299af..92878bf 100644 --- a/apps/server/agent/prompts/execute.ts +++ b/apps/server/agent/prompts/execute.ts @@ -14,13 +14,26 @@ import { } from './shared.js'; export function buildExecutePrompt(taskDescription?: string): string { + const hasReviewComments = taskDescription?.includes('[comment:'); + const reviewCommentsSection = hasReviewComments + ? ` + +You are addressing review feedback. Each comment is tagged with [comment:ID]. +For EACH comment you address: +1. Fix the issue in code, OR explain why no change is needed. +2. Write \`.cw/output/comment-responses.json\`: + [{"commentId": "abc123", "body": "Fixed: added try-catch around token validation", "resolved": true}] +Set resolved:true when you fixed it, false when you're explaining why you didn't. +` + : ''; + const taskSection = taskDescription ? ` ${taskDescription} Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies. -` +${reviewCommentsSection}` : ''; return ` diff --git a/apps/server/container.ts b/apps/server/container.ts index a009504..5e6aefd 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -183,6 +183,7 @@ export async function createContainer(options?: ContainerOptions): Promise { + // Fetch parent comment to copy context fields + const parentRows = await this.db + .select() + .from(reviewComments) + .where(eq(reviewComments.id, parentCommentId)) + .limit(1); + const parent = parentRows[0]; + if (!parent) { + throw new Error(`Parent comment not found: ${parentCommentId}`); + } + + const now = new Date(); + const id = nanoid(); + await this.db.insert(reviewComments).values({ + id, + phaseId: parent.phaseId, + filePath: parent.filePath, + lineNumber: parent.lineNumber, + lineType: parent.lineType, + body, + author: author ?? 'user', + parentCommentId, resolved: false, createdAt: now, updatedAt: now, diff --git a/apps/server/db/repositories/review-comment-repository.ts b/apps/server/db/repositories/review-comment-repository.ts index 50831bb..fff6f16 100644 --- a/apps/server/db/repositories/review-comment-repository.ts +++ b/apps/server/db/repositories/review-comment-repository.ts @@ -13,10 +13,12 @@ export interface CreateReviewCommentData { lineType: 'added' | 'removed' | 'context'; body: string; author?: string; + parentCommentId?: string; // for replies } export interface ReviewCommentRepository { create(data: CreateReviewCommentData): Promise; + createReply(parentCommentId: string, body: string, author?: string): Promise; findByPhaseId(phaseId: string): Promise; resolve(id: string): Promise; unresolve(id: string): Promise; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 1248f7f..77d9073 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -617,11 +617,13 @@ export const reviewComments = sqliteTable('review_comments', { lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(), body: text('body').notNull(), author: text('author').notNull().default('you'), + parentCommentId: text('parent_comment_id').references((): ReturnType => reviewComments.id, { onDelete: 'cascade' }), resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => [ index('review_comments_phase_id_idx').on(table.phaseId), + index('review_comments_parent_id_idx').on(table.parentCommentId), ]); export type ReviewComment = InferSelectModel; diff --git a/apps/server/drizzle/0032_add_comment_threading.sql b/apps/server/drizzle/0032_add_comment_threading.sql new file mode 100644 index 0000000..fd17e7a --- /dev/null +++ b/apps/server/drizzle/0032_add_comment_threading.sql @@ -0,0 +1,2 @@ +ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE; +CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id); diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index b48b821..973cc59 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -344,7 +344,14 @@ export class ExecutionOrchestrator { */ async requestChangesOnPhase( phaseId: string, - unresolvedComments: Array<{ filePath: string; lineNumber: number; body: string }>, + unresolvedThreads: Array<{ + id: string; + filePath: string; + lineNumber: number; + body: string; + author: string; + replies: Array<{ id: string; body: string; author: string }>; + }>, summary?: string, ): Promise<{ taskId: string }> { const phase = await this.phaseRepository.findById(phaseId); @@ -365,16 +372,16 @@ export class ExecutionOrchestrator { return { taskId: activeReview.id }; } - // Build revision task description from comments + summary + // Build revision task description from threaded comments + summary const lines: string[] = []; if (summary) { lines.push(`## Summary\n\n${summary}\n`); } - if (unresolvedComments.length > 0) { + if (unresolvedThreads.length > 0) { lines.push('## Review Comments\n'); // Group comments by file - const byFile = new Map(); - for (const c of unresolvedComments) { + const byFile = new Map(); + for (const c of unresolvedThreads) { const arr = byFile.get(c.filePath) ?? []; arr.push(c); byFile.set(c.filePath, arr); @@ -382,9 +389,13 @@ export class ExecutionOrchestrator { for (const [filePath, fileComments] of byFile) { lines.push(`### ${filePath}\n`); for (const c of fileComments) { - lines.push(`- **Line ${c.lineNumber}**: ${c.body}`); + lines.push(`#### Line ${c.lineNumber} [comment:${c.id}]`); + lines.push(`**${c.author}**: ${c.body}`); + for (const r of c.replies) { + lines.push(`> **${r.author}**: ${r.body}`); + } + lines.push(''); } - lines.push(''); } } @@ -414,12 +425,12 @@ export class ExecutionOrchestrator { phaseId, initiativeId: phase.initiativeId, taskId: task.id, - commentCount: unresolvedComments.length, + commentCount: unresolvedThreads.length, }, }; this.eventBus.emit(event); - log.info({ phaseId, taskId: task.id, commentCount: unresolvedComments.length }, 'changes requested on phase'); + log.info({ phaseId, taskId: task.id, commentCount: unresolvedThreads.length }, 'changes requested on phase'); // Kick off dispatch this.scheduleDispatch(); diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index d2ec9e8..ef6608c 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -368,6 +368,17 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { return comment; }), + replyToReviewComment: publicProcedure + .input(z.object({ + parentCommentId: z.string().min(1), + body: z.string().trim().min(1), + author: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const repo = requireReviewCommentRepository(ctx); + return repo.createReply(input.parentCommentId, input.body, input.author); + }), + requestPhaseChanges: publicProcedure .input(z.object({ phaseId: z.string().min(1), @@ -378,15 +389,33 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { const reviewCommentRepo = requireReviewCommentRepository(ctx); const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId); - const unresolved = allComments - .filter((c: { resolved: boolean }) => !c.resolved) - .map((c: { filePath: string; lineNumber: number; body: string }) => ({ + // Build threaded structure: unresolved root comments with their replies + const rootComments = allComments.filter((c) => !c.parentCommentId); + const repliesByParent = new Map(); + for (const c of allComments) { + if (c.parentCommentId) { + const arr = repliesByParent.get(c.parentCommentId) ?? []; + arr.push(c); + repliesByParent.set(c.parentCommentId, arr); + } + } + + const unresolvedThreads = rootComments + .filter((c) => !c.resolved) + .map((c) => ({ + id: c.id, filePath: c.filePath, lineNumber: c.lineNumber, body: c.body, + author: c.author, + replies: (repliesByParent.get(c.id) ?? []).map((r) => ({ + id: r.id, + body: r.body, + author: r.author, + })), })); - if (unresolved.length === 0 && !input.summary) { + if (unresolvedThreads.length === 0 && !input.summary) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Add comments or a summary before requesting changes', @@ -395,7 +424,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { const result = await orchestrator.requestChangesOnPhase( input.phaseId, - unresolved, + unresolvedThreads, input.summary, ); return { success: true, taskId: result.taskId }; diff --git a/apps/web/src/components/review/CommentThread.tsx b/apps/web/src/components/review/CommentThread.tsx index 6599e34..52d575a 100644 --- a/apps/web/src/components/review/CommentThread.tsx +++ b/apps/web/src/components/review/CommentThread.tsx @@ -1,71 +1,150 @@ -import { Check, RotateCcw } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { Check, RotateCcw, Reply } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { CommentForm } from "./CommentForm"; import type { ReviewComment } from "./types"; interface CommentThreadProps { comments: ReviewComment[]; onResolve: (commentId: string) => void; onUnresolve: (commentId: string) => void; + onReply?: (parentCommentId: string, body: string) => void; } -export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) { +export function CommentThread({ comments, onResolve, onUnresolve, onReply }: CommentThreadProps) { + // Group: root comments (no parentCommentId) and their replies + const rootComments = comments.filter((c) => !c.parentCommentId); + const repliesByParent = new Map(); + for (const c of comments) { + if (c.parentCommentId) { + const arr = repliesByParent.get(c.parentCommentId) ?? []; + arr.push(c); + repliesByParent.set(c.parentCommentId, arr); + } + } + return (
- {comments.map((comment) => ( -
( + -
-
- {comment.author} - - {formatTime(comment.createdAt)} - - {comment.resolved && ( - - - Resolved - - )} -
-
- {comment.resolved ? ( - - ) : ( - - )} -
-
-

- {comment.body} -

-
+ comment={comment} + replies={repliesByParent.get(comment.id) ?? []} + onResolve={onResolve} + onUnresolve={onUnresolve} + onReply={onReply} + /> ))}
); } +function RootComment({ + comment, + replies, + onResolve, + onUnresolve, + onReply, +}: { + comment: ReviewComment; + replies: ReviewComment[]; + onResolve: (id: string) => void; + onUnresolve: (id: string) => void; + onReply?: (parentCommentId: string, body: string) => void; +}) { + const [isReplying, setIsReplying] = useState(false); + const replyRef = useRef(null); + + useEffect(() => { + if (isReplying) replyRef.current?.focus(); + }, [isReplying]); + + return ( +
+ {/* Root comment */} +
+
+
+ {comment.author} + {formatTime(comment.createdAt)} + {comment.resolved && ( + + + Resolved + + )} +
+
+ {onReply && !comment.resolved && ( + + )} + {comment.resolved ? ( + + ) : ( + + )} +
+
+

{comment.body}

+
+ + {/* Replies */} + {replies.length > 0 && ( +
+ {replies.map((reply) => ( +
+
+ + {reply.author} + + {formatTime(reply.createdAt)} +
+

{reply.body}

+
+ ))} +
+ )} + + {/* Reply form */} + {isReplying && onReply && ( +
+ { + onReply(comment.id, body); + setIsReplying(false); + }} + onCancel={() => setIsReplying(false)} + placeholder="Write a reply..." + submitLabel="Reply" + /> +
+ )} +
+ ); +} + function formatTime(iso: string): string { const d = new Date(iso); return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 5cec6c5..6fac668 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -12,6 +12,7 @@ interface DiffViewerProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; viewedFiles?: Set; onToggleViewed?: (filePath: string) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; @@ -23,6 +24,7 @@ export function DiffViewer({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, viewedFiles, onToggleViewed, onRegisterRef, @@ -37,6 +39,7 @@ export function DiffViewer({ onAddComment={onAddComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} isViewed={viewedFiles?.has(file.newPath) ?? false} onToggleViewed={() => onToggleViewed?.(file.newPath)} /> diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index a1180e3..5606de4 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -52,6 +52,7 @@ interface FileCardProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; isViewed?: boolean; onToggleViewed?: () => void; } @@ -62,6 +63,7 @@ export function FileCard({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, isViewed = false, onToggleViewed = () => {}, }: FileCardProps) { @@ -157,6 +159,7 @@ export function FileCard({ onAddComment={onAddComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} tokenMap={tokenMap} /> ))} diff --git a/apps/web/src/components/review/HunkRows.tsx b/apps/web/src/components/review/HunkRows.tsx index e8f9038..eaeeb5a 100644 --- a/apps/web/src/components/review/HunkRows.tsx +++ b/apps/web/src/components/review/HunkRows.tsx @@ -15,6 +15,7 @@ interface HunkRowsProps { ) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; tokenMap?: LineTokenMap | null; } @@ -25,6 +26,7 @@ export function HunkRows({ onAddComment, onResolveComment, onUnresolveComment, + onReplyComment, tokenMap, }: HunkRowsProps) { const [commentingLine, setCommentingLine] = useState<{ @@ -98,6 +100,7 @@ export function HunkRows({ onSubmitComment={handleSubmitComment} onResolveComment={onResolveComment} onUnresolveComment={onUnresolveComment} + onReplyComment={onReplyComment} tokens={ line.newLineNumber !== null ? tokenMap?.get(line.newLineNumber) ?? undefined diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index ac4288f..57d58cf 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -15,6 +15,7 @@ interface LineWithCommentsProps { onSubmitComment: (body: string) => void; onResolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void; + onReplyComment?: (parentCommentId: string, body: string) => void; /** Syntax-highlighted tokens for this line (if available) */ tokens?: TokenizedLine; } @@ -29,6 +30,7 @@ export function LineWithComments({ onSubmitComment, onResolveComment, onUnresolveComment, + onReplyComment, tokens, }: LineWithCommentsProps) { const formRef = useRef(null); @@ -141,6 +143,7 @@ export function LineWithComments({ comments={lineComments} onResolve={onResolveComment} onUnresolve={onUnresolveComment} + onReply={onReplyComment} /> diff --git a/apps/web/src/components/review/ReviewSidebar.tsx b/apps/web/src/components/review/ReviewSidebar.tsx index 3a5eb54..fe8d383 100644 --- a/apps/web/src/components/review/ReviewSidebar.tsx +++ b/apps/web/src/components/review/ReviewSidebar.tsx @@ -183,8 +183,8 @@ function FilesView({ activeFiles: FileDiff[]; viewedFiles: Set; }) { - const unresolvedCount = comments.filter((c) => !c.resolved).length; - const resolvedCount = comments.filter((c) => c.resolved).length; + const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length; + const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length; const activeFilePaths = new Set(activeFiles.map((f) => f.newPath)); const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]); @@ -263,7 +263,7 @@ function FilesView({
{group.files.map((file) => { const fileCommentCount = comments.filter( - (c) => c.filePath === file.newPath, + (c) => c.filePath === file.newPath && !c.parentCommentId, ).length; const isInView = activeFilePaths.has(file.newPath); const dimmed = selectedCommit && !isInView; diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 7a28e3a..cd5d987 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -153,6 +153,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { author: c.author, createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt), resolved: c.resolved, + parentCommentId: c.parentCommentId ?? null, })); }, [commentsQuery.data]); @@ -175,6 +176,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { }, }); + const replyToCommentMutation = trpc.replyToReviewComment.useMutation({ + onSuccess: () => { + utils.listReviewComments.invalidate({ phaseId: activePhaseId! }); + }, + onError: (err) => toast.error(`Failed to post reply: ${err.message}`), + }); + const approveMutation = trpc.approvePhaseReview.useMutation({ onSuccess: () => { setStatus("approved"); @@ -221,6 +229,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { unresolveCommentMutation.mutate({ id: commentId }); }, [unresolveCommentMutation]); + const handleReplyComment = useCallback((parentCommentId: string, body: string) => { + replyToCommentMutation.mutate({ parentCommentId, body }); + }, [replyToCommentMutation]); + const handleApprove = useCallback(() => { if (!activePhaseId) return; approveMutation.mutate({ phaseId: activePhaseId }); @@ -256,7 +268,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { setViewedFiles(new Set()); }, []); - const unresolvedCount = comments.filter((c) => !c.resolved).length; + const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length; const activePhaseName = diffQuery.data?.phaseName ?? @@ -350,6 +362,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { onAddComment={handleAddComment} onResolveComment={handleResolveComment} onUnresolveComment={handleUnresolveComment} + onReplyComment={handleReplyComment} viewedFiles={viewedFiles} onToggleViewed={toggleViewed} onRegisterRef={registerFileRef} diff --git a/apps/web/src/components/review/types.ts b/apps/web/src/components/review/types.ts index 488a832..2b99452 100644 --- a/apps/web/src/components/review/types.ts +++ b/apps/web/src/components/review/types.ts @@ -34,6 +34,7 @@ export interface ReviewComment { author: string; createdAt: string; resolved: boolean; + parentCommentId?: string | null; } export type ReviewStatus = "pending" | "approved" | "changes_requested"; diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 558a325..0c261b7 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -123,5 +123,5 @@ Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are co - **YOLO**: phase completes → auto-merge → auto-dispatch next phase → auto-dispatch tasks - **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks -- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) → phase reset to `in_progress` → agent fixes → phase returns to `pending_review` +- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) with threaded comments (`[comment:ID]` tags + reply threads) → phase reset to `in_progress` → agent reads comments, fixes code, writes `.cw/output/comment-responses.json` → OutputHandler creates reply comments and optionally resolves threads → phase returns to `pending_review` - **request-changes (initiative)**: initiative `pending_review` → user requests changes → creates/reuses "Finalization" phase → creates review task → initiative reset to `active` → agent fixes → initiative returns to `pending_review` diff --git a/docs/frontend.md b/docs/frontend.md index 4c8ebfa..cd95313 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -111,10 +111,11 @@ The initiative detail page has three tabs managed via local state (not URL param ### Review Components (`src/components/review/`) | Component | Purpose | |-----------|---------| -| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has inline comments + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push | +| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push | | `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions | -| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, comment counts, and commit navigation | -| `DiffViewer` | Unified diff renderer with inline comments | +| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation | +| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) | +| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form | | `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) | | `ProposalCard` | Individual proposal display | diff --git a/docs/server-api.md b/docs/server-api.md index 337ec2a..48c62bd 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -116,11 +116,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getPhaseReviewCommits | query | List commits between initiative and phase branch | | getCommitDiff | query | Diff for a single commit (by hash) in a phase | | approvePhaseReview | mutation | Approve and merge phase branch | -| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved comments (dedup guard skips if active review exists), resets phase to in_progress | -| listReviewComments | query | List review comments by phaseId | +| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved threaded comments (with `[comment:ID]` tags and reply threads), resets phase to in_progress | +| listReviewComments | query | List review comments by phaseId (flat list including replies, frontend groups by parentCommentId) | | createReviewComment | mutation | Create inline review comment on diff | | resolveReviewComment | mutation | Mark review comment as resolved | | unresolveReviewComment | mutation | Mark review comment as unresolved | +| replyToReviewComment | mutation | Create a threaded reply to an existing review comment (copies parent's phaseId/filePath/lineNumber) | ### Phase Dispatch | Procedure | Type | Description |