feat: Add merge conflict detection and agent resolution in initiative review
Pre-merge mergeability check via `git merge-tree --write-tree` (dry-run, no side effects). When conflicts exist the "Merge & Push" button is disabled and a ConflictResolutionPanel shows conflict files with options to resolve manually or spawn a conflict-resolution agent. Agent questions appear inline via QuestionForm; on completion the mergeability re-checks automatically. New server-side: MergeabilityResult type, BranchManager.checkMergeability, conflict-resolution prompt, checkInitiativeMergeability query, spawnConflictResolutionAgent mutation, getActiveConflictAgent query. New frontend: useConflictAgent hook, ConflictResolutionPanel component, mergeability badge + panel integration in InitiativeReview.
This commit is contained in:
74
apps/server/agent/prompts/conflict-resolution.ts
Normal file
74
apps/server/agent/prompts/conflict-resolution.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Conflict resolution prompt — spawned when initiative branch has merge conflicts
|
||||||
|
* with the target branch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SIGNAL_FORMAT,
|
||||||
|
SESSION_STARTUP,
|
||||||
|
GIT_WORKFLOW,
|
||||||
|
CONTEXT_MANAGEMENT,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
export function buildConflictResolutionPrompt(
|
||||||
|
sourceBranch: string,
|
||||||
|
targetBranch: string,
|
||||||
|
conflicts: string[],
|
||||||
|
): string {
|
||||||
|
const conflictList = conflicts.map(f => `- \`${f}\``).join('\n');
|
||||||
|
|
||||||
|
return `<role>
|
||||||
|
You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` into \`${sourceBranch}\` and resolve all merge conflicts so the initiative branch is up to date with the target branch.
|
||||||
|
</role>
|
||||||
|
|
||||||
|
<conflict_details>
|
||||||
|
**Source branch (initiative):** \`${sourceBranch}\`
|
||||||
|
**Target branch (default):** \`${targetBranch}\`
|
||||||
|
|
||||||
|
**Conflicting files:**
|
||||||
|
${conflictList}
|
||||||
|
</conflict_details>
|
||||||
|
${SIGNAL_FORMAT}
|
||||||
|
${SESSION_STARTUP}
|
||||||
|
|
||||||
|
<resolution_protocol>
|
||||||
|
Follow these steps in order:
|
||||||
|
|
||||||
|
1. **Inspect divergence**: Run \`git log --oneline ${targetBranch}..${sourceBranch}\` and \`git log --oneline ${sourceBranch}..${targetBranch}\` to understand what each side changed.
|
||||||
|
|
||||||
|
2. **Review conflicting files**: For each conflicting file, read both versions:
|
||||||
|
- \`git show ${sourceBranch}:<file>\`
|
||||||
|
- \`git show ${targetBranch}:<file>\`
|
||||||
|
|
||||||
|
3. **Merge**: Run \`git merge ${targetBranch} --no-edit\`. This will produce conflict markers.
|
||||||
|
|
||||||
|
4. **Resolve each file**: For each conflicting file:
|
||||||
|
- Read the file to see conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`)
|
||||||
|
- Understand both sides' intent from step 1-2
|
||||||
|
- Choose the correct resolution — keep both changes when they don't overlap, prefer the more complete version when they do
|
||||||
|
- If you genuinely cannot determine the correct resolution, signal "questions" explaining the ambiguity
|
||||||
|
|
||||||
|
5. **Verify**: Run \`git diff --check\` to confirm no conflict markers remain. Run the test suite to confirm nothing is broken.
|
||||||
|
|
||||||
|
6. **Commit**: Stage resolved files with \`git add <file>\` (never \`git add .\`), then \`git commit --no-edit\` to complete the merge commit.
|
||||||
|
|
||||||
|
7. **Signal done**: Write signal.json with status "done".
|
||||||
|
</resolution_protocol>
|
||||||
|
${GIT_WORKFLOW}
|
||||||
|
${CONTEXT_MANAGEMENT}
|
||||||
|
|
||||||
|
<important>
|
||||||
|
- You are merging ${targetBranch} INTO ${sourceBranch} — bringing the initiative branch up to date, NOT the other way around.
|
||||||
|
- Do NOT force-push or rebase. A merge commit is the correct approach.
|
||||||
|
- If tests fail after resolution, fix the code — don't skip tests.
|
||||||
|
- If a conflict is genuinely ambiguous (e.g., both sides rewrote the same function differently), signal "questions" with the specific ambiguity and your proposed resolution.
|
||||||
|
</important>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConflictResolutionDescription(
|
||||||
|
sourceBranch: string,
|
||||||
|
targetBranch: string,
|
||||||
|
conflicts: string[],
|
||||||
|
): string {
|
||||||
|
return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`;
|
||||||
|
}
|
||||||
@@ -15,3 +15,4 @@ export { buildChatPrompt } from './chat.js';
|
|||||||
export type { ChatHistoryEntry } from './chat.js';
|
export type { ChatHistoryEntry } from './chat.js';
|
||||||
export { buildWorkspaceLayout } from './workspace.js';
|
export { buildWorkspaceLayout } from './workspace.js';
|
||||||
export { buildPreviewInstructions } from './preview.js';
|
export { buildPreviewInstructions } from './preview.js';
|
||||||
|
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function createMocks() {
|
|||||||
diffCommit: vi.fn().mockResolvedValue(''),
|
diffCommit: vi.fn().mockResolvedValue(''),
|
||||||
getMergeBase: vi.fn().mockResolvedValue('abc123'),
|
getMergeBase: vi.fn().mockResolvedValue('abc123'),
|
||||||
pushBranch: vi.fn(),
|
pushBranch: vi.fn(),
|
||||||
|
checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const phaseRepository = {
|
const phaseRepository = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* a worktree to be checked out.
|
* a worktree to be checked out.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MergeResult, BranchCommit } from './types.js';
|
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
|
||||||
|
|
||||||
export interface BranchManager {
|
export interface BranchManager {
|
||||||
/**
|
/**
|
||||||
@@ -68,4 +68,11 @@ export interface BranchManager {
|
|||||||
* Defaults to 'origin' if no remote specified.
|
* Defaults to 'origin' if no remote specified.
|
||||||
*/
|
*/
|
||||||
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry-run merge check — determines if sourceBranch can be cleanly merged
|
||||||
|
* into targetBranch without actually performing the merge.
|
||||||
|
* Uses `git merge-tree --write-tree` (git 2.38+).
|
||||||
|
*/
|
||||||
|
checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
export type { WorktreeManager } from './types.js';
|
export type { WorktreeManager } from './types.js';
|
||||||
|
|
||||||
// Domain types
|
// Domain types
|
||||||
export type { Worktree, WorktreeDiff, MergeResult } from './types.js';
|
export type { Worktree, WorktreeDiff, MergeResult, MergeabilityResult } from './types.js';
|
||||||
|
|
||||||
// Adapters
|
// Adapters
|
||||||
export { SimpleGitWorktreeManager } from './manager.js';
|
export { SimpleGitWorktreeManager } from './manager.js';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { simpleGit } from 'simple-git';
|
import { simpleGit } from 'simple-git';
|
||||||
import type { BranchManager } from './branch-manager.js';
|
import type { BranchManager } from './branch-manager.js';
|
||||||
import type { MergeResult, BranchCommit } from './types.js';
|
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
|
||||||
import { createModuleLogger } from '../logger/index.js';
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
|
|
||||||
const log = createModuleLogger('branch-manager');
|
const log = createModuleLogger('branch-manager');
|
||||||
@@ -164,4 +164,41 @@ export class SimpleGitBranchManager implements BranchManager {
|
|||||||
await git.push(remote, branch);
|
await git.push(remote, branch);
|
||||||
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult> {
|
||||||
|
const git = simpleGit(repoPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// git merge-tree --write-tree merges source INTO target virtually.
|
||||||
|
// Exit 0 = clean merge, non-zero = conflicts.
|
||||||
|
await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]);
|
||||||
|
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean');
|
||||||
|
return { mergeable: true };
|
||||||
|
} catch (err) {
|
||||||
|
const stderr = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
// Parse conflict file names from "CONFLICT (content): Merge conflict in <path>"
|
||||||
|
const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g;
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = conflictPattern.exec(stderr)) !== null) {
|
||||||
|
conflicts.push(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts');
|
||||||
|
return { mergeable: false, conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't parse conflicts but the command failed, it's still a conflict
|
||||||
|
// (could be add/add, rename conflicts, etc.)
|
||||||
|
if (stderr.includes('CONFLICT')) {
|
||||||
|
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts');
|
||||||
|
return { mergeable: false, conflicts: ['(unable to parse conflict details)'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genuine error (not a conflict)
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,19 @@ export interface MergeResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mergeability Check
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a dry-run merge check.
|
||||||
|
* No side effects — only tells you whether the merge would succeed.
|
||||||
|
*/
|
||||||
|
export interface MergeabilityResult {
|
||||||
|
mergeable: boolean;
|
||||||
|
conflicts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Branch Commit Info
|
// Branch Commit Info
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -184,6 +184,27 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
return candidates[0] ?? null;
|
return candidates[0] ?? null;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getActiveConflictAgent: publicProcedure
|
||||||
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||||
|
const agentManager = requireAgentManager(ctx);
|
||||||
|
const allAgents = await agentManager.list();
|
||||||
|
const candidates = allAgents
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.mode === 'execute' &&
|
||||||
|
a.initiativeId === input.initiativeId &&
|
||||||
|
a.name?.startsWith('conflict-') &&
|
||||||
|
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
|
||||||
|
!a.userDismissedAt,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}),
|
||||||
|
|
||||||
getAgentOutput: publicProcedure
|
getAgentOutput: publicProcedure
|
||||||
.input(agentIdentifierSchema)
|
.input(agentIdentifierSchema)
|
||||||
.query(async ({ ctx, input }): Promise<string> => {
|
.query(async ({ ctx, input }): Promise<string> => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js';
|
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js';
|
||||||
import { deriveInitiativeActivity } from './initiative-activity.js';
|
import { deriveInitiativeActivity } from './initiative-activity.js';
|
||||||
import { buildRefinePrompt } from '../../agent/prompts/index.js';
|
import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResolutionDescription } from '../../agent/prompts/index.js';
|
||||||
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
||||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||||
|
|
||||||
@@ -349,5 +349,128 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
);
|
);
|
||||||
return { success: true, taskId: result.taskId };
|
return { success: true, taskId: result.taskId };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
checkInitiativeMergeability: publicProcedure
|
||||||
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||||
|
const projectRepo = requireProjectRepository(ctx);
|
||||||
|
const branchManager = requireBranchManager(ctx);
|
||||||
|
|
||||||
|
const initiative = await initiativeRepo.findById(input.initiativeId);
|
||||||
|
if (!initiative) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
|
||||||
|
}
|
||||||
|
if (!initiative.branch) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
|
||||||
|
const allConflicts: string[] = [];
|
||||||
|
let mergeable = true;
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
||||||
|
const result = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
|
||||||
|
if (!result.mergeable) {
|
||||||
|
mergeable = false;
|
||||||
|
if (result.conflicts) allConflicts.push(...result.conflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mergeable,
|
||||||
|
conflictFiles: allConflicts,
|
||||||
|
targetBranch: projects[0]?.defaultBranch ?? 'main',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
spawnConflictResolutionAgent: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
initiativeId: z.string().min(1),
|
||||||
|
provider: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const agentManager = requireAgentManager(ctx);
|
||||||
|
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||||
|
const projectRepo = requireProjectRepository(ctx);
|
||||||
|
const taskRepo = requireTaskRepository(ctx);
|
||||||
|
const branchManager = requireBranchManager(ctx);
|
||||||
|
|
||||||
|
const initiative = await initiativeRepo.findById(input.initiativeId);
|
||||||
|
if (!initiative) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
|
||||||
|
}
|
||||||
|
if (!initiative.branch) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
|
||||||
|
if (projects.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no linked projects' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss stale conflict agents
|
||||||
|
const allAgents = await agentManager.list();
|
||||||
|
const staleAgents = allAgents.filter(
|
||||||
|
(a) =>
|
||||||
|
a.mode === 'execute' &&
|
||||||
|
a.initiativeId === input.initiativeId &&
|
||||||
|
a.name?.startsWith('conflict-') &&
|
||||||
|
['crashed', 'idle'].includes(a.status) &&
|
||||||
|
!a.userDismissedAt,
|
||||||
|
);
|
||||||
|
for (const stale of staleAgents) {
|
||||||
|
await agentManager.dismiss(stale.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if active conflict agent already running
|
||||||
|
const activeConflictAgents = allAgents.filter(
|
||||||
|
(a) =>
|
||||||
|
a.mode === 'execute' &&
|
||||||
|
a.initiativeId === input.initiativeId &&
|
||||||
|
a.name?.startsWith('conflict-') &&
|
||||||
|
['running', 'waiting_for_input'].includes(a.status),
|
||||||
|
);
|
||||||
|
if (activeConflictAgents.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'A conflict resolution agent is already running for this initiative',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check mergeability to get current conflict list
|
||||||
|
const project = projects[0];
|
||||||
|
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
||||||
|
const mergeCheck = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
|
||||||
|
if (mergeCheck.mergeable) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No merge conflicts detected — merge is clean' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = mergeCheck.conflicts ?? [];
|
||||||
|
const targetBranch = project.defaultBranch;
|
||||||
|
|
||||||
|
// Create task
|
||||||
|
const task = await taskRepo.create({
|
||||||
|
initiativeId: input.initiativeId,
|
||||||
|
name: `Resolve conflicts: ${initiative.name}`,
|
||||||
|
description: buildConflictResolutionDescription(initiative.branch, targetBranch, conflicts),
|
||||||
|
category: 'merge',
|
||||||
|
status: 'in_progress',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn agent
|
||||||
|
const prompt = buildConflictResolutionPrompt(initiative.branch, targetBranch, conflicts);
|
||||||
|
return agentManager.spawn({
|
||||||
|
name: `conflict-${Date.now()}`,
|
||||||
|
taskId: task.id,
|
||||||
|
prompt,
|
||||||
|
mode: 'execute',
|
||||||
|
provider: input.provider,
|
||||||
|
initiativeId: input.initiativeId,
|
||||||
|
baseBranch: targetBranch,
|
||||||
|
branchName: initiative.branch,
|
||||||
|
});
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
182
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal file
182
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { QuestionForm } from '@/components/QuestionForm';
|
||||||
|
import { useConflictAgent } from '@/hooks/useConflictAgent';
|
||||||
|
|
||||||
|
interface ConflictResolutionPanelProps {
|
||||||
|
initiativeId: string;
|
||||||
|
conflicts: string[];
|
||||||
|
onResolved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) {
|
||||||
|
const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
|
||||||
|
const [showManual, setShowManual] = useState(false);
|
||||||
|
|
||||||
|
if (state === 'none') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="h-4 w-4 text-status-error-fg mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-1">
|
||||||
|
{conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected
|
||||||
|
</h3>
|
||||||
|
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 mb-3">
|
||||||
|
{conflicts.map((file) => (
|
||||||
|
<li key={file}>{file}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => spawn.mutate({ initiativeId })}
|
||||||
|
disabled={spawn.isPending}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{spawn.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<GitMerge className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Resolve with Agent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowManual(!showManual)}
|
||||||
|
className="h-7 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{showManual ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
|
Manual Resolution
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{spawn.error && (
|
||||||
|
<p className="mt-2 text-xs text-status-error-fg">{spawn.error.message}</p>
|
||||||
|
)}
|
||||||
|
{showManual && (
|
||||||
|
<div className="mt-3 rounded border border-border bg-card p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
In your project clone, run:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs font-mono bg-terminal text-terminal-fg rounded p-2 overflow-x-auto">
|
||||||
|
{`git checkout <initiative-branch>
|
||||||
|
git merge <target-branch>
|
||||||
|
# Resolve conflicts in each file
|
||||||
|
git add <resolved-files>
|
||||||
|
git commit --no-edit`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'running') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||||
|
<span className="text-sm text-muted-foreground">Resolving merge conflicts...</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => stop.mutate()}
|
||||||
|
disabled={stop.isPending}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'waiting' && questions) {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Terminal className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">Agent needs input</h3>
|
||||||
|
</div>
|
||||||
|
<QuestionForm
|
||||||
|
questions={questions.questions}
|
||||||
|
onSubmit={(answers) => resume.mutate(answers)}
|
||||||
|
onCancel={() => {}}
|
||||||
|
onDismiss={() => stop.mutate()}
|
||||||
|
isSubmitting={resume.isPending}
|
||||||
|
isDismissing={stop.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'completed') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-status-success-border bg-status-success-bg/50 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-status-success-fg" />
|
||||||
|
<span className="text-sm text-status-success-fg">Conflicts resolved</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
onResolved();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Re-check Mergeability
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'crashed') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
|
||||||
|
<span className="text-sm text-status-error-fg">Conflict resolution agent crashed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
spawn.mutate({ initiativeId });
|
||||||
|
}}
|
||||||
|
disabled={spawn.isPending}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react";
|
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { parseUnifiedDiff } from "./parse-diff";
|
import { parseUnifiedDiff } from "./parse-diff";
|
||||||
import { DiffViewer } from "./DiffViewer";
|
import { DiffViewer } from "./DiffViewer";
|
||||||
import { ReviewSidebar } from "./ReviewSidebar";
|
import { ReviewSidebar } from "./ReviewSidebar";
|
||||||
import { PreviewControls } from "./PreviewControls";
|
import { PreviewControls } from "./PreviewControls";
|
||||||
|
import { ConflictResolutionPanel } from "./ConflictResolutionPanel";
|
||||||
|
|
||||||
interface InitiativeReviewProps {
|
interface InitiativeReviewProps {
|
||||||
initiativeId: string;
|
initiativeId: string;
|
||||||
@@ -49,6 +51,26 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
{ enabled: !!selectedCommit },
|
{ enabled: !!selectedCommit },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mergeability check
|
||||||
|
const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery(
|
||||||
|
{ initiativeId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
);
|
||||||
|
const mergeability = mergeabilityQuery.data ?? null;
|
||||||
|
|
||||||
|
// Auto-refresh mergeability when a conflict agent completes
|
||||||
|
const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
|
||||||
|
const conflictAgentStatus = conflictAgentQuery.data?.status;
|
||||||
|
const prevConflictStatusRef = useRef(conflictAgentStatus);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevConflictStatusRef.current;
|
||||||
|
prevConflictStatusRef.current = conflictAgentStatus;
|
||||||
|
// When agent transitions from running/waiting to idle (completed)
|
||||||
|
if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') {
|
||||||
|
void mergeabilityQuery.refetch();
|
||||||
|
}
|
||||||
|
}, [conflictAgentStatus, mergeabilityQuery]);
|
||||||
|
|
||||||
// Preview state
|
// Preview state
|
||||||
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||||
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
||||||
@@ -184,6 +206,24 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
{totalDeletions}
|
{totalDeletions}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mergeability badge */}
|
||||||
|
{mergeabilityQuery.isLoading ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">
|
||||||
|
<Loader2 className="h-2.5 w-2.5 animate-spin mr-1" />
|
||||||
|
Checking...
|
||||||
|
</Badge>
|
||||||
|
) : mergeability?.mergeable ? (
|
||||||
|
<Badge variant="success" className="text-[10px] h-5">
|
||||||
|
<CheckCircle2 className="h-2.5 w-2.5 mr-1" />
|
||||||
|
Clean merge
|
||||||
|
</Badge>
|
||||||
|
) : mergeability && !mergeability.mergeable ? (
|
||||||
|
<Badge variant="error" className="text-[10px] h-5">
|
||||||
|
<AlertTriangle className="h-2.5 w-2.5 mr-1" />
|
||||||
|
{mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: preview + action buttons */}
|
{/* Right: preview + action buttons */}
|
||||||
@@ -206,7 +246,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
|
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
|
||||||
disabled={approveMutation.isPending}
|
disabled={approveMutation.isPending || mergeability?.mergeable === false}
|
||||||
|
title={mergeability?.mergeable === false ? 'Resolve merge conflicts before merging' : undefined}
|
||||||
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
||||||
>
|
>
|
||||||
{approveMutation.isPending ? (
|
{approveMutation.isPending ? (
|
||||||
@@ -220,6 +261,15 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Conflict resolution panel */}
|
||||||
|
{mergeability && !mergeability.mergeable && (
|
||||||
|
<ConflictResolutionPanel
|
||||||
|
initiativeId={initiativeId}
|
||||||
|
conflicts={mergeability.conflictFiles}
|
||||||
|
onResolved={() => mergeabilityQuery.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
||||||
<div className="border-r border-border">
|
<div className="border-r border-border">
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ export { useAutoSave } from './useAutoSave.js';
|
|||||||
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
||||||
export { useLiveUpdates } from './useLiveUpdates.js';
|
export { useLiveUpdates } from './useLiveUpdates.js';
|
||||||
export { useRefineAgent } from './useRefineAgent.js';
|
export { useRefineAgent } from './useRefineAgent.js';
|
||||||
|
export { useConflictAgent } from './useConflictAgent.js';
|
||||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
RefineAgentState,
|
RefineAgentState,
|
||||||
SpawnRefineAgentOptions,
|
SpawnRefineAgentOptions,
|
||||||
UseRefineAgentResult,
|
UseRefineAgentResult,
|
||||||
} from './useRefineAgent.js';
|
} from './useRefineAgent.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ConflictAgentState,
|
||||||
|
UseConflictAgentResult,
|
||||||
|
} from './useConflictAgent.js';
|
||||||
214
apps/web/src/hooks/useConflictAgent.ts
Normal file
214
apps/web/src/hooks/useConflictAgent.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import type { PendingQuestions } from '@codewalk-district/shared';
|
||||||
|
|
||||||
|
export type ConflictAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
|
||||||
|
|
||||||
|
type ConflictAgent = NonNullable<ReturnType<typeof trpc.getActiveConflictAgent.useQuery>['data']>;
|
||||||
|
|
||||||
|
export interface UseConflictAgentResult {
|
||||||
|
agent: ConflictAgent | null;
|
||||||
|
state: ConflictAgentState;
|
||||||
|
questions: PendingQuestions | null;
|
||||||
|
spawn: {
|
||||||
|
mutate: (options: { initiativeId: string; provider?: string }) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
resume: {
|
||||||
|
mutate: (answers: Record<string, string>) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
stop: {
|
||||||
|
mutate: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
dismiss: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConflictAgent(initiativeId: string): UseConflictAgentResult {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const agentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
|
||||||
|
const agent = agentQuery.data ?? null;
|
||||||
|
|
||||||
|
const state: ConflictAgentState = useMemo(() => {
|
||||||
|
if (!agent) return 'none';
|
||||||
|
switch (agent.status) {
|
||||||
|
case 'running':
|
||||||
|
return 'running';
|
||||||
|
case 'waiting_for_input':
|
||||||
|
return 'waiting';
|
||||||
|
case 'idle':
|
||||||
|
return 'completed';
|
||||||
|
case 'crashed':
|
||||||
|
return 'crashed';
|
||||||
|
default:
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}, [agent]);
|
||||||
|
|
||||||
|
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
||||||
|
{ id: agent?.id ?? '' },
|
||||||
|
{ enabled: state === 'waiting' && !!agent },
|
||||||
|
);
|
||||||
|
|
||||||
|
const spawnMutation = trpc.spawnConflictResolutionAgent.useMutation({
|
||||||
|
onMutate: async () => {
|
||||||
|
await utils.listAgents.cancel();
|
||||||
|
await utils.getActiveConflictAgent.cancel({ initiativeId });
|
||||||
|
|
||||||
|
const previousAgents = utils.listAgents.getData();
|
||||||
|
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
|
||||||
|
|
||||||
|
const tempAgent = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
name: `conflict-${Date.now()}`,
|
||||||
|
mode: 'execute' as const,
|
||||||
|
status: 'running' as const,
|
||||||
|
initiativeId,
|
||||||
|
taskId: null,
|
||||||
|
phaseId: null,
|
||||||
|
provider: 'claude',
|
||||||
|
accountId: null,
|
||||||
|
instruction: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
userDismissedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, tempAgent as any);
|
||||||
|
|
||||||
|
return { previousAgents, previousConflictAgent };
|
||||||
|
},
|
||||||
|
onError: (_err, _variables, context) => {
|
||||||
|
if (context?.previousAgents) {
|
||||||
|
utils.listAgents.setData(undefined, context.previousAgents);
|
||||||
|
}
|
||||||
|
if (context?.previousConflictAgent !== undefined) {
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumeMutation = trpc.resumeAgent.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopMutation = trpc.stopAgent.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
void utils.listWaitingAgents.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissMutation = trpc.dismissAgent.useMutation({
|
||||||
|
onMutate: async ({ id }) => {
|
||||||
|
await utils.listAgents.cancel();
|
||||||
|
await utils.getActiveConflictAgent.cancel({ initiativeId });
|
||||||
|
|
||||||
|
const previousAgents = utils.listAgents.getData();
|
||||||
|
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
|
||||||
|
|
||||||
|
utils.listAgents.setData(undefined, (old = []) => old.filter(a => a.id !== id));
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, null);
|
||||||
|
|
||||||
|
return { previousAgents, previousConflictAgent };
|
||||||
|
},
|
||||||
|
onError: (_err, _variables, context) => {
|
||||||
|
if (context?.previousAgents) {
|
||||||
|
utils.listAgents.setData(undefined, context.previousAgents);
|
||||||
|
}
|
||||||
|
if (context?.previousConflictAgent !== undefined) {
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const spawnMutateRef = useRef(spawnMutation.mutate);
|
||||||
|
spawnMutateRef.current = spawnMutation.mutate;
|
||||||
|
const agentRef = useRef(agent);
|
||||||
|
agentRef.current = agent;
|
||||||
|
const resumeMutateRef = useRef(resumeMutation.mutate);
|
||||||
|
resumeMutateRef.current = resumeMutation.mutate;
|
||||||
|
const stopMutateRef = useRef(stopMutation.mutate);
|
||||||
|
stopMutateRef.current = stopMutation.mutate;
|
||||||
|
const dismissMutateRef = useRef(dismissMutation.mutate);
|
||||||
|
dismissMutateRef.current = dismissMutation.mutate;
|
||||||
|
|
||||||
|
const spawnFn = useCallback(({ initiativeId, provider }: { initiativeId: string; provider?: string }) => {
|
||||||
|
spawnMutateRef.current({ initiativeId, provider });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const spawn = useMemo(() => ({
|
||||||
|
mutate: spawnFn,
|
||||||
|
isPending: spawnMutation.isPending,
|
||||||
|
error: spawnMutation.error,
|
||||||
|
}), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
|
||||||
|
|
||||||
|
const resumeFn = useCallback((answers: Record<string, string>) => {
|
||||||
|
const a = agentRef.current;
|
||||||
|
if (a) {
|
||||||
|
resumeMutateRef.current({ id: a.id, answers });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resume = useMemo(() => ({
|
||||||
|
mutate: resumeFn,
|
||||||
|
isPending: resumeMutation.isPending,
|
||||||
|
error: resumeMutation.error,
|
||||||
|
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
|
||||||
|
|
||||||
|
const stopFn = useCallback(() => {
|
||||||
|
const a = agentRef.current;
|
||||||
|
if (a) {
|
||||||
|
stopMutateRef.current({ id: a.id });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useMemo(() => ({
|
||||||
|
mutate: stopFn,
|
||||||
|
isPending: stopMutation.isPending,
|
||||||
|
}), [stopFn, stopMutation.isPending]);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
const a = agentRef.current;
|
||||||
|
if (a) {
|
||||||
|
dismissMutateRef.current({ id: a.id });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||||
|
}, [utils, initiativeId]);
|
||||||
|
|
||||||
|
const isLoading = agentQuery.isLoading ||
|
||||||
|
(state === 'waiting' && questionsQuery.isLoading);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
state,
|
||||||
|
questions: questionsQuery.data ?? null,
|
||||||
|
spawn,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
dismiss,
|
||||||
|
isLoading,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
|
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
|
||||||
| `credentials/` | `AccountCredentialManager` — credential injection per account |
|
| `credentials/` | `AccountCredentialManager` — credential injection per account |
|
||||||
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
|
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
|
||||||
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
|
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
|
||||||
|
|
||||||
## Key Flows
|
## Key Flows
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|
|||||||
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only 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 threaded inline comments (root + reply threads) |
|
| `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 |
|
| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form |
|
||||||
|
| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion |
|
||||||
| `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 |
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ shadcn/ui components: badge (6 status variants + xs size), button, card, dialog,
|
|||||||
| Hook | Purpose |
|
| Hook | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
|
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
|
||||||
|
| `useConflictAgent` | Manages conflict resolution agent lifecycle for initiative review |
|
||||||
| `useDetailAgent` | Manages detail agent for phase planning |
|
| `useDetailAgent` | Manages detail agent for phase planning |
|
||||||
| `useAgentOutput` | Subscribes to live agent output stream |
|
| `useAgentOutput` | Subscribes to live agent output stream |
|
||||||
| `useChatSession` | Manages chat session for phase/task refinement |
|
| `useChatSession` | Manages chat session for phase/task refinement |
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
|
|||||||
| `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) |
|
| `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) |
|
||||||
| `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit |
|
| `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit |
|
||||||
| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash |
|
| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash |
|
||||||
|
| `pushBranch(repoPath, branch, remote?)` | Push branch to remote (default: 'origin') |
|
||||||
|
| `checkMergeability(repoPath, source, target)` | Dry-run merge check via `git merge-tree --write-tree` (git 2.38+). Returns `{ mergeable, conflicts? }` with no side effects |
|
||||||
|
|
||||||
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.
|
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| getAgentQuestions | query | Pending questions |
|
| getAgentQuestions | query | Pending questions |
|
||||||
| getAgentOutput | query | Full output from DB log chunks |
|
| getAgentOutput | query | Full output from DB log chunks |
|
||||||
| getActiveRefineAgent | query | Active refine agent for initiative |
|
| getActiveRefineAgent | query | Active refine agent for initiative |
|
||||||
|
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
|
||||||
| listWaitingAgents | query | Agents waiting for input |
|
| listWaitingAgents | query | Agents waiting for input |
|
||||||
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus |
|
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus |
|
||||||
|
|
||||||
@@ -96,6 +97,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| getInitiativeCommitDiff | query | Single commit diff for initiative review |
|
| getInitiativeCommitDiff | query | Single commit diff for initiative review |
|
||||||
| approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` |
|
| approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` |
|
||||||
| requestInitiativeChanges | mutation | Request changes on initiative: `{initiativeId, summary}` → creates review task in Finalization phase, resets initiative to active |
|
| requestInitiativeChanges | mutation | Request changes on initiative: `{initiativeId, summary}` → creates review task in Finalization phase, resets initiative to active |
|
||||||
|
| checkInitiativeMergeability | query | Dry-run merge check: `{initiativeId}` → `{mergeable, conflictFiles[], targetBranch}` |
|
||||||
|
| spawnConflictResolutionAgent | mutation | Spawn agent to resolve merge conflicts: `{initiativeId, provider?}` → auto-dismisses stale conflict agents, creates merge task |
|
||||||
|
|
||||||
### Phases
|
### Phases
|
||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
|
|||||||
Reference in New Issue
Block a user