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

@@ -23,7 +23,43 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
lineNumber: data.lineNumber,
lineType: data.lineType,
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,
createdAt: now,
updatedAt: now,

View File

@@ -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<ReviewComment>;
createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment>;
findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
resolve(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(),
body: text('body').notNull(),
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),
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<typeof reviewComments>;