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

@@ -397,6 +397,34 @@ export async function readDecisionFiles(agentWorkdir: string): Promise<ParsedDec
}); });
} }
export interface ParsedCommentResponse {
commentId: string;
body: string;
resolved?: boolean;
}
export async function readCommentResponses(agentWorkdir: string): Promise<ParsedCommentResponse[]> {
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<string, unknown>;
return typeof e.commentId === 'string' && typeof e.body === 'string';
})
.map((entry: Record<string, unknown>) => ({
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<ParsedPageFile[]> { export async function readPageFiles(agentWorkdir: string): Promise<ParsedPageFile[]> {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages'); const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => { return readFrontmatterDir(dirPath, (data, body, filename) => {

View File

@@ -27,6 +27,7 @@ import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js'; import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-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 { generateUniqueAlias } from './alias.js';
import type { import type {
EventBus, EventBus,
@@ -84,11 +85,12 @@ export class MultiProviderAgentManager implements AgentManager {
private debug: boolean = false, private debug: boolean = false,
processManagerOverride?: ProcessManager, processManagerOverride?: ProcessManager,
private chatSessionRepository?: ChatSessionRepository, private chatSessionRepository?: ChatSessionRepository,
private reviewCommentRepository?: ReviewCommentRepository,
) { ) {
this.signalManager = new FileSystemSignalManager(); this.signalManager = new FileSystemSignalManager();
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository); this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager); 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.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
this.lifecycleController = createLifecycleController({ this.lifecycleController = createLifecycleController({
repository, repository,

View File

@@ -15,6 +15,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { import type {
EventBus, EventBus,
AgentStoppedEvent, AgentStoppedEvent,
@@ -37,6 +38,7 @@ import {
readDecisionFiles, readDecisionFiles,
readPageFiles, readPageFiles,
readFrontmatterFile, readFrontmatterFile,
readCommentResponses,
} from './file-io.js'; } from './file-io.js';
import { getProvider } from './providers/registry.js'; import { getProvider } from './providers/registry.js';
import { markdownToTiptapJson } from './markdown-to-tiptap.js'; import { markdownToTiptapJson } from './markdown-to-tiptap.js';
@@ -92,6 +94,7 @@ export class OutputHandler {
private pageRepository?: PageRepository, private pageRepository?: PageRepository,
private signalManager?: SignalManager, private signalManager?: SignalManager,
private chatSessionRepository?: ChatSessionRepository, 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 = { const resultPayload: AgentResult = {
success: true, success: true,
message: resultMessage, message: resultMessage,

View File

@@ -14,13 +14,26 @@ import {
} from './shared.js'; } from './shared.js';
export function buildExecutePrompt(taskDescription?: string): string { export function buildExecutePrompt(taskDescription?: string): string {
const hasReviewComments = taskDescription?.includes('[comment:');
const reviewCommentsSection = hasReviewComments
? `
<review_comments>
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.
</review_comments>`
: '';
const taskSection = taskDescription const taskSection = taskDescription
? ` ? `
<task> <task>
${taskDescription} ${taskDescription}
Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies. Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies.
</task>` </task>${reviewCommentsSection}`
: ''; : '';
return `<role> return `<role>

View File

@@ -183,6 +183,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
options?.debug ?? false, options?.debug ?? false,
undefined, // processManagerOverride undefined, // processManagerOverride
repos.chatSessionRepository, repos.chatSessionRepository,
repos.reviewCommentRepository,
); );
log.info('agent manager created'); log.info('agent manager created');

View File

@@ -23,7 +23,43 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
lineNumber: data.lineNumber, lineNumber: data.lineNumber,
lineType: data.lineType, lineType: data.lineType,
body: data.body, body: data.body,
author: data.author ?? 'you', author: data.author ?? 'user',
parentCommentId: data.parentCommentId ?? null,
resolved: false,
createdAt: now,
updatedAt: now,
});
const rows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, id))
.limit(1);
return rows[0]!;
}
async createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment> {
// 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, resolved: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -13,10 +13,12 @@ export interface CreateReviewCommentData {
lineType: 'added' | 'removed' | 'context'; lineType: 'added' | 'removed' | 'context';
body: string; body: string;
author?: string; author?: string;
parentCommentId?: string; // for replies
} }
export interface ReviewCommentRepository { export interface ReviewCommentRepository {
create(data: CreateReviewCommentData): Promise<ReviewComment>; create(data: CreateReviewCommentData): Promise<ReviewComment>;
createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment>;
findByPhaseId(phaseId: string): Promise<ReviewComment[]>; findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
resolve(id: string): Promise<ReviewComment | null>; resolve(id: string): Promise<ReviewComment | null>;
unresolve(id: string): Promise<ReviewComment | null>; unresolve(id: string): Promise<ReviewComment | null>;

View File

@@ -617,11 +617,13 @@ export const reviewComments = sqliteTable('review_comments', {
lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(), lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(),
body: text('body').notNull(), body: text('body').notNull(),
author: text('author').notNull().default('you'), author: text('author').notNull().default('you'),
parentCommentId: text('parent_comment_id').references((): ReturnType<typeof text> => reviewComments.id, { onDelete: 'cascade' }),
resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => [ }, (table) => [
index('review_comments_phase_id_idx').on(table.phaseId), index('review_comments_phase_id_idx').on(table.phaseId),
index('review_comments_parent_id_idx').on(table.parentCommentId),
]); ]);
export type ReviewComment = InferSelectModel<typeof reviewComments>; export type ReviewComment = InferSelectModel<typeof reviewComments>;

View File

@@ -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);

View File

@@ -344,7 +344,14 @@ export class ExecutionOrchestrator {
*/ */
async requestChangesOnPhase( async requestChangesOnPhase(
phaseId: string, 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, summary?: string,
): Promise<{ taskId: string }> { ): Promise<{ taskId: string }> {
const phase = await this.phaseRepository.findById(phaseId); const phase = await this.phaseRepository.findById(phaseId);
@@ -365,16 +372,16 @@ export class ExecutionOrchestrator {
return { taskId: activeReview.id }; return { taskId: activeReview.id };
} }
// Build revision task description from comments + summary // Build revision task description from threaded comments + summary
const lines: string[] = []; const lines: string[] = [];
if (summary) { if (summary) {
lines.push(`## Summary\n\n${summary}\n`); lines.push(`## Summary\n\n${summary}\n`);
} }
if (unresolvedComments.length > 0) { if (unresolvedThreads.length > 0) {
lines.push('## Review Comments\n'); lines.push('## Review Comments\n');
// Group comments by file // Group comments by file
const byFile = new Map<string, typeof unresolvedComments>(); const byFile = new Map<string, typeof unresolvedThreads>();
for (const c of unresolvedComments) { for (const c of unresolvedThreads) {
const arr = byFile.get(c.filePath) ?? []; const arr = byFile.get(c.filePath) ?? [];
arr.push(c); arr.push(c);
byFile.set(c.filePath, arr); byFile.set(c.filePath, arr);
@@ -382,9 +389,13 @@ export class ExecutionOrchestrator {
for (const [filePath, fileComments] of byFile) { for (const [filePath, fileComments] of byFile) {
lines.push(`### ${filePath}\n`); lines.push(`### ${filePath}\n`);
for (const c of fileComments) { 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, phaseId,
initiativeId: phase.initiativeId, initiativeId: phase.initiativeId,
taskId: task.id, taskId: task.id,
commentCount: unresolvedComments.length, commentCount: unresolvedThreads.length,
}, },
}; };
this.eventBus.emit(event); 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 // Kick off dispatch
this.scheduleDispatch(); this.scheduleDispatch();

View File

@@ -368,6 +368,17 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return comment; 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 requestPhaseChanges: publicProcedure
.input(z.object({ .input(z.object({
phaseId: z.string().min(1), phaseId: z.string().min(1),
@@ -378,15 +389,33 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
const reviewCommentRepo = requireReviewCommentRepository(ctx); const reviewCommentRepo = requireReviewCommentRepository(ctx);
const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId); const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId);
const unresolved = allComments // Build threaded structure: unresolved root comments with their replies
.filter((c: { resolved: boolean }) => !c.resolved) const rootComments = allComments.filter((c) => !c.parentCommentId);
.map((c: { filePath: string; lineNumber: number; body: string }) => ({ const repliesByParent = new Map<string, typeof allComments>();
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, filePath: c.filePath,
lineNumber: c.lineNumber, lineNumber: c.lineNumber,
body: c.body, 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({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Add comments or a summary before requesting changes', message: 'Add comments or a summary before requesting changes',
@@ -395,7 +424,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
const result = await orchestrator.requestChangesOnPhase( const result = await orchestrator.requestChangesOnPhase(
input.phaseId, input.phaseId,
unresolved, unresolvedThreads,
input.summary, input.summary,
); );
return { success: true, taskId: result.taskId }; return { success: true, taskId: result.taskId };

View File

@@ -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 { Button } from "@/components/ui/button";
import { CommentForm } from "./CommentForm";
import type { ReviewComment } from "./types"; import type { ReviewComment } from "./types";
interface CommentThreadProps { interface CommentThreadProps {
comments: ReviewComment[]; comments: ReviewComment[];
onResolve: (commentId: string) => void; onResolve: (commentId: string) => void;
onUnresolve: (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<string, ReviewComment[]>();
for (const c of comments) {
if (c.parentCommentId) {
const arr = repliesByParent.get(c.parentCommentId) ?? [];
arr.push(c);
repliesByParent.set(c.parentCommentId, arr);
}
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{comments.map((comment) => ( {rootComments.map((comment) => (
<div <RootComment
key={comment.id} key={comment.id}
className={`rounded border p-2.5 text-xs space-y-1.5 ${ comment={comment}
comment.resolved replies={repliesByParent.get(comment.id) ?? []}
? "border-status-success-border bg-status-success-bg/50" onResolve={onResolve}
: "border-border bg-card" onUnresolve={onUnresolve}
}`} onReply={onReply}
> />
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-foreground">{comment.author}</span>
<span className="text-muted-foreground">
{formatTime(comment.createdAt)}
</span>
{comment.resolved && (
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
<Check className="h-3 w-3" />
Resolved
</span>
)}
</div>
<div>
{comment.resolved ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onUnresolve(comment.id)}
>
<RotateCcw className="h-3 w-3 mr-0.5" />
Reopen
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onResolve(comment.id)}
>
<Check className="h-3 w-3 mr-0.5" />
Resolve
</Button>
)}
</div>
</div>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{comment.body}
</p>
</div>
))} ))}
</div> </div>
); );
} }
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<HTMLTextAreaElement>(null);
useEffect(() => {
if (isReplying) replyRef.current?.focus();
}, [isReplying]);
return (
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
{/* Root comment */}
<div className="p-2.5 text-xs space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-foreground">{comment.author}</span>
<span className="text-muted-foreground">{formatTime(comment.createdAt)}</span>
{comment.resolved && (
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
<Check className="h-3 w-3" />
Resolved
</span>
)}
</div>
<div className="flex items-center gap-0.5">
{onReply && !comment.resolved && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setIsReplying(!isReplying)}
>
<Reply className="h-3 w-3 mr-0.5" />
Reply
</Button>
)}
{comment.resolved ? (
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onUnresolve(comment.id)}>
<RotateCcw className="h-3 w-3 mr-0.5" />
Reopen
</Button>
) : (
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onResolve(comment.id)}>
<Check className="h-3 w-3 mr-0.5" />
Resolve
</Button>
)}
</div>
</div>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
</div>
{/* Replies */}
{replies.length > 0 && (
<div className="border-t border-border/50">
{replies.map((reply) => (
<div
key={reply.id}
className={`px-2.5 py-2 text-xs border-l-2 ml-3 space-y-1 ${
reply.author === "agent"
? "border-l-primary bg-primary/5"
: "border-l-muted-foreground/30"
}`}
>
<div className="flex items-center gap-1.5">
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
{reply.author}
</span>
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
</div>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
</div>
))}
</div>
)}
{/* Reply form */}
{isReplying && onReply && (
<div className="border-t border-border/50 p-2.5">
<CommentForm
ref={replyRef}
onSubmit={(body) => {
onReply(comment.id, body);
setIsReplying(false);
}}
onCancel={() => setIsReplying(false)}
placeholder="Write a reply..."
submitLabel="Reply"
/>
</div>
)}
</div>
);
}
function formatTime(iso: string): string { function formatTime(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });

View File

@@ -12,6 +12,7 @@ interface DiffViewerProps {
) => void; ) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
viewedFiles?: Set<string>; viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void; onToggleViewed?: (filePath: string) => void;
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
@@ -23,6 +24,7 @@ export function DiffViewer({
onAddComment, onAddComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
viewedFiles, viewedFiles,
onToggleViewed, onToggleViewed,
onRegisterRef, onRegisterRef,
@@ -37,6 +39,7 @@ export function DiffViewer({
onAddComment={onAddComment} onAddComment={onAddComment}
onResolveComment={onResolveComment} onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment} onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
isViewed={viewedFiles?.has(file.newPath) ?? false} isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)} onToggleViewed={() => onToggleViewed?.(file.newPath)}
/> />

View File

@@ -52,6 +52,7 @@ interface FileCardProps {
) => void; ) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
isViewed?: boolean; isViewed?: boolean;
onToggleViewed?: () => void; onToggleViewed?: () => void;
} }
@@ -62,6 +63,7 @@ export function FileCard({
onAddComment, onAddComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
isViewed = false, isViewed = false,
onToggleViewed = () => {}, onToggleViewed = () => {},
}: FileCardProps) { }: FileCardProps) {
@@ -157,6 +159,7 @@ export function FileCard({
onAddComment={onAddComment} onAddComment={onAddComment}
onResolveComment={onResolveComment} onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment} onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
tokenMap={tokenMap} tokenMap={tokenMap}
/> />
))} ))}

View File

@@ -15,6 +15,7 @@ interface HunkRowsProps {
) => void; ) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
tokenMap?: LineTokenMap | null; tokenMap?: LineTokenMap | null;
} }
@@ -25,6 +26,7 @@ export function HunkRows({
onAddComment, onAddComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
tokenMap, tokenMap,
}: HunkRowsProps) { }: HunkRowsProps) {
const [commentingLine, setCommentingLine] = useState<{ const [commentingLine, setCommentingLine] = useState<{
@@ -98,6 +100,7 @@ export function HunkRows({
onSubmitComment={handleSubmitComment} onSubmitComment={handleSubmitComment}
onResolveComment={onResolveComment} onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment} onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
tokens={ tokens={
line.newLineNumber !== null line.newLineNumber !== null
? tokenMap?.get(line.newLineNumber) ?? undefined ? tokenMap?.get(line.newLineNumber) ?? undefined

View File

@@ -15,6 +15,7 @@ interface LineWithCommentsProps {
onSubmitComment: (body: string) => void; onSubmitComment: (body: string) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
/** Syntax-highlighted tokens for this line (if available) */ /** Syntax-highlighted tokens for this line (if available) */
tokens?: TokenizedLine; tokens?: TokenizedLine;
} }
@@ -29,6 +30,7 @@ export function LineWithComments({
onSubmitComment, onSubmitComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
tokens, tokens,
}: LineWithCommentsProps) { }: LineWithCommentsProps) {
const formRef = useRef<HTMLTextAreaElement>(null); const formRef = useRef<HTMLTextAreaElement>(null);
@@ -141,6 +143,7 @@ export function LineWithComments({
comments={lineComments} comments={lineComments}
onResolve={onResolveComment} onResolve={onResolveComment}
onUnresolve={onUnresolveComment} onUnresolve={onUnresolveComment}
onReply={onReplyComment}
/> />
</td> </td>
</tr> </tr>

View File

@@ -183,8 +183,8 @@ function FilesView({
activeFiles: FileDiff[]; activeFiles: FileDiff[];
viewedFiles: Set<string>; viewedFiles: Set<string>;
}) { }) {
const unresolvedCount = comments.filter((c) => !c.resolved).length; const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
const resolvedCount = comments.filter((c) => c.resolved).length; const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length;
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath)); const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]); const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
@@ -263,7 +263,7 @@ function FilesView({
<div className="space-y-0.5"> <div className="space-y-0.5">
{group.files.map((file) => { {group.files.map((file) => {
const fileCommentCount = comments.filter( const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath, (c) => c.filePath === file.newPath && !c.parentCommentId,
).length; ).length;
const isInView = activeFilePaths.has(file.newPath); const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView; const dimmed = selectedCommit && !isInView;

View File

@@ -153,6 +153,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
author: c.author, author: c.author,
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt), createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
resolved: c.resolved, resolved: c.resolved,
parentCommentId: c.parentCommentId ?? null,
})); }));
}, [commentsQuery.data]); }, [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({ const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => { onSuccess: () => {
setStatus("approved"); setStatus("approved");
@@ -221,6 +229,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
unresolveCommentMutation.mutate({ id: commentId }); unresolveCommentMutation.mutate({ id: commentId });
}, [unresolveCommentMutation]); }, [unresolveCommentMutation]);
const handleReplyComment = useCallback((parentCommentId: string, body: string) => {
replyToCommentMutation.mutate({ parentCommentId, body });
}, [replyToCommentMutation]);
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (!activePhaseId) return; if (!activePhaseId) return;
approveMutation.mutate({ phaseId: activePhaseId }); approveMutation.mutate({ phaseId: activePhaseId });
@@ -256,7 +268,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
setViewedFiles(new Set()); setViewedFiles(new Set());
}, []); }, []);
const unresolvedCount = comments.filter((c) => !c.resolved).length; const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
const activePhaseName = const activePhaseName =
diffQuery.data?.phaseName ?? diffQuery.data?.phaseName ??
@@ -350,6 +362,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onAddComment={handleAddComment} onAddComment={handleAddComment}
onResolveComment={handleResolveComment} onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment} onUnresolveComment={handleUnresolveComment}
onReplyComment={handleReplyComment}
viewedFiles={viewedFiles} viewedFiles={viewedFiles}
onToggleViewed={toggleViewed} onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef} onRegisterRef={registerFileRef}

View File

@@ -34,6 +34,7 @@ export interface ReviewComment {
author: string; author: string;
createdAt: string; createdAt: string;
resolved: boolean; resolved: boolean;
parentCommentId?: string | null;
} }
export type ReviewStatus = "pending" | "approved" | "changes_requested"; export type ReviewStatus = "pending" | "approved" | "changes_requested";

View File

@@ -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 - **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 - **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` - **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`

View File

@@ -111,10 +111,11 @@ The initiative detail page has three tabs managed via local state (not URL param
### Review Components (`src/components/review/`) ### Review Components (`src/components/review/`)
| Component | Purpose | | 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 | | `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 | | `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation |
| `DiffViewer` | Unified diff renderer with inline comments | | `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) | | `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
| `ProposalCard` | Individual proposal display | | `ProposalCard` | Individual proposal display |

View File

@@ -116,11 +116,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getPhaseReviewCommits | query | List commits between initiative and phase branch | | getPhaseReviewCommits | query | List commits between initiative and phase branch |
| getCommitDiff | query | Diff for a single commit (by hash) in a phase | | getCommitDiff | query | Diff for a single commit (by hash) in a phase |
| approvePhaseReview | mutation | Approve and merge phase branch | | 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 | | 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 | | listReviewComments | query | List review comments by phaseId (flat list including replies, frontend groups by parentCommentId) |
| createReviewComment | mutation | Create inline review comment on diff | | createReviewComment | mutation | Create inline review comment on diff |
| resolveReviewComment | mutation | Mark review comment as resolved | | resolveReviewComment | mutation | Mark review comment as resolved |
| unresolveReviewComment | mutation | Mark review comment as unresolved | | 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 ### Phase Dispatch
| Procedure | Type | Description | | Procedure | Type | Description |