feat: Add threaded review comments + agent comment responses

Introduces GitHub-style threaded comments via parentCommentId self-reference.
Users and agents can reply within comment threads, and review agents receive
comment IDs so they can post targeted responses via comment-responses.json.

- Migration 0032: parentCommentId column + index on review_comments
- Repository: createReply() copies parent context, default author 'you' → 'user'
- tRPC: replyToReviewComment procedure, requestPhaseChanges passes threaded comments
- Orchestrator: formats [comment:ID] tags with full reply threads in task description
- Agent IO: readCommentResponses() reads .cw/output/comment-responses.json
- OutputHandler: processes agent comment responses (creates replies, resolves threads)
- Execute prompt: conditional <review_comments> block when task has [comment:] markers
- Frontend: CommentThread renders root+replies with agent-specific styling + reply form
- Sidebar/ReviewTab: root-only comment counts, reply mutation plumbing through DiffViewer chain
This commit is contained in:
Lukas May
2026-03-06 10:21:22 +01:00
parent 2da6632298
commit 7695604da2
22 changed files with 336 additions and 78 deletions

View File

@@ -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<string, typeof unresolvedComments>();
for (const c of unresolvedComments) {
const byFile = new Map<string, typeof unresolvedThreads>();
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();