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:
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
2
apps/server/drizzle/0032_add_comment_threading.sql
Normal file
2
apps/server/drizzle/0032_add_comment_threading.sql
Normal 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);
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user