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[]> {
|
||||
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
|
||||
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 { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
||||
import { generateUniqueAlias } from './alias.js';
|
||||
import type {
|
||||
EventBus,
|
||||
@@ -84,11 +85,12 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
private debug: boolean = false,
|
||||
processManagerOverride?: ProcessManager,
|
||||
private chatSessionRepository?: ChatSessionRepository,
|
||||
private reviewCommentRepository?: ReviewCommentRepository,
|
||||
) {
|
||||
this.signalManager = new FileSystemSignalManager();
|
||||
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
|
||||
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository, reviewCommentRepository);
|
||||
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
|
||||
this.lifecycleController = createLifecycleController({
|
||||
repository,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
||||
import type {
|
||||
EventBus,
|
||||
AgentStoppedEvent,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
readDecisionFiles,
|
||||
readPageFiles,
|
||||
readFrontmatterFile,
|
||||
readCommentResponses,
|
||||
} from './file-io.js';
|
||||
import { getProvider } from './providers/registry.js';
|
||||
import { markdownToTiptapJson } from './markdown-to-tiptap.js';
|
||||
@@ -92,6 +94,7 @@ export class OutputHandler {
|
||||
private pageRepository?: PageRepository,
|
||||
private signalManager?: SignalManager,
|
||||
private chatSessionRepository?: ChatSessionRepository,
|
||||
private reviewCommentRepository?: ReviewCommentRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -851,6 +854,28 @@ export class OutputHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Process comment responses from agent (for review/execute tasks)
|
||||
if (this.reviewCommentRepository) {
|
||||
try {
|
||||
const commentResponses = await readCommentResponses(agentWorkdir);
|
||||
for (const resp of commentResponses) {
|
||||
try {
|
||||
await this.reviewCommentRepository.createReply(resp.commentId, resp.body, 'agent');
|
||||
if (resp.resolved) {
|
||||
await this.reviewCommentRepository.resolve(resp.commentId);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, commentId: resp.commentId, err: err instanceof Error ? err.message : String(err) }, 'failed to process comment response');
|
||||
}
|
||||
}
|
||||
if (commentResponses.length > 0) {
|
||||
log.info({ agentId, count: commentResponses.length }, 'processed agent comment responses');
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to read comment responses');
|
||||
}
|
||||
}
|
||||
|
||||
const resultPayload: AgentResult = {
|
||||
success: true,
|
||||
message: resultMessage,
|
||||
|
||||
@@ -14,13 +14,26 @@ import {
|
||||
} from './shared.js';
|
||||
|
||||
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
|
||||
? `
|
||||
<task>
|
||||
${taskDescription}
|
||||
|
||||
Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies.
|
||||
</task>`
|
||||
</task>${reviewCommentsSection}`
|
||||
: '';
|
||||
|
||||
return `<role>
|
||||
|
||||
Reference in New Issue
Block a user