From 2ce5c2efa11f10900fd02b15d4c48cb112c33ef3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:27:08 -0400 Subject: [PATCH 01/22] Add PR convergence runtime and inventory tools Introduce PR convergence features and wiring across the desktop app: add CTO operator tools to read/update convergence runtime and pipeline, start/stop convergence rounds, and read inventory summaries (ctoOperatorTools). Implement helpers to build fallback inventory items, summarize rounds, and patch runtime state. Wire issueInventoryService through agent chat service and main app initialization, add IPC handlers for getting/saving/deleting convergence runtime and persist runtime when launching issue-resolution chats. Extend and update tests for ctoOperatorTools and issueInventoryService to cover runtime/pipeline behavior and thread tracking. --- apps/desktop/src/main/main.ts | 1 + .../ai/tools/ctoOperatorTools.test.ts | 350 +++ .../services/ai/tools/ctoOperatorTools.ts | 595 ++++- .../main/services/chat/agentChatService.ts | 5 + .../src/main/services/ipc/registerIpc.ts | 29 +- .../prs/issueInventoryService.test.ts | 324 ++- .../services/prs/issueInventoryService.ts | 332 ++- apps/desktop/src/main/services/state/kvDb.ts | 28 + apps/desktop/src/preload/global.d.ts | 4 + apps/desktop/src/preload/preload.ts | 7 + apps/desktop/src/renderer/browserMock.ts | 39 + .../components/lanes/CreateLaneDialog.tsx | 11 +- .../renderer/components/lanes/LanesPage.tsx | 6 + .../PrDetailPane.issueResolver.test.tsx | 68 +- .../components/prs/detail/PrDetailPane.tsx | 774 +++++-- .../prs/shared/PrConvergencePanel.tsx | 2007 ++++++----------- .../components/prs/state/PrsContext.tsx | 82 + apps/desktop/src/shared/ipc.ts | 3 + apps/desktop/src/shared/types/prs.ts | 70 + apps/mcp-server/src/bootstrap.ts | 4 + apps/mcp-server/src/mcpServer.test.ts | 64 + apps/mcp-server/src/mcpServer.ts | 2 + 22 files changed, 3288 insertions(+), 1517 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a0cf85777..a888000a2 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2239,6 +2239,7 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, + issueInventoryService, eventBuffer: mcpEventBuffer, dispose: () => {} // desktop manages service lifecycle }; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index c3ec81ca5..7a9f18b69 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -44,8 +44,12 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo linearDispatcherService: null, flowPolicyService: null, prService: null, + issueInventoryService: null, fileService: null, processService: null, + sessionService: { + updateMeta: vi.fn(), + } as any, issueTracker: null, listChats: vi.fn().mockResolvedValue([]), getChatStatus: vi.fn().mockResolvedValue(null), @@ -121,6 +125,11 @@ describe("createCtoOperatorTools", () => { expect(toolKeys).toContain("commentOnPullRequest"); expect(toolKeys).toContain("updatePullRequestTitle"); expect(toolKeys).toContain("updatePullRequestBody"); + expect(toolKeys).toContain("getPullRequestConvergence"); + expect(toolKeys).toContain("updatePullRequestConvergencePipeline"); + expect(toolKeys).toContain("updatePullRequestConvergenceRuntime"); + expect(toolKeys).toContain("startPullRequestConvergenceRound"); + expect(toolKeys).toContain("stopPullRequestConvergence"); // Linear issue routing / issue tools expect(toolKeys).toContain("routeLinearIssueToCto"); @@ -844,6 +853,347 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: true, prId: "pr-1" }); }); + + it("reads PR convergence runtime, pipeline settings, and inventory summary", async () => { + const snapshot = { + prId: "pr-1", + items: [ + { + id: "item-1", + prId: "pr-1", + source: "unknown", + type: "review_thread", + externalId: "review-thread:thread-1", + state: "new", + round: 2, + filePath: "src/app.ts", + line: 12, + severity: "major", + headline: "Fix the null check", + body: "Please handle null.", + author: "Reviewer", + url: "https://github.com/acme/repo/pull/1#discussion_r1", + dismissReason: null, + agentSessionId: null, + threadCommentCount: 2, + threadLatestCommentId: "c-2", + threadLatestCommentAuthor: "Reviewer", + threadLatestCommentAt: "2026-03-16T00:00:00.000Z", + threadLatestCommentSource: "unknown", + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }, + ], + convergence: { + currentRound: 2, + maxRounds: 5, + issuesPerRound: [{ round: 2, newCount: 1, fixedCount: 0, dismissedCount: 0 }], + totalNew: 1, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: true, + }, + }; + const runtime = { + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "waiting_for_comments", + currentRound: 2, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + pauseReason: null, + errorMessage: null, + lastStartedAt: "2026-03-16T00:00:00.000Z", + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }; + const pipelineSettings = { + autoMerge: true, + mergeMethod: "squash", + maxRounds: 4, + onRebaseNeeded: "pause", + }; + const deps = buildDeps({ + prService: { + listAll: vi.fn().mockReturnValue([issueFixture, { ...issueFixture, id: "pr-1", title: "Path to merge", laneId: "lane-1" }]), + getStatus: vi.fn().mockResolvedValue({ + prId: "pr-1", + state: "open", + checksStatus: "pending", + reviewStatus: "changes_requested", + isMergeable: false, + mergeConflicts: false, + behindBaseBy: 0, + }), + getChecks: vi.fn().mockResolvedValue([{ name: "CI", status: "completed", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }]), + getReviewThreads: vi.fn().mockResolvedValue([{ id: "thread-1", isResolved: false, isOutdated: false, path: "src/app.ts", line: 12, originalLine: 12, startLine: null, originalStartLine: null, diffSide: "RIGHT", url: "https://github.com/acme/repo/pull/1#discussion_r1", createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z", comments: [{ id: "c-1", author: "Reviewer", authorAvatarUrl: null, body: "Please handle null.", url: null, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }, { id: "c-2", author: "Reviewer", authorAvatarUrl: null, body: "Still needs a guard.", url: null, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }] }]), + getComments: vi.fn().mockResolvedValue([{ id: "comment-1", author: "Reviewer", authorAvatarUrl: null, body: "Please handle null.", source: "issue", url: null, path: "src/app.ts", line: 12, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }]), + } as any, + issueInventoryService: { + syncFromPrData: vi.fn().mockReturnValue(snapshot), + getConvergenceRuntime: vi.fn().mockReturnValue(runtime), + getPipelineSettings: vi.fn().mockReturnValue(pipelineSettings), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getPullRequestConvergence as any).execute({ prId: "pr-1" }); + + expect((deps.issueInventoryService as any).syncFromPrData).toHaveBeenCalledWith( + "pr-1", + expect.any(Array), + expect.any(Array), + expect.any(Array), + ); + expect(result).toMatchObject({ + success: true, + pr: expect.objectContaining({ id: "pr-1", title: "Path to merge" }), + runtime, + pipelineSettings, + inventory: { + summary: expect.objectContaining({ + currentRound: 2, + totalNew: 1, + }), + items: [expect.objectContaining({ + id: "item-1", + latestComment: expect.objectContaining({ id: "c-2", author: "Reviewer" }), + })], + }, + }); + }); + + it("updates convergence pipeline and runtime state", async () => { + const deps = buildDeps({ + issueInventoryService: { + savePipelineSettings: vi.fn(), + getPipelineSettings: vi.fn().mockReturnValue({ + autoMerge: true, + mergeMethod: "merge", + maxRounds: 3, + onRebaseNeeded: "auto_rebase", + }), + saveConvergenceRuntime: vi.fn().mockReturnValue({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const pipeline = await (tools.updatePullRequestConvergencePipeline as any).execute({ + prId: "pr-1", + autoMerge: true, + mergeMethod: "merge", + maxRounds: 3, + onRebaseNeeded: "auto_rebase", + }); + const runtime = await (tools.updatePullRequestConvergenceRuntime as any).execute({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + }); + + expect((deps.issueInventoryService as any).savePipelineSettings).toHaveBeenCalledWith("pr-1", { + autoMerge: true, + mergeMethod: "merge", + maxRounds: 3, + onRebaseNeeded: "auto_rebase", + }); + expect((deps.issueInventoryService as any).saveConvergenceRuntime).toHaveBeenCalledWith("pr-1", expect.objectContaining({ + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + })); + expect(pipeline).toMatchObject({ success: true, pipelineSettings: { autoMerge: true, mergeMethod: "merge" } }); + expect(runtime).toMatchObject({ success: true, runtime: expect.objectContaining({ currentRound: 3 }) }); + }); + + it("launches and stops a PR convergence round through chat services", async () => { + const deps = buildDeps({ + prService: { + listAll: vi.fn().mockReturnValue([{ ...issueFixture, id: "pr-1", laneId: "lane-1" }]), + getStatus: vi.fn().mockResolvedValue({ + prId: "pr-1", + state: "open", + checksStatus: "failing", + reviewStatus: "changes_requested", + isMergeable: false, + mergeConflicts: false, + behindBaseBy: 0, + }), + getChecks: vi.fn().mockResolvedValue([]), + getReviewThreads: vi.fn().mockResolvedValue([{ id: "thread-1", isResolved: false, isOutdated: false, path: "src/app.ts", line: 12, originalLine: 12, startLine: null, originalStartLine: null, diffSide: "RIGHT", url: "https://github.com/acme/repo/pull/1#discussion_r1", createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z", comments: [{ id: "c-1", author: "Reviewer", authorAvatarUrl: null, body: "Please handle null.", url: null, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }] }]), + getComments: vi.fn().mockResolvedValue([]), + getDetail: vi.fn().mockResolvedValue(null), + getFiles: vi.fn().mockResolvedValue([]), + getActionRuns: vi.fn().mockResolvedValue([]), + } as any, + laneService: { + list: vi.fn().mockResolvedValue([{ id: "lane-1", worktreePath: "/tmp", archivedAt: null }]), + getLaneBaseAndBranch: vi.fn().mockResolvedValue({ baseBranch: "main", headBranch: "feature" }), + } as any, + issueInventoryService: { + syncFromPrData: vi.fn().mockReturnValue({ + prId: "pr-1", + items: [ + { + id: "inventory-item-1", + prId: "pr-1", + source: "unknown", + type: "review_thread", + externalId: "review-thread:thread-1", + state: "new", + round: 1, + filePath: "src/app.ts", + line: 12, + severity: "major", + headline: "Please handle null.", + body: "Please handle null.", + author: "Reviewer", + url: "https://github.com/acme/repo/pull/1#discussion_r1", + dismissReason: null, + agentSessionId: null, + threadCommentCount: 1, + threadLatestCommentId: "c-1", + threadLatestCommentAuthor: "Reviewer", + threadLatestCommentAt: "2026-03-16T00:00:00.000Z", + threadLatestCommentSource: "unknown", + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }, + ], + convergence: { + currentRound: 1, + maxRounds: 5, + issuesPerRound: [{ round: 1, newCount: 1, fixedCount: 0, dismissedCount: 0 }], + totalNew: 1, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: true, + }, + }), + getNewItems: vi.fn().mockReturnValue([{ id: "inventory-item-1" }]), + markSentToAgent: vi.fn(), + getConvergenceRuntime: vi.fn().mockReturnValue({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "waiting_for_comments", + currentRound: 1, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }), + getPipelineSettings: vi.fn().mockReturnValue({ + autoMerge: true, + mergeMethod: "merge", + maxRounds: 5, + onRebaseNeeded: "pause", + }), + saveConvergenceRuntime: vi.fn().mockReturnValue({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "waiting_for_comments", + currentRound: 2, + activeSessionId: "session-2", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + pauseReason: null, + errorMessage: null, + lastStartedAt: "2026-03-16T00:00:00.000Z", + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }), + } as any, + sessionService: { + updateMeta: vi.fn(), + } as any, + createChat: vi.fn().mockResolvedValue({ ...baseSession, id: "session-2", laneId: "lane-1" }), + sendChatMessage: vi.fn().mockResolvedValue(undefined), + interruptChat: vi.fn().mockResolvedValue(undefined), + }); + const tools = createCtoOperatorTools(deps); + + const started = await (tools.startPullRequestConvergenceRound as any).execute({ + prId: "pr-1", + scope: "comments", + modelId: "openai/gpt-5.4", + additionalInstructions: "Be concise.", + }); + const stopped = await (tools.stopPullRequestConvergence as any).execute({ + prId: "pr-1", + sessionId: "session-2", + reason: "Stop for now.", + }); + + expect(started).toMatchObject({ success: true }); + expect((deps.issueInventoryService as any).markSentToAgent).toHaveBeenCalledWith( + "pr-1", + ["inventory-item-1"], + "session-2", + 2, + ); + expect((deps.interruptChat as any)).toHaveBeenCalledWith({ sessionId: "session-2" }); + expect((deps.issueInventoryService as any).saveConvergenceRuntime).toHaveBeenCalledWith("pr-1", expect.objectContaining({ + status: "stopped", + pollerStatus: "stopped", + autoConvergeEnabled: false, + })); + expect(started).toMatchObject({ + success: true, + sessionId: "session-2", + laneId: "lane-1", + href: "/work?laneId=lane-1&sessionId=session-2", + }); + expect(stopped).toMatchObject({ + success: true, + sessionId: "session-2", + }); + }); }); // ── Linear workflow tools ─────────────────────────────────────── diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 2001d9ba5..7907a8b3e 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -18,6 +18,18 @@ import type { TestRunSummary, TestSuiteDefinition, } from "../../../../shared/types"; +import type { + ConvergenceRuntimeState, + ConvergenceStatus, + ConvergenceRoundStat, + IssueInventoryItem, + PipelineSettings, + PrCheck, + PrComment, + PrReviewThread, + PrSummary, +} from "../../../../shared/types/prs"; +import { DEFAULT_PIPELINE_SETTINGS } from "../../../../shared/types/prs"; import type { IssueTracker } from "../../cto/issueTracker"; import type { createLinearDispatcherService } from "../../cto/linearDispatcherService"; import type { createWorkerAgentService } from "../../cto/workerAgentService"; @@ -27,9 +39,12 @@ import type { createFileService } from "../../files/fileService"; import type { createLaneService } from "../../lanes/laneService"; import type { createMissionService } from "../../missions/missionService"; import type { createAiOrchestratorService } from "../../orchestrator/aiOrchestratorService"; +import type { createIssueInventoryService } from "../../prs/issueInventoryService"; +import { previewPrIssueResolutionPrompt } from "../../prs/prIssueResolver"; import type { createPrService } from "../../prs/prService"; import type { createProcessService } from "../../processes/processService"; -import { getErrorMessage } from "../../shared/utils"; +import type { createSessionService } from "../../sessions/sessionService"; +import { getErrorMessage, nowIso } from "../../shared/utils"; export interface CtoOperatorToolDeps { currentSessionId: string; @@ -50,8 +65,10 @@ export interface CtoOperatorToolDeps { linearDispatcherService?: ReturnType | null; flowPolicyService?: ReturnType | null; prService?: ReturnType | null; + issueInventoryService?: ReturnType | null; fileService?: ReturnType | null; processService?: ReturnType | null; + sessionService: Pick, "updateMeta">; testService?: { listSuites: () => TestSuiteDefinition[]; run: (args: { laneId: string; suiteId: string }) => Promise; @@ -253,6 +270,309 @@ function resolveWorkspaceIdForLane( throw new Error(`Workspace not found for lane ${laneId}.`); } +function extractSeverityFromText(text: string | null | undefined): IssueInventoryItem["severity"] { + const match = String(text ?? "").match(/\b(Critical|Major|Minor)\b/i); + if (!match?.[1]) return null; + return match[1].toLowerCase() as IssueInventoryItem["severity"]; +} + +function truncateForHeadline(text: string, max = 140): string { + const normalized = text.trim().replace(/\s+/g, " "); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +function summarizeInventoryItems(items: IssueInventoryItem[], maxRounds: number): ConvergenceStatus { + let totalNew = 0; + let totalFixed = 0; + let totalDismissed = 0; + let totalEscalated = 0; + let totalSentToAgent = 0; + let currentRound = 0; + const roundMap = new Map(); + + for (const item of items) { + switch (item.state) { + case "new": + totalNew++; + break; + case "fixed": + totalFixed++; + break; + case "dismissed": + totalDismissed++; + break; + case "escalated": + totalEscalated++; + break; + case "sent_to_agent": + totalSentToAgent++; + break; + } + + if (item.round > currentRound) currentRound = item.round; + if (item.round <= 0) continue; + + const stat = roundMap.get(item.round) ?? { round: item.round, newCount: 0, fixedCount: 0, dismissedCount: 0 }; + switch (item.state) { + case "new": + case "sent_to_agent": + stat.newCount++; + break; + case "fixed": + stat.fixedCount++; + break; + case "dismissed": + stat.dismissedCount++; + break; + } + roundMap.set(item.round, stat); + } + + const issuesPerRound = Array.from(roundMap.values()).sort((a, b) => a.round - b.round); + const lastRoundStat = issuesPerRound.at(-1); + const isConverging = lastRoundStat != null && (lastRoundStat.fixedCount + lastRoundStat.dismissedCount) > 0; + const canAutoAdvance = totalNew > 0 && currentRound < maxRounds; + + return { + currentRound, + maxRounds, + issuesPerRound, + totalNew, + totalFixed, + totalDismissed, + totalEscalated, + totalSentToAgent, + isConverging, + canAutoAdvance, + }; +} + +function buildFallbackInventoryItems(args: { + prId: string; + checks: PrCheck[]; + reviewThreads: PrReviewThread[]; + comments: PrComment[]; +}): IssueInventoryItem[] { + const items: IssueInventoryItem[] = []; + const timestamp = nowIso(); + + for (const thread of args.reviewThreads) { + const latestComment = thread.comments.at(-1) ?? thread.comments[0] ?? null; + const headlineSource = latestComment?.body?.trim() || thread.comments[0]?.body?.trim() || thread.path || `Review thread ${thread.id}`; + const body = thread.comments + .map((entry) => entry.body?.trim() || "") + .filter(Boolean) + .join("\n\n") + .trim() || null; + items.push({ + id: `transient-thread-${thread.id}`, + prId: args.prId, + source: "unknown", + type: "review_thread", + externalId: `review-thread:${thread.id}`, + state: thread.isResolved || thread.isOutdated ? "fixed" : "new", + round: 0, + filePath: thread.path, + line: thread.line, + severity: extractSeverityFromText(body ?? headlineSource), + headline: truncateForHeadline(headlineSource), + body, + author: latestComment?.author ?? null, + url: thread.url, + dismissReason: null, + agentSessionId: null, + threadCommentCount: thread.comments.length, + threadLatestCommentId: latestComment?.id ?? null, + threadLatestCommentAuthor: latestComment?.author ?? null, + threadLatestCommentAt: latestComment?.createdAt ?? null, + threadLatestCommentSource: "unknown", + createdAt: thread.createdAt ?? timestamp, + updatedAt: thread.updatedAt ?? thread.createdAt ?? timestamp, + }); + } + + for (const comment of args.comments) { + if (comment.source !== "issue") continue; + const body = comment.body?.trim() || null; + items.push({ + id: `transient-comment-${comment.id}`, + prId: args.prId, + source: "unknown", + type: "issue_comment", + externalId: `issue-comment:${comment.id}`, + state: "new", + round: 0, + filePath: comment.path, + line: comment.line, + severity: extractSeverityFromText(body), + headline: body ? truncateForHeadline(body) : `Issue comment by ${comment.author}`, + body, + author: comment.author, + url: comment.url, + dismissReason: null, + agentSessionId: null, + threadCommentCount: null, + threadLatestCommentId: null, + threadLatestCommentAuthor: null, + threadLatestCommentAt: null, + threadLatestCommentSource: null, + createdAt: comment.createdAt ?? timestamp, + updatedAt: comment.updatedAt ?? comment.createdAt ?? timestamp, + }); + } + + for (const check of args.checks) { + if (check.conclusion !== "failure") continue; + const timestampValue = check.completedAt ?? check.startedAt ?? timestamp; + items.push({ + id: `transient-check-${check.name}`, + prId: args.prId, + source: "unknown", + type: "check_failure", + externalId: `check:${check.name}`, + state: "new", + round: 0, + filePath: null, + line: null, + severity: "major", + headline: `Check failed: ${check.name}`, + body: check.detailsUrl ? `Details: ${check.detailsUrl}` : null, + author: null, + url: check.detailsUrl, + dismissReason: null, + agentSessionId: null, + threadCommentCount: null, + threadLatestCommentId: null, + threadLatestCommentAuthor: null, + threadLatestCommentAt: null, + threadLatestCommentSource: null, + createdAt: timestampValue, + updatedAt: timestampValue, + }); + } + + return items; +} + +function mapInventoryItemView(item: IssueInventoryItem) { + return { + id: item.id, + externalId: item.externalId, + type: item.type, + state: item.state, + source: item.source, + round: item.round, + filePath: item.filePath, + line: item.line, + severity: item.severity, + headline: item.headline, + body: item.body, + author: item.author, + url: item.url, + dismissReason: item.dismissReason, + agentSessionId: item.agentSessionId, + threadCommentCount: item.threadCommentCount, + latestComment: item.threadLatestCommentId + ? { + id: item.threadLatestCommentId, + author: item.threadLatestCommentAuthor ?? null, + at: item.threadLatestCommentAt ?? null, + source: item.threadLatestCommentSource ?? null, + } + : null, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }; +} + +function buildRuntimePatch(input: { + autoConvergeEnabled?: boolean | null; + autoConverge?: boolean | null; + status?: ConvergenceRuntimeState["status"]; + pollerStatus?: ConvergenceRuntimeState["pollerStatus"]; + currentRound?: number | null; + activeSessionId?: string | null; + activeLaneId?: string | null; + activeHref?: string | null; + pauseReason?: string | null; + errorMessage?: string | null; + lastStartedAt?: string | null; + lastPolledAt?: string | null; + lastPausedAt?: string | null; + lastStoppedAt?: string | null; +}): Partial { + const patch: Partial = {}; + const autoConvergeEnabled = input.autoConvergeEnabled ?? input.autoConverge; + if (autoConvergeEnabled !== undefined && autoConvergeEnabled !== null) { + patch.autoConvergeEnabled = autoConvergeEnabled; + } + if (input.status !== undefined) patch.status = input.status; + if (input.pollerStatus !== undefined) patch.pollerStatus = input.pollerStatus; + if (input.currentRound !== undefined && input.currentRound !== null) patch.currentRound = input.currentRound; + if (input.activeSessionId !== undefined) patch.activeSessionId = input.activeSessionId; + if (input.activeLaneId !== undefined) patch.activeLaneId = input.activeLaneId; + if (input.activeHref !== undefined) patch.activeHref = input.activeHref; + if (input.pauseReason !== undefined) patch.pauseReason = input.pauseReason; + if (input.errorMessage !== undefined) patch.errorMessage = input.errorMessage; + if (input.lastStartedAt !== undefined) patch.lastStartedAt = input.lastStartedAt; + if (input.lastPolledAt !== undefined) patch.lastPolledAt = input.lastPolledAt; + if (input.lastPausedAt !== undefined) patch.lastPausedAt = input.lastPausedAt; + if (input.lastStoppedAt !== undefined) patch.lastStoppedAt = input.lastStoppedAt; + return patch; +} + +async function loadPrConvergenceContext( + deps: Pick, + prId: string, +): Promise<{ + pr: PrSummary; + status: Awaited["getStatus"]>>; + runtime: ConvergenceRuntimeState | null; + pipelineSettings: PipelineSettings; + inventory: { items: IssueInventoryItem[]; summary: ConvergenceStatus }; + persistedInventory: boolean; +}> { + if (!deps.prService) throw new Error("PR service is not available."); + const pr = deps.prService.listAll().find((entry) => entry.id === prId) ?? null; + if (!pr) throw new Error(`PR not found: ${prId}`); + + const [status, checks, reviewThreads, comments] = await Promise.all([ + deps.prService.getStatus(prId), + deps.prService.getChecks(prId), + deps.prService.getReviewThreads(prId), + deps.prService.getComments(prId), + ]); + + if (deps.issueInventoryService) { + const snapshot = deps.issueInventoryService.syncFromPrData(prId, checks, reviewThreads, comments); + return { + pr, + status, + runtime: deps.issueInventoryService.getConvergenceRuntime(prId), + pipelineSettings: deps.issueInventoryService.getPipelineSettings(prId), + inventory: { + items: snapshot.items, + summary: snapshot.convergence, + }, + persistedInventory: true, + }; + } + + const items = buildFallbackInventoryItems({ prId, checks, reviewThreads, comments }); + return { + pr, + status, + runtime: null, + pipelineSettings: { ...DEFAULT_PIPELINE_SETTINGS }, + inventory: { + items, + summary: summarizeInventoryItems(items, DEFAULT_PIPELINE_SETTINGS.maxRounds), + }, + persistedInventory: false, + }; +} + export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { const tools: Record = {}; @@ -1135,6 +1455,279 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + const context = await loadPrConvergenceContext(deps, prId); + return { + success: true, + pr: context.pr, + status: context.status, + runtime: context.runtime, + pipelineSettings: context.pipelineSettings, + persistedInventory: context.persistedInventory, + inventory: { + summary: context.inventory.summary, + items: context.inventory.items.map(mapInventoryItemView), + }, + ...(context.runtime?.activeSessionId ? buildNavigationPayload(buildNavigationSuggestion({ + surface: "work", + laneId: context.runtime.activeLaneId ?? context.pr.laneId, + sessionId: context.runtime.activeSessionId, + })) : {}), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.updatePullRequestConvergencePipeline = tool({ + description: "Edit the persisted PR convergence pipeline settings for auto-merge and round handling.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + autoMerge: z.boolean().optional(), + mergeMethod: z.enum(["merge", "squash", "rebase", "repo_default"]).optional(), + maxRounds: z.number().int().positive().max(20).optional(), + onRebaseNeeded: z.enum(["pause", "auto_rebase"]).optional(), + }), + execute: async ({ prId, autoMerge, mergeMethod, maxRounds, onRebaseNeeded }) => { + if (!deps.issueInventoryService) { + return { success: false, error: "Issue inventory service is not available." }; + } + try { + const hasPatch = autoMerge !== undefined || mergeMethod !== undefined || maxRounds !== undefined || onRebaseNeeded !== undefined; + if (!hasPatch) { + return { success: false, error: "No pipeline fields were provided." }; + } + deps.issueInventoryService.savePipelineSettings(prId, { + ...(autoMerge !== undefined ? { autoMerge } : {}), + ...(mergeMethod !== undefined ? { mergeMethod } : {}), + ...(maxRounds !== undefined ? { maxRounds } : {}), + ...(onRebaseNeeded !== undefined ? { onRebaseNeeded } : {}), + }); + return { + success: true, + prId, + pipelineSettings: deps.issueInventoryService.getPipelineSettings(prId), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.updatePullRequestConvergenceRuntime = tool({ + description: "Edit the persisted PR convergence runtime object that tracks status, session, and polling state.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + autoConvergeEnabled: z.boolean().optional(), + autoConverge: z.boolean().optional(), + status: z.enum(["idle", "launching", "running", "polling", "paused", "converged", "merged", "failed", "cancelled", "stopped"]).optional(), + pollerStatus: z.enum(["idle", "scheduled", "polling", "waiting_for_checks", "waiting_for_comments", "paused", "stopped"]).optional(), + currentRound: z.number().int().min(0).optional(), + activeSessionId: z.string().nullable().optional(), + activeLaneId: z.string().nullable().optional(), + activeHref: z.string().nullable().optional(), + pauseReason: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + lastStartedAt: z.string().nullable().optional(), + lastPolledAt: z.string().nullable().optional(), + lastPausedAt: z.string().nullable().optional(), + lastStoppedAt: z.string().nullable().optional(), + }), + execute: async (input) => { + if (!deps.issueInventoryService) { + return { success: false, error: "Issue inventory service is not available." }; + } + try { + const patch = buildRuntimePatch(input); + if (Object.keys(patch).length === 0) { + return { success: false, error: "No runtime fields were provided." }; + } + const runtime = deps.issueInventoryService.saveConvergenceRuntime(input.prId, patch); + return { success: true, prId: input.prId, runtime }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.startPullRequestConvergenceRound = tool({ + description: "Launch the next PR convergence round through the existing PR issue-resolution workflow.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + scope: z.enum(["checks", "comments", "both"]).optional().default("both"), + modelId: z.string().trim().min(1).optional(), + reasoning: z.string().nullable().optional(), + permissionMode: z.enum(["read_only", "guarded_edit", "full_edit"]).optional(), + additionalInstructions: z.string().nullable().optional(), + autoConvergeEnabled: z.boolean().optional().default(true), + }), + execute: async ({ prId, scope, modelId, reasoning, permissionMode, additionalInstructions, autoConvergeEnabled }) => { + if (!deps.prService) { + return { success: false, error: "PR service is not available." }; + } + try { + const resolvedModelId = modelId?.trim() || deps.defaultModelId?.trim() || null; + if (!resolvedModelId) { + return { success: false, error: "A modelId is required to launch a convergence round." }; + } + + const context = deps.issueInventoryService + ? await loadPrConvergenceContext(deps, prId) + : null; + const pr = context?.pr ?? deps.prService.listAll().find((entry) => entry.id === prId) ?? null; + if (!pr) { + return { success: false, error: `PR not found: ${prId}` }; + } + + const prepared = await previewPrIssueResolutionPrompt( + { + prService: deps.prService, + laneService: { + list: deps.laneService.list, + getLaneBaseAndBranch: deps.laneService.getLaneBaseAndBranch, + }, + agentChatService: { + createSession: deps.createChat, + sendMessage: deps.sendChatMessage, + }, + sessionService: deps.sessionService, + issueInventoryService: deps.issueInventoryService ?? null, + }, + { + prId, + scope, + modelId: resolvedModelId, + reasoning: reasoning ?? deps.defaultReasoningEffort ?? null, + permissionMode, + additionalInstructions: additionalInstructions ?? null, + }, + ); + + const session = await deps.createChat({ + laneId: pr.laneId, + provider: "unified", + model: resolvedModelId, + ...(resolvedModelId ? { modelId: resolvedModelId } : {}), + reasoningEffort: reasoning ?? deps.defaultReasoningEffort ?? null, + surface: "work", + sessionProfile: "workflow", + }); + + await deps.sessionService.updateMeta({ + sessionId: session.id, + title: prepared.title, + }); + await deps.sendChatMessage({ + sessionId: session.id, + text: prepared.prompt, + displayText: prepared.title, + reasoningEffort: reasoning ?? deps.defaultReasoningEffort ?? null, + }); + + let runtime: ConvergenceRuntimeState | null = null; + if (deps.issueInventoryService) { + const roundNumber = (context?.inventory.summary.currentRound ?? 0) + 1; + const inventoryNewItems = deps.issueInventoryService.getNewItems(prId); + if (inventoryNewItems.length > 0) { + deps.issueInventoryService.markSentToAgent( + prId, + inventoryNewItems.map((item) => item.id), + session.id, + roundNumber, + ); + } + runtime = deps.issueInventoryService.saveConvergenceRuntime(prId, { + autoConvergeEnabled, + status: "running", + pollerStatus: "waiting_for_comments", + currentRound: roundNumber, + activeSessionId: session.id, + activeLaneId: session.laneId, + activeHref: `/work?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(session.id)}`, + pauseReason: null, + errorMessage: null, + lastStartedAt: nowIso(), + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + }); + } + + return { + success: true, + prId, + sessionId: session.id, + laneId: session.laneId, + href: `/work?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(session.id)}`, + runtime, + ...buildNavigationPayload(buildNavigationSuggestion({ + surface: "work", + laneId: session.laneId, + sessionId: session.id, + })), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.stopPullRequestConvergence = tool({ + description: "Stop an active PR convergence run, interrupt the chat session, and persist the stopped runtime state.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + sessionId: z.string().trim().min(1).optional(), + reason: z.string().nullable().optional(), + }), + execute: async ({ prId, sessionId, reason }) => { + try { + const runtime = deps.issueInventoryService?.getConvergenceRuntime(prId) ?? null; + const activeSessionId = sessionId?.trim() || runtime?.activeSessionId || null; + if (!activeSessionId) { + return { success: false, error: "No active convergence session was found to stop." }; + } + await deps.interruptChat({ sessionId: activeSessionId }); + + if (deps.issueInventoryService) { + const nextRuntime = deps.issueInventoryService.saveConvergenceRuntime(prId, { + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: reason?.trim() || null, + errorMessage: null, + lastStoppedAt: nowIso(), + }); + return { + success: true, + prId, + sessionId: activeSessionId, + runtime: nextRuntime, + }; + } + + return { + success: true, + prId, + sessionId: activeSessionId, + runtime: null, + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + tools.listFileWorkspaces = tool({ description: "List ADE file workspaces so the CTO can inspect files by lane or attached workspace.", inputSchema: z.object({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 89031fef9..7b7562573 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -141,6 +141,7 @@ import type { createLinearDispatcherService } from "../cto/linearDispatcherServi import type { LinearClient } from "../cto/linearClient"; import type { LinearCredentialService } from "../cto/linearCredentialService"; import type { createPrService } from "../prs/prService"; +import type { createIssueInventoryService } from "../prs/issueInventoryService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { createProofObserver } from "../computerUse/proofObserver"; import { maybeSyntheticToolResult } from "../computerUse/syntheticToolResult"; @@ -2087,6 +2088,7 @@ export function createAgentChatService(args: { linearClient?: LinearClient | null; linearCredentials?: LinearCredentialService | null; prService?: ReturnType | null; + issueInventoryService?: ReturnType | null; processService?: ReturnType | null; getTestService?: () => { listSuites: () => any[]; run: (args: any) => Promise; stop: (args: any) => void; listRuns: (args?: any) => any[]; getLogTail: (args: any) => string } | null; ptyService?: { create: (args: any) => Promise<{ ptyId: string; sessionId: string }> } | null; @@ -2120,6 +2122,7 @@ export function createAgentChatService(args: { linearClient: linearClientRef, linearCredentials: linearCredentialsRef, prService, + issueInventoryService, processService, getTestService, ptyService, @@ -6302,6 +6305,7 @@ export function createAgentChatService(args: { linearDispatcherService: getLinearDispatcherService?.() ?? null, flowPolicyService: flowPolicyService ?? null, prService: prService ?? null, + issueInventoryService: issueInventoryService ?? null, fileService: fileService ?? null, processService: processService ?? null, testService: getTestService?.() ?? null, @@ -6317,6 +6321,7 @@ export function createAgentChatService(args: { interruptChat: interrupt, resumeChat: resumeSession, disposeChat: dispose, + sessionService, ensureCtoSession: async ({ laneId, modelId, reasoningEffort, reuseExisting }) => ensureIdentitySession({ identityKey: "cto", diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b87cc4950..d67f1dd1d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -129,6 +129,7 @@ import type { PrIssueResolutionStartResult, IssueInventoryItem, IssueInventorySnapshot, + ConvergenceRuntimeState, ConvergenceStatus, PipelineSettings, RebaseResolutionStartArgs, @@ -5018,15 +5019,33 @@ export function registerIpc({ ipcMain.handle(IPC.prsIssueResolutionStart, async (_event, arg: PrIssueResolutionStartArgs): Promise => { const ctx = getCtx(); - return await launchPrIssueResolutionChat( + const result = await launchPrIssueResolutionChat( { prService: ctx.prService, laneService: ctx.laneService, agentChatService: ctx.agentChatService, sessionService: ctx.sessionService, + issueInventoryService: ctx.issueInventoryService, }, arg, ); + try { + const status = ctx.issueInventoryService.getConvergenceStatus(arg.prId); + ctx.issueInventoryService.saveConvergenceRuntime(arg.prId, { + currentRound: status.currentRound, + status: "running", + pollerStatus: "idle", + activeSessionId: result.sessionId, + activeLaneId: result.laneId, + activeHref: result.href, + lastStartedAt: nowIso(), + errorMessage: null, + pauseReason: null, + }); + } catch { + // Best-effort persistence only. + } + return result; }); ipcMain.handle(IPC.prsIssueResolutionPreviewPrompt, async ( @@ -5040,6 +5059,7 @@ export function registerIpc({ laneService: ctx.laneService, agentChatService: ctx.agentChatService, sessionService: ctx.sessionService, + issueInventoryService: ctx.issueInventoryService, }, arg, ); @@ -5100,6 +5120,13 @@ export function registerIpc({ ipcMain.handle(IPC.prsIssueInventoryReset, (_e, args: { prId: string }): void => getCtx().issueInventoryService.resetInventory(args.prId)); + ipcMain.handle(IPC.prsConvergenceStateGet, (_e, args: { prId: string }): ConvergenceRuntimeState => + getCtx().issueInventoryService.getConvergenceRuntime(args.prId)); + ipcMain.handle(IPC.prsConvergenceStateSave, (_e, args: { prId: string; state: Partial }): ConvergenceRuntimeState => + getCtx().issueInventoryService.saveConvergenceRuntime(args.prId, args.state)); + ipcMain.handle(IPC.prsConvergenceStateDelete, (_e, args: { prId: string }): void => + getCtx().issueInventoryService.resetConvergenceRuntime(args.prId)); + ipcMain.handle(IPC.prsPipelineSettingsGet, (_e, args: { prId: string }): PipelineSettings => getCtx().issueInventoryService.getPipelineSettings(args.prId)); ipcMain.handle(IPC.prsPipelineSettingsSave, (_e, args: { prId: string; settings: Partial }): void => diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts index 226f59aa1..7a88e2338 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts @@ -102,12 +102,39 @@ function makeFakeRow(overrides: Record = {}) { url: "https://example.com/thread/1", dismiss_reason: null, agent_session_id: null, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + thread_latest_comment_author: "reviewer", + thread_latest_comment_at: "2026-03-23T12:00:00.000Z", + thread_latest_comment_source: "unknown", created_at: "2026-03-23T12:00:00.000Z", updated_at: "2026-03-23T12:00:00.000Z", ...overrides, }; } +function makeRuntimeRow(overrides: Record = {}) { + return { + pr_id: PR_ID, + auto_converge_enabled: 1, + status: "running", + poller_status: "waiting_for_comments", + current_round: 2, + active_session_id: "session-1", + active_lane_id: "lane-1", + active_href: "/work?laneId=lane-1&sessionId=session-1", + pause_reason: null, + error_message: null, + last_started_at: "2026-03-23T12:00:00.000Z", + last_polled_at: "2026-03-23T12:01:00.000Z", + last_paused_at: null, + last_stopped_at: null, + created_at: "2026-03-23T11:59:00.000Z", + updated_at: "2026-03-23T12:01:00.000Z", + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Tests — syncFromPrData // --------------------------------------------------------------------------- @@ -146,7 +173,7 @@ describe("issueInventoryService", () => { expect(insertArgs[2]).toBe("unknown"); // source (checks have unknown source) expect(insertArgs[3]).toBe("check_failure"); // type expect(insertArgs[4]).toBe('check:ci / lint'); // externalId - expect(insertArgs[7]).toBe("major"); // severity + expect(insertArgs[9]).toBe("major"); // severity // Result snapshot expect(result.prId).toBe(PR_ID); @@ -195,6 +222,53 @@ describe("issueInventoryService", () => { expect(args[3]).toBe("review_thread"); // type }); + it("tracks the latest reply in a review thread", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-latest", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "**Minor** Initial concern.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + { + id: "comment-2", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: "**Major** This still needs a fix.", + url: "https://example.com/comment/2", + createdAt: "2026-03-23T12:02:00.000Z", + updatedAt: "2026-03-23T12:02:00.000Z", + }, + ], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(insertCalls.length).toBe(1); + const args = insertCalls[0][1] as unknown[]; + expect(args[11]).toContain("This still needs a fix."); + expect(args[12]).toBe("coderabbitai[bot]"); + expect(args[16]).toBe(2); + expect(args[17]).toBe("comment-2"); + expect(args[20]).toBe("coderabbit"); + }); + it("skips resolved review threads", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -233,6 +307,112 @@ describe("issueInventoryService", () => { expect(insertCalls.length).toBe(0); }); + it("marks previously tracked resolved threads as fixed", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-resolved", + state: "sent_to_agent", + round: 2, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })); + db.all.mockReturnValue([makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-resolved", + state: "fixed", + round: 2, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-resolved", + isResolved: true, + comments: [{ + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Resolved in code.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + const params = updateCalls[0][1] as unknown[]; + expect(params[8]).toBe("fixed"); + }); + + it("reopens a thread as new when a new reply appears", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-reopened", + state: "sent_to_agent", + round: 2, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })); + db.all.mockReturnValue([makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-reopened", + state: "new", + round: 2, + thread_comment_count: 2, + thread_latest_comment_id: "comment-2", + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-reopened", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Initial thread comment.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + { + id: "comment-2", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: "This still needs attention.", + url: null, + createdAt: "2026-03-23T12:03:00.000Z", + updatedAt: "2026-03-23T12:03:00.000Z", + }, + ], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + const params = updateCalls[0][1] as unknown[]; + expect(params[8]).toBe("new"); + expect(params[11]).toBeNull(); + expect(params[12]).toBe(2); + }); + it("detects coderabbit bot as source from review thread author", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -324,7 +504,7 @@ describe("issueInventoryService", () => { expect(args[2]).toBe("copilot"); // source }); - it("maps unknown authors as human source", () => { + it("maps unmatched human authors as human source", () => { const db = makeMockDb(); db.get.mockReturnValue(null); db.all.mockReturnValue([]); @@ -354,6 +534,36 @@ describe("issueInventoryService", () => { expect(args[2]).toBe("human"); // source }); + it("maps unrecognized bot authors as unknown source", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "thread-comment-1", + author: "greptile-review[bot]", + authorAvatarUrl: null, + body: "Potential issue in this block.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[2]).toBe("unknown"); // source + }); + it("extracts severity from bold keywords (Critical/Major/Minor)", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -381,7 +591,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); // severity + expect(args[9]).toBe("critical"); // severity }); it("extracts severity from P1/P2/P3 labels", () => { @@ -414,7 +624,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("major"); // severity from P2 + expect(args[9]).toBe("major"); // severity from P2 }); it("extracts severity from emoji indicators", () => { @@ -444,7 +654,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); + expect(args[9]).toBe("critical"); }); it("extracts severity from bracket patterns like [bug]", () => { @@ -474,7 +684,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("major"); // [warning] -> major + expect(args[9]).toBe("major"); // [warning] -> major }); it("extracts headline from bold title in body", () => { @@ -505,7 +715,7 @@ describe("issueInventoryService", () => { ); const args = insertCalls[0][1] as unknown[]; // The headline should be the extracted bold title, cleaned of emoji noise - const headline = args[8] as string; + const headline = args[10] as string; expect(headline).toContain("Derive"); expect(headline).toContain("assistantLabel"); }); @@ -537,7 +747,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - const headline = args[8] as string; + const headline = args[10] as string; expect(headline).toContain("Consider using a Map"); }); @@ -987,6 +1197,80 @@ describe("issueInventoryService", () => { }); }); + // --------------------------------------------------------------------------- + // Tests — convergence runtime state + // --------------------------------------------------------------------------- + + describe("convergence runtime state", () => { + it("returns convergence runtime state from the database", () => { + const db = makeMockDb(); + db.get.mockImplementation((sql: string) => { + if (sql.includes("from pr_convergence_state")) { + return makeRuntimeRow({ + auto_converge_enabled: 0, + status: "polling", + poller_status: "waiting_for_checks", + }); + } + return null; + }); + + const service = createIssueInventoryService({ db }); + const runtime = service.getConvergenceRuntime(PR_ID); + + expect(runtime).toEqual(expect.objectContaining({ + prId: PR_ID, + autoConvergeEnabled: false, + status: "polling", + pollerStatus: "waiting_for_checks", + })); + }); + + it("upserts convergence runtime state", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + const runtime = service.saveConvergenceRuntime(PR_ID, { + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + activeSessionId: "session-9", + activeLaneId: "lane-9", + activeHref: "/work?laneId=lane-9&sessionId=session-9", + pauseReason: null, + errorMessage: null, + }); + + expect(runtime.prId).toBe(PR_ID); + expect(runtime.autoConvergeEnabled).toBe(true); + expect(runtime.status).toBe("running"); + expect(db.run).toHaveBeenCalledTimes(1); + const sql = db.run.mock.calls[0][0] as string; + expect(sql).toContain("insert into pr_convergence_state"); + const params = db.run.mock.calls[0][1] as unknown[]; + expect(params[0]).toBe(PR_ID); + expect(params[1]).toBe(1); + expect(params[2]).toBe("running"); + expect(params[3]).toBe("scheduled"); + expect(params[4]).toBe(3); + expect(params[5]).toBe("session-9"); + }); + + it("deletes convergence runtime state on reset", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.resetConvergenceRuntime(PR_ID); + + expect(db.run).toHaveBeenCalledWith( + "delete from pr_convergence_state where pr_id = ?", + [PR_ID], + ); + }); + }); + // --------------------------------------------------------------------------- // Tests — pipeline settings // --------------------------------------------------------------------------- @@ -1115,7 +1399,7 @@ describe("issueInventoryService", () => { const insertCalls = db.run.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; // Should have stripped "⚠️" emoji but kept the useful text expect(headline).not.toContain("⚠️"); expect(headline).toContain("Fix the race condition"); @@ -1148,7 +1432,7 @@ describe("issueInventoryService", () => { const insertCalls = db.run.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; expect(headline).toContain("Review thread at src/utils.ts"); }); @@ -1179,7 +1463,7 @@ describe("issueInventoryService", () => { const insertCalls = db.run.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; expect(headline.length).toBeLessThanOrEqual(120); expect(headline).toContain("..."); }); @@ -1212,7 +1496,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); expect(insertCalls.length).toBe(1); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; expect(headline).toBe("Review thread at src/main.ts"); }); @@ -1238,7 +1522,7 @@ describe("issueInventoryService", () => { expect(insertCalls.length).toBe(1); // author null, headline should use fallback const args = insertCalls[0][1] as unknown[]; - expect(args[10]).toBeNull(); // author + expect(args[12]).toBeNull(); // author }); it("detects ade-review bot source", () => { @@ -1328,7 +1612,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBeNull(); // severity + expect(args[9]).toBeNull(); // severity }); it("extracts P1 as critical severity", () => { @@ -1358,7 +1642,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); + expect(args[9]).toBe("critical"); }); it("extracts P3 as minor severity", () => { @@ -1388,7 +1672,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("minor"); + expect(args[9]).toBe("minor"); }); it("extracts [nit] bracket as minor severity", () => { @@ -1418,7 +1702,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("minor"); + expect(args[9]).toBe("minor"); }); it("extracts [error] bracket as critical severity", () => { @@ -1448,7 +1732,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); + expect(args[9]).toBe("critical"); }); it("extracts 🟠 emoji as major severity", () => { @@ -1478,7 +1762,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("major"); + expect(args[9]).toBe("major"); }); it("extracts 🟡 emoji as minor severity", () => { @@ -1508,7 +1792,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("minor"); + expect(args[9]).toBe("minor"); }); }); }); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index e43287718..f53e0a6b4 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -3,6 +3,7 @@ import type { AdeDb } from "../state/kvDb"; import type { ConvergenceRoundStat, ConvergenceStatus, + ConvergenceRuntimeState, IssueInventoryItem, IssueInventorySnapshot, IssueInventoryState, @@ -12,7 +13,7 @@ import type { PrComment, PrReviewThread, } from "../../../shared/types"; -import { DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types"; +import { DEFAULT_CONVERGENCE_RUNTIME_STATE, DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types"; import { isNoisyIssueComment } from "./resolverUtils"; import { nowIso } from "../shared/utils"; @@ -35,6 +36,7 @@ function detectSource(author: string | null | undefined): IssueSource { for (const { pattern, source } of SOURCE_PATTERNS) { if (pattern.test(name)) return source; } + if (/\[bot\]/i.test(name) || /\bbot\b/i.test(name)) return "unknown"; return "human"; } @@ -120,6 +122,11 @@ type InventoryRow = { url: string | null; dismiss_reason: string | null; agent_session_id: string | null; + thread_comment_count: number | null; + thread_latest_comment_id: string | null; + thread_latest_comment_author: string | null; + thread_latest_comment_at: string | null; + thread_latest_comment_source: string | null; created_at: string; updated_at: string; }; @@ -142,6 +149,11 @@ function rowToItem(row: InventoryRow): IssueInventoryItem { url: row.url, dismissReason: row.dismiss_reason, agentSessionId: row.agent_session_id, + threadCommentCount: row.thread_comment_count, + threadLatestCommentId: row.thread_latest_comment_id, + threadLatestCommentAuthor: row.thread_latest_comment_author, + threadLatestCommentAt: row.thread_latest_comment_at, + threadLatestCommentSource: row.thread_latest_comment_source as IssueSource | null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -203,6 +215,56 @@ function computeConvergenceStatus(items: IssueInventoryItem[], maxRounds: number }; } +type ConvergenceRuntimeRow = { + pr_id: string; + auto_converge_enabled: number; + status: string; + poller_status: string; + current_round: number; + active_session_id: string | null; + active_lane_id: string | null; + active_href: string | null; + pause_reason: string | null; + error_message: string | null; + last_started_at: string | null; + last_polled_at: string | null; + last_paused_at: string | null; + last_stopped_at: string | null; + created_at: string; + updated_at: string; +}; + +function buildDefaultRuntimeState(prId: string): ConvergenceRuntimeState { + const now = nowIso(); + return { + prId, + ...DEFAULT_CONVERGENCE_RUNTIME_STATE, + createdAt: now, + updatedAt: now, + }; +} + +function rowToConvergenceRuntime(row: ConvergenceRuntimeRow): ConvergenceRuntimeState { + return { + prId: row.pr_id, + autoConvergeEnabled: row.auto_converge_enabled === 1, + status: row.status as ConvergenceRuntimeState["status"], + pollerStatus: row.poller_status as ConvergenceRuntimeState["pollerStatus"], + currentRound: row.current_round, + activeSessionId: row.active_session_id, + activeLaneId: row.active_lane_id, + activeHref: row.active_href, + pauseReason: row.pause_reason, + errorMessage: row.error_message, + lastStartedAt: row.last_started_at, + lastPolledAt: row.last_polled_at, + lastPausedAt: row.last_paused_at, + lastStoppedAt: row.last_stopped_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- @@ -254,44 +316,185 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { body: string | null; author: string | null; url: string | null; + threadCommentCount?: number | null; + threadLatestCommentId?: string | null; + threadLatestCommentAuthor?: string | null; + threadLatestCommentAt?: string | null; + threadLatestCommentSource?: IssueSource | null; }, + options: { + state?: IssueInventoryState; + round?: number; + dismissReason?: string | null; + agentSessionId?: string | null; + } = {}, ): void { const now = nowIso(); const existing = db.get( "select * from pr_issue_inventory where pr_id = ? and external_id = ?", [prId, externalId], ); + const nextState = options.state ?? existing?.state ?? "new"; + const nextRound = options.round ?? existing?.round ?? 0; + const nextDismissReason = options.dismissReason !== undefined + ? options.dismissReason + : existing?.dismiss_reason ?? null; + const nextAgentSessionId = options.agentSessionId !== undefined + ? options.agentSessionId + : existing?.agent_session_id ?? null; + const nextThreadCommentCount = data.threadCommentCount ?? existing?.thread_comment_count ?? null; + const nextThreadLatestCommentId = data.threadLatestCommentId ?? existing?.thread_latest_comment_id ?? null; + const nextThreadLatestCommentAuthor = data.threadLatestCommentAuthor ?? existing?.thread_latest_comment_author ?? null; + const nextThreadLatestCommentAt = data.threadLatestCommentAt ?? existing?.thread_latest_comment_at ?? null; + const nextThreadLatestCommentSource = data.threadLatestCommentSource ?? (existing?.thread_latest_comment_source as IssueSource | null) ?? null; + if (existing) { - // Update mutable fields but keep state db.run( `update pr_issue_inventory set headline = ?, body = ?, severity = ?, file_path = ?, line = ?, - author = ?, url = ?, source = ?, updated_at = ? + author = ?, url = ?, source = ?, state = ?, round = ?, dismiss_reason = ?, + agent_session_id = ?, thread_comment_count = ?, thread_latest_comment_id = ?, + thread_latest_comment_author = ?, thread_latest_comment_at = ?, + thread_latest_comment_source = ?, updated_at = ? where id = ?`, - [data.headline, data.body, data.severity, data.filePath, data.line, - data.author, data.url, data.source, now, existing.id], + [ + data.headline, + data.body, + data.severity, + data.filePath, + data.line, + data.author, + data.url, + data.source, + nextState, + nextRound, + nextDismissReason, + nextAgentSessionId, + nextThreadCommentCount, + nextThreadLatestCommentId, + nextThreadLatestCommentAuthor, + nextThreadLatestCommentAt, + nextThreadLatestCommentSource, + now, + existing.id, + ], ); } else { db.run( `insert into pr_issue_inventory (id, pr_id, source, type, external_id, state, round, file_path, line, severity, headline, body, author, url, dismiss_reason, agent_session_id, - created_at, updated_at) - values (?, ?, ?, ?, ?, 'new', 0, ?, ?, ?, ?, ?, ?, ?, null, null, ?, ?)`, - [randomUUID(), prId, data.source, data.type, externalId, - data.filePath, data.line, data.severity, data.headline, data.body, - data.author, data.url, now, now], + thread_comment_count, thread_latest_comment_id, thread_latest_comment_author, + thread_latest_comment_at, thread_latest_comment_source, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + randomUUID(), + prId, + data.source, + data.type, + externalId, + nextState, + nextRound, + data.filePath, + data.line, + data.severity, + data.headline, + data.body, + data.author, + data.url, + nextDismissReason, + nextAgentSessionId, + nextThreadCommentCount, + nextThreadLatestCommentId, + nextThreadLatestCommentAuthor, + nextThreadLatestCommentAt, + nextThreadLatestCommentSource, + now, + now, + ], ); } } + function getConvergenceRuntimeRow(prId: string): ConvergenceRuntimeRow | null { + return db.get( + "select * from pr_convergence_state where pr_id = ?", + [prId], + ); + } + + function saveConvergenceRuntimeState(prId: string, state: Partial): ConvergenceRuntimeState { + const existing = readConvergenceRuntime(prId); + const merged: ConvergenceRuntimeState = { + ...(existing ?? buildDefaultRuntimeState(prId)), + ...state, + prId, + createdAt: existing?.createdAt ?? nowIso(), + updatedAt: nowIso(), + }; + + db.run( + `insert into pr_convergence_state + (pr_id, auto_converge_enabled, status, poller_status, current_round, active_session_id, + active_lane_id, active_href, pause_reason, error_message, last_started_at, last_polled_at, + last_paused_at, last_stopped_at, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(pr_id) do update set + auto_converge_enabled = excluded.auto_converge_enabled, + status = excluded.status, + poller_status = excluded.poller_status, + current_round = excluded.current_round, + active_session_id = excluded.active_session_id, + active_lane_id = excluded.active_lane_id, + active_href = excluded.active_href, + pause_reason = excluded.pause_reason, + error_message = excluded.error_message, + last_started_at = excluded.last_started_at, + last_polled_at = excluded.last_polled_at, + last_paused_at = excluded.last_paused_at, + last_stopped_at = excluded.last_stopped_at, + updated_at = excluded.updated_at`, + [ + merged.prId, + merged.autoConvergeEnabled ? 1 : 0, + merged.status, + merged.pollerStatus, + merged.currentRound, + merged.activeSessionId, + merged.activeLaneId, + merged.activeHref, + merged.pauseReason, + merged.errorMessage, + merged.lastStartedAt, + merged.lastPolledAt, + merged.lastPausedAt, + merged.lastStoppedAt, + merged.createdAt, + merged.updatedAt, + ], + ); + + return merged; + } + + function readConvergenceRuntime(prId: string): ConvergenceRuntimeState { + const row = getConvergenceRuntimeRow(prId); + return row ? rowToConvergenceRuntime(row) : buildDefaultRuntimeState(prId); + } + function buildSnapshot(prId: string): IssueInventorySnapshot { const items = getAllRows(prId).map(rowToItem); const { maxRounds } = readPipelineSettings(prId); + const convergence = computeConvergenceStatus(items, maxRounds); + const runtime = readConvergenceRuntime(prId); return { prId, items, - convergence: computeConvergenceStatus(items, maxRounds), + convergence, + runtime: { + ...runtime, + currentRound: Math.max(runtime.currentRound, convergence.currentRound), + }, }; } @@ -302,10 +505,17 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { reviewThreads: PrReviewThread[], comments: PrComment[], ): IssueInventorySnapshot { + const existingRows = getAllRows(prId); + const existingByExternalId = new Map(existingRows.map((row) => [row.external_id, row] as const)); + const activeFailingChecks = new Set(); + // Sync failing checks for (const check of checks) { if (check.conclusion !== "failure") continue; - upsertItem(prId, `check:${check.name}`, { + const externalId = `check:${check.name}`; + activeFailingChecks.add(externalId); + const existing = existingByExternalId.get(externalId) ?? null; + upsertItem(prId, externalId, { source: "unknown", type: "check_failure", filePath: null, @@ -315,17 +525,71 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { body: check.detailsUrl ? `Details: ${check.detailsUrl}` : null, author: null, url: check.detailsUrl, + }, existing?.state === "fixed" ? { + state: "new", + round: 0, + dismissReason: null, + agentSessionId: null, + } : { + state: existing?.state as IssueInventoryState | undefined, }); } - // Sync unresolved, non-outdated review threads + for (const existing of existingRows) { + if (existing.type !== "check_failure") continue; + if (activeFailingChecks.has(existing.external_id)) continue; + if (existing.state === "fixed" || existing.state === "dismissed") continue; + db.run( + "update pr_issue_inventory set state = 'fixed', updated_at = ? where id = ?", + [nowIso(), existing.id], + ); + } + + // Sync review threads using the latest reply in the conversation. for (const thread of reviewThreads) { - if (thread.isResolved || thread.isOutdated) continue; - const firstComment = thread.comments[0] ?? null; - const author = firstComment?.author ?? null; - const body = firstComment?.body ?? null; - upsertItem(prId, `thread:${thread.id}`, { - source: detectSource(author), + const externalId = `thread:${thread.id}`; + const latestComment = thread.comments.at(-1) ?? null; + const commentCount = thread.comments.length; + const author = latestComment?.author ?? null; + const body = latestComment?.body ?? null; + const source = detectSource(author); + const existing = existingByExternalId.get(externalId) ?? null; + + if (thread.isResolved || thread.isOutdated) { + if (!existing) continue; + upsertItem(prId, externalId, { + source, + type: "review_thread", + filePath: thread.path, + line: thread.line, + severity: extractSeverity(body ?? ""), + headline: extractHeadline(body, `Review thread at ${thread.path ?? "unknown"}`), + body, + author, + url: thread.url ?? latestComment?.url ?? null, + threadCommentCount: commentCount, + threadLatestCommentId: latestComment?.id ?? existing.thread_latest_comment_id, + threadLatestCommentAuthor: author, + threadLatestCommentAt: latestComment?.updatedAt ?? latestComment?.createdAt ?? thread.updatedAt, + threadLatestCommentSource: source, + }, { + state: "fixed", + round: existing.round, + dismissReason: null, + agentSessionId: existing.agent_session_id, + }); + continue; + } + + const threadChanged = existing != null + && ( + commentCount > (existing.thread_comment_count ?? 0) + || (latestComment?.id != null && latestComment.id !== existing.thread_latest_comment_id) + ); + const shouldReopen = !existing || (threadChanged && source !== "ade"); + + upsertItem(prId, externalId, { + source, type: "review_thread", filePath: thread.path, line: thread.line, @@ -333,7 +597,22 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { headline: extractHeadline(body, `Review thread at ${thread.path ?? "unknown"}`), body, author, - url: thread.url ?? firstComment?.url ?? null, + url: thread.url ?? latestComment?.url ?? null, + threadCommentCount: commentCount, + threadLatestCommentId: latestComment?.id ?? existing?.thread_latest_comment_id ?? null, + threadLatestCommentAuthor: author, + threadLatestCommentAt: latestComment?.updatedAt ?? latestComment?.createdAt ?? thread.updatedAt, + threadLatestCommentSource: source, + }, shouldReopen ? { + state: "new", + round: existing?.round ?? 0, + dismissReason: null, + agentSessionId: null, + } : { + state: existing?.state as IssueInventoryState | undefined, + round: existing?.round, + dismissReason: existing?.dismiss_reason, + agentSessionId: existing?.agent_session_id, }); } @@ -415,6 +694,19 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { resetInventory(prId: string): void { db.run("delete from pr_issue_inventory where pr_id = ?", [prId]); + db.run("delete from pr_convergence_state where pr_id = ?", [prId]); + }, + + getConvergenceRuntime(prId: string): ConvergenceRuntimeState { + return readConvergenceRuntime(prId); + }, + + saveConvergenceRuntime(prId: string, state: Partial): ConvergenceRuntimeState { + return saveConvergenceRuntimeState(prId, state); + }, + + resetConvergenceRuntime(prId: string): void { + db.run("delete from pr_convergence_state where pr_id = ?", [prId]); }, // ----- Pipeline settings (auto-converge / auto-merge) ----- diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 28f4017a5..613fe7ca6 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -248,6 +248,7 @@ const FK_CONSTRAINTS: Record = { // PR convergence loop tables "pr_issue_inventory:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, "pr_pipeline_settings:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, + "pr_convergence_state:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, }; /** @@ -2960,6 +2961,11 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { foreign key(pr_id) references pull_requests(id) on delete cascade ) `); + try { db.run("alter table pr_issue_inventory add column thread_comment_count integer"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_id text"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_author text"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_at text"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_source text"); } catch {} db.run("create index if not exists idx_inventory_pr_state on pr_issue_inventory(pr_id, state)"); // PR pipeline settings: per-PR auto-converge / auto-merge configuration @@ -2974,6 +2980,28 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { foreign key(pr_id) references pull_requests(id) on delete cascade ) `); + + db.run(` + create table if not exists pr_convergence_state ( + pr_id text primary key, + auto_converge_enabled integer not null default 0, + status text not null default 'idle', + poller_status text not null default 'idle', + current_round integer not null default 0, + active_session_id text, + active_lane_id text, + active_href text, + pause_reason text, + error_message text, + last_started_at text, + last_polled_at text, + last_paused_at text, + last_stopped_at text, + created_at text not null, + updated_at text not null, + foreign key(pr_id) references pull_requests(id) on delete cascade + ) + `); } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index fd6a5cbe8..5437c2ed8 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -321,6 +321,7 @@ import type { AiReviewSummary, IssueInventoryItem, IssueInventorySnapshot, + PrConvergenceState, ConvergenceStatus, PipelineSettings, UpdateIntegrationProposalArgs, @@ -1030,6 +1031,9 @@ declare global { issueInventoryMarkEscalated: (prId: string, itemIds: string[]) => Promise; issueInventoryGetConvergence: (prId: string) => Promise; issueInventoryReset: (prId: string) => Promise; + convergenceStateGet: (prId: string) => Promise; + convergenceStateSave: (prId: string, state: Partial) => Promise; + convergenceStateDelete: (prId: string) => Promise; pipelineSettingsGet: (prId: string) => Promise; pipelineSettingsSave: (prId: string, settings: Partial) => Promise; pipelineSettingsDelete: (prId: string) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e7ca703ee..608a89122 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -207,6 +207,7 @@ import type { AiReviewSummary, IssueInventoryItem, IssueInventorySnapshot, + PrConvergenceState, ConvergenceStatus, PipelineSettings, UpdateIntegrationProposalArgs, @@ -1462,6 +1463,12 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.prsIssueInventoryGetConvergence, { prId }), issueInventoryReset: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsIssueInventoryReset, { prId }), + convergenceStateGet: async (prId: string): Promise => + ipcRenderer.invoke(IPC.prsConvergenceStateGet, { prId }), + convergenceStateSave: async (prId: string, state: Partial): Promise => + ipcRenderer.invoke(IPC.prsConvergenceStateSave, { prId, state }), + convergenceStateDelete: async (prId: string): Promise => + ipcRenderer.invoke(IPC.prsConvergenceStateDelete, { prId }), pipelineSettingsGet: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsPipelineSettingsGet, { prId }), pipelineSettingsSave: async (prId: string, settings: Partial): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index f4018cd7f..b451a38f8 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -461,6 +461,30 @@ const MOCK_STATUS_BY_PR: Record = { "pr-5": { prId: "pr-5", state: "open", checksStatus: "passing", reviewStatus: "none", isMergeable: true, mergeConflicts: false, behindBaseBy: 3 }, }; +const MOCK_CONVERGENCE_RUNTIME: Record = {}; + +function createDefaultConvergenceRuntime(prId: string) { + const nowIso = new Date().toISOString(); + return { + prId, + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: nowIso, + updatedAt: nowIso, + }; +} + // ── Rebase Needs (all urgency categories) ───────────────────── const MOCK_REBASE_NEEDS: any[] = [ // Attention: behind + conflicts predicted @@ -1511,6 +1535,21 @@ if (typeof window !== "undefined" && !(window as any).ade) { laneId: "lane-dashboard", href: "/work?laneId=lane-dashboard&sessionId=mock-pr-issue-session", }), + convergenceStateGet: async (prId: string) => MOCK_CONVERGENCE_RUNTIME[prId] ?? createDefaultConvergenceRuntime(prId), + convergenceStateSave: async (prId: string, state: Record) => { + const nowIso = new Date().toISOString(); + const next = { + ...createDefaultConvergenceRuntime(prId), + ...(MOCK_CONVERGENCE_RUNTIME[prId] ?? {}), + ...state, + updatedAt: nowIso, + }; + MOCK_CONVERGENCE_RUNTIME[prId] = next; + return next; + }, + convergenceStateDelete: async (prId: string) => { + delete MOCK_CONVERGENCE_RUNTIME[prId]; + }, rebaseResolutionStart: async () => ({ sessionId: "mock-rebase-session", laneId: "lane-dashboard", diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index c8583e11c..21bd6b31c 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -45,7 +45,8 @@ export function CreateLaneDialog({ templates, selectedTemplateId, setSelectedTemplateId, - onNavigateToTemplates + onNavigateToTemplates, + importBranchWarning }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -71,6 +72,8 @@ export function CreateLaneDialog({ selectedTemplateId: string; setSelectedTemplateId: (id: string) => void; onNavigateToTemplates?: () => void; + /** Warning shown below the import branch selector (e.g. uncommitted changes). */ + importBranchWarning?: string | null; }) { const localBranches = createBranches.filter((b) => !b.isRemote); const allBranches = createBranches; @@ -197,6 +200,12 @@ export function CreateLaneDialog({ Base will be auto-detected from git history ) : null} + {importBranchWarning ? ( +
+ + {importBranchWarning} +
+ ) : null} ) : (
diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index e7483a3cd..935b02741 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -2019,6 +2019,12 @@ export function LanesPage() { selectedTemplateId={selectedTemplateId} setSelectedTemplateId={setSelectedTemplateId} onNavigateToTemplates={() => navigate("/settings?tab=lane-templates")} + importBranchWarning={ + createMode === "existing" && createImportBranch && primaryLane?.status.dirty + && createBranches.find((b) => b.name === createImportBranch && !b.isRemote)?.isCurrent + ? `This branch is currently checked out and has uncommitted changes. The new lane will only include committed changes\u2009—\u2009uncommitted work will not carry over.` + : null + } /> {/* Attach Lane dialog */} diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 35d39da3f..a59f6216f 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { LaneSummary, PrActivityEvent, PrCheck, PrReviewThread, PrStatus, PrWithConflicts } from "../../../../shared/types"; +import type { LaneSummary, PrActivityEvent, PrCheck, PrConvergenceState, PrReviewThread, PrStatus, PrWithConflicts } from "../../../../shared/types"; const mockUsePrs = vi.fn(); @@ -31,6 +31,28 @@ vi.mock("../shared/PrIssueResolverModal", () => ({ import { PrDetailPane } from "./PrDetailPane"; +function makeConvergenceState(overrides: Partial = {}): PrConvergenceState { + return { + prId: "pr-80", + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + ...overrides, + }; +} + function makeCheck(overrides: Partial = {}): PrCheck { return { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null, ...overrides }; } @@ -208,7 +230,36 @@ function renderPane(args: { getReviewThreads, issueResolutionStart, issueResolutionPreviewPrompt, + issueInventorySync: vi.fn().mockResolvedValue({ + prId: "pr-80", + items: [], + convergence: { + currentRound: 0, + maxRounds: 5, + issuesPerRound: [], + totalNew: 0, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: false, + }, + runtime: makeConvergenceState(), + }), + issueInventoryReset: vi.fn().mockResolvedValue(undefined), + issueInventoryMarkDismissed: vi.fn().mockResolvedValue(undefined), + issueInventoryMarkEscalated: vi.fn().mockResolvedValue(undefined), land, + aiResolutionStop: vi.fn().mockResolvedValue(undefined), + onAiResolutionEvent: vi.fn(() => () => {}), + pipelineSettingsGet: vi.fn().mockResolvedValue({ + autoMerge: false, + mergeMethod: "squash", + maxRounds: 5, + onRebaseNeeded: "pause", + }), + pipelineSettingsSave: vi.fn().mockResolvedValue(undefined), openInGitHub: vi.fn().mockResolvedValue(undefined), }, app: { @@ -245,6 +296,11 @@ function renderPane(args: { describe("PrDetailPane issue resolver CTA", () => { beforeEach(() => { mockUsePrs.mockReturnValue({ + rebaseNeeds: [], + convergenceStatesByPrId: { "pr-80": makeConvergenceState() }, + loadConvergenceState: vi.fn().mockResolvedValue(makeConvergenceState()), + saveConvergenceState: vi.fn().mockImplementation(async (_prId: string, patch: Partial) => makeConvergenceState(patch)), + resetConvergenceState: vi.fn().mockResolvedValue(undefined), resolverModel: "openai/gpt-5.4-codex", resolverReasoningLevel: "high", resolverPermissionMode: "guarded_edit", @@ -259,13 +315,16 @@ describe("PrDetailPane issue resolver CTA", () => { }); it.each(visibilityCases)("$name", async ({ checks, reviewThreads, statusOverrides, visible }) => { + const user = userEvent.setup(); renderPane({ checks, reviewThreads, statusOverrides }); + await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); await waitFor(() => { + expect(screen.getAllByRole("button", { name: /path to merge/i }).length).toBeGreaterThan(0); if (visible) { - expect(screen.getByRole("button", { name: /path to merge/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /resolve issues with agent/i })).toBeTruthy(); } else { - expect(screen.queryByRole("button", { name: /path to merge/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /resolve issues with agent/i })).toBeNull(); } }); }); @@ -280,8 +339,7 @@ describe("PrDetailPane issue resolver CTA", () => { await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); await waitFor(() => { - // "Path to Merge" in header + "Resolve issues with agent" in ChecksTab - expect(screen.getByRole("button", { name: /path to merge/i })).toBeTruthy(); + expect(screen.getAllByRole("button", { name: /path to merge/i }).length).toBeGreaterThan(0); expect(screen.getByRole("button", { name: /resolve issues with agent/i })).toBeTruthy(); }); }); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 191c1d994..2f58b2418 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -10,19 +10,22 @@ import { CaretDown, CaretRight, UserCircle, DotsThreeVertical, Robot, Stack as Layers, } from "@phosphor-icons/react"; import type { + ConvergencePollerStatus, + ConvergenceRuntimeStatus, + PrConvergenceState, PrWithConflicts, PrCheck, PrReview, PrComment, PrStatus, PrDetail, PrFile, PrActionRun, PrActivityEvent, AiReviewSummary, PrReviewThread, LaneSummary, MergeMethod, LandResult, IssueInventorySnapshot, PipelineSettings, } from "../../../../shared/types"; -import { DEFAULT_PIPELINE_SETTINGS } from "../../../../shared/types"; +import { DEFAULT_CONVERGENCE_RUNTIME_STATE, DEFAULT_PIPELINE_SETTINGS } from "../../../../shared/types"; import { getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, inlineBadge, outlineButton, primaryButton, dangerButton } from "../../lanes/laneDesignTokens"; import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge, PrCiRunningIndicator } from "../shared/prVisuals"; import { PrIssueResolverModal } from "../shared/PrIssueResolverModal"; import { PrConvergencePanel } from "../shared/PrConvergencePanel"; -import type { IssueInventoryItem as PanelIssueItem, ConvergenceStatus as PanelConvergence } from "../shared/PrConvergencePanel"; +import type { PathToMergeRuntimeState } from "../shared/PrConvergencePanel"; import { PrLaneCleanupBanner } from "../shared/PrLaneCleanupBanner"; import { formatTimeAgo, formatTimestampFull } from "../shared/prFormatters"; import { describePrTargetDiff } from "../shared/laneBranchTargets"; @@ -30,7 +33,210 @@ import { findMatchingRebaseNeed, rebaseNeedItemKey } from "../shared/rebaseNeedU import { usePrs } from "../state/PrsContext"; // ---- Sub-tab type ---- -type DetailTab = "overview" | "files" | "checks" | "activity"; +type DetailTab = "overview" | "files" | "checks" | "activity" | "path-to-merge"; + +function createDefaultConvergenceState(prId: string): PrConvergenceState { + const now = new Date().toISOString(); + return { + prId, + ...DEFAULT_CONVERGENCE_RUNTIME_STATE, + createdAt: now, + updatedAt: now, + }; +} + +function mapPathToMergePhase(status: ConvergenceRuntimeStatus): PathToMergeRuntimeState["phase"] { + switch (status) { + case "launching": + return "launching"; + case "running": + return "working"; + case "polling": + return "polling"; + case "paused": + return "paused"; + case "converged": + return "converged"; + case "merged": + return "merged"; + case "failed": + return "error"; + case "cancelled": + case "stopped": + return "stopped"; + case "idle": + default: + return "idle"; + } +} + +function mapPathToMergePollerPhase(status: ConvergencePollerStatus): PathToMergeRuntimeState["pollerPhase"] { + switch (status) { + case "waiting_for_checks": + return "waiting_checks"; + case "waiting_for_comments": + return "waiting_comments"; + case "paused": + return "paused"; + case "polling": + case "scheduled": + return "polling"; + case "idle": + case "stopped": + default: + return "idle"; + } +} + +function pathToMergeStatusTone(phase: PathToMergeRuntimeState["phase"]): { color: string; bg: string; border: string; label: string } { + switch (phase) { + case "launching": + return { color: COLORS.warning, bg: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.24)", label: "Launching" }; + case "working": + return { color: COLORS.accent, bg: `${COLORS.accent}10`, border: `${COLORS.accent}28`, label: "Agent working" }; + case "polling": + return { color: COLORS.warning, bg: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.24)", label: "Polling" }; + case "paused": + return { color: "#F59E0B", bg: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.24)", label: "Paused" }; + case "converged": + return { color: COLORS.success, bg: "rgba(34,197,94,0.10)", border: "rgba(34,197,94,0.24)", label: "Converged" }; + case "merged": + return { color: COLORS.success, bg: "rgba(34,197,94,0.10)", border: "rgba(34,197,94,0.24)", label: "Merged" }; + case "stopped": + return { color: COLORS.textDim, bg: "rgba(255,255,255,0.02)", border: COLORS.border, label: "Stopped" }; + case "error": + return { color: COLORS.danger, bg: "rgba(239,68,68,0.08)", border: "rgba(239,68,68,0.24)", label: "Error" }; + case "idle": + default: + return { color: COLORS.textMuted, bg: "rgba(255,255,255,0.02)", border: COLORS.border, label: "Idle" }; + } +} + +function PathToMergeHeaderBanner({ + runtime, + onViewSession, + onStop, + onResume, + onDismissPause, +}: { + runtime: PathToMergeRuntimeState; + onViewSession?: (href: string) => void; + onStop: () => Promise | void; + onResume: () => void; + onDismissPause: () => void; +}) { + const tone = pathToMergeStatusTone(runtime.phase); + const showBanner = runtime.autoConverge || runtime.phase !== "idle" || runtime.agentSessionId != null || runtime.pauseReason != null; + if (!showBanner) return null; + + return ( +
+
+ + {runtime.phase === "working" || runtime.phase === "launching" || runtime.phase === "polling" ? ( + + ) : null} + {tone.label} + + + Round {runtime.currentRound > 0 ? runtime.currentRound : 0} of {runtime.maxRounds > 0 ? runtime.maxRounds : 5} + + {runtime.pauseReason ? ( + {runtime.pauseReason} + ) : null} +
+
+ {runtime.sessionHref ? ( + + ) : null} + {runtime.phase === "paused" ? ( + + ) : null} + {runtime.pauseReason ? ( + + ) : null} + {runtime.phase !== "idle" && runtime.phase !== "stopped" && runtime.phase !== "merged" ? ( + + ) : null} +
+
+ ); +} // ---- Avatar component ---- function Avatar({ user, size = 20 }: { user: { login: string; avatarUrl?: string | null }; size?: number }) { @@ -402,6 +608,10 @@ export function PrDetailPane({ }: PrDetailPaneProps) { const { rebaseNeeds, + convergenceStatesByPrId, + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, resolverModel, resolverReasoningLevel, resolverPermissionMode, @@ -424,13 +634,8 @@ export function PrDetailPane({ const [issueResolverError, setIssueResolverError] = React.useState(null); // Convergence panel state - const [showConvergencePanel, setShowConvergencePanel] = React.useState(false); const [inventorySnapshot, setInventorySnapshot] = React.useState(null); - const [convergenceBusy, setConvergenceBusy] = React.useState(false); - const [autoConverge, setAutoConverge] = React.useState(false); - const [convergenceSessionId, setConvergenceSessionId] = React.useState(null); - const [convergenceMerged, setConvergenceMerged] = React.useState(false); - const [convergencePauseReason, setConvergencePauseReason] = React.useState(null); + const [pathToMergeInstructions, setPathToMergeInstructions] = React.useState(""); const autoConvergeTimerRef = React.useRef | null>(null); const behindCountRef = React.useRef(0); const [pipelineSettings, setPipelineSettings] = React.useState(DEFAULT_PIPELINE_SETTINGS); @@ -439,6 +644,17 @@ export function PrDetailPane({ mergeMethodRef.current = mergeMethod; const onRefreshRef = React.useRef(onRefresh); onRefreshRef.current = onRefresh; + const convergenceState = convergenceStatesByPrId[pr.id] ?? createDefaultConvergenceState(pr.id); + const autoConverge = convergenceState.autoConvergeEnabled; + const convergenceBusy = convergenceState.status === "launching"; + const convergenceSessionId = convergenceState.activeSessionId; + const convergenceSessionHref = convergenceState.activeHref; + const convergencePauseReason = convergenceState.pauseReason; + const nowIso = React.useCallback(() => new Date().toISOString(), []); + const saveConvergenceStateSafely = React.useCallback( + async (patch: Partial) => saveConvergenceState(pr.id, patch), + [pr.id, saveConvergenceState], + ); // Action states const [actionBusy, setActionBusy] = React.useState(false); @@ -490,13 +706,8 @@ export function PrDetailPane({ setIssueResolverCopyBusy(false); setIssueResolverCopyNotice(null); setShowIssueResolverModal(false); - setShowConvergencePanel(false); setInventorySnapshot(null); - setConvergenceBusy(false); - setAutoConverge(false); - setConvergenceSessionId(null); - setConvergenceMerged(false); - setConvergencePauseReason(null); + setPathToMergeInstructions(""); setPipelineSettings(DEFAULT_PIPELINE_SETTINGS); pipelineSettingsRef.current = DEFAULT_PIPELINE_SETTINGS; if (autoConvergeTimerRef.current) { @@ -517,11 +728,16 @@ export function PrDetailPane({ setShowReviewerEditor(false); setShowReviewModal(false); + void loadConvergenceState(pr.id, { force: true }).catch(() => {}); + void window.ade.prs.pipelineSettingsGet(pr.id).then((settings) => { + setPipelineSettings(settings); + pipelineSettingsRef.current = settings; + }).catch(() => {}); void loadDetail(); return () => { detailLoadSeqRef.current += 1; }; - }, [loadDetail, pr.id]); + }, [loadConvergenceState, loadDetail, pr.id]); React.useEffect(() => { if (!issueResolverCopyNotice) return; @@ -529,6 +745,21 @@ export function PrDetailPane({ return () => window.clearTimeout(timer); }, [issueResolverCopyNotice]); + React.useEffect(() => { + if (typeof document === "undefined") return; + const styleId = "ptm-header-keyframes"; + if (document.getElementById(styleId)) return; + const style = document.createElement("style"); + style.id = styleId; + style.textContent = ` + @keyframes ptmHeaderSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + `; + document.head.appendChild(style); + }, []); + // ---- Action helper to reduce repetitive try/catch/finally ---- const runAction = async (fn: () => Promise) => { setActionBusy(true); @@ -717,54 +948,31 @@ export function PrDetailPane({ try { const snapshot = await window.ade.prs.issueInventorySync(pr.id); setInventorySnapshot(snapshot); + if (snapshot.convergence.currentRound > convergenceState.currentRound && !convergenceState.activeSessionId) { + void saveConvergenceStateSafely({ + currentRound: snapshot.convergence.currentRound, + }).catch(() => {}); + } return snapshot; } catch { return null; } - }, [pr.id]); + }, [convergenceState.activeSessionId, convergenceState.currentRound, pr.id, saveConvergenceStateSafely]); - const mapInventoryItems = React.useCallback((snapshot: IssueInventorySnapshot | null): PanelIssueItem[] => { - if (!snapshot) return []; - return snapshot.items.map((item) => ({ - id: item.id, - state: item.state === "sent_to_agent" ? "in_progress" : item.state, - severity: item.severity ?? "minor", - headline: item.headline, - filePath: item.filePath, - line: item.line, - source: item.source === "unknown" ? "human" : item.source, - dismissReason: item.dismissReason, - agentSessionId: item.agentSessionId, - })) as PanelIssueItem[]; - }, []); - - const mapConvergenceStatus = React.useCallback((snapshot: IssueInventorySnapshot | null): PanelConvergence => { - if (!snapshot) return { state: "not_started", currentRound: 1, maxRounds: 5 }; - const c = snapshot.convergence; - const displayRound = Math.max(1, c.currentRound); - let state: PanelConvergence["state"] = "not_started"; - if (c.currentRound > 0) { - if (c.totalNew === 0 && c.totalSentToAgent === 0) { - state = "complete"; - } else if (c.isConverging) { - state = "converging"; - } else { - state = "stalled"; - } - } - return { state, currentRound: displayRound, maxRounds: c.maxRounds }; - }, []); + // Sync inventory and load pipeline settings when the tab is opened. + React.useEffect(() => { + if (activeTab !== "path-to-merge") return; + void syncInventory(); + void window.ade.prs.pipelineSettingsGet(pr.id).then((s) => { + setPipelineSettings(s); + pipelineSettingsRef.current = s; + }); + }, [activeTab, pr.id, syncInventory]); - // Sync inventory and load pipeline settings on panel open React.useEffect(() => { - if (showConvergencePanel) { - void syncInventory(); - void window.ade.prs.pipelineSettingsGet(pr.id).then((s) => { - setPipelineSettings(s); - pipelineSettingsRef.current = s; - }); - } - }, [showConvergencePanel, syncInventory, pr.id]); + if (activeTab !== "path-to-merge" && !autoConverge && !convergenceSessionId) return; + void syncInventory(); + }, [activeTab, autoConverge, convergenceSessionId, syncInventory]); // Auto-converge: hybrid polling (checks complete + comment stabilization) // After agent session completes, polls every 60s. Triggers next round when: @@ -795,6 +1003,13 @@ export function PrDetailPane({ const startAutoConvergePoller = React.useCallback(() => { stopAutoConvergePoller(); + void saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "polling", + pollerStatus: "scheduled", + pauseReason: null, + errorMessage: null, + }).catch(() => {}); const scheduleTick = () => { autoConvergePollerRef.current = setTimeout(async () => { @@ -817,7 +1032,13 @@ export function PrDetailPane({ const rebasePolicy = pipelineSettingsRef.current.onRebaseNeeded; if (rebasePolicy === "pause") { stopAutoConvergePoller(); - setConvergencePauseReason("PR is behind base branch. Rebase needed to continue."); + await saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "paused", + pollerStatus: "paused", + pauseReason: "PR is behind base branch. Rebase needed to continue.", + lastPausedAt: nowIso(), + }); return; } // rebasePolicy === "auto_rebase" @@ -827,9 +1048,23 @@ export function PrDetailPane({ behindCountRef.current++; if (behindCountRef.current >= 3) { stopAutoConvergePoller(); - setConvergencePauseReason("PR needs rebase but auto-rebase appears stuck. Resolve conflicts manually."); + await saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "paused", + pollerStatus: "paused", + pauseReason: "PR needs rebase but auto-rebase appears stuck. Resolve conflicts manually.", + lastPausedAt: nowIso(), + }); return; } + void saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "polling", + pollerStatus: "waiting_for_checks", + pauseReason: null, + errorMessage: null, + lastPolledAt: nowIso(), + }).catch(() => {}); scheduleTick(); // Keep polling, give auto-rebase time to work return; } @@ -843,6 +1078,14 @@ export function PrDetailPane({ if (checksStillRunning) { lastCommentCountRef.current = -1; stableCountRef.current = 0; + void saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "polling", + pollerStatus: "waiting_for_checks", + pauseReason: null, + errorMessage: null, + lastPolledAt: nowIso(), + }).catch(() => {}); scheduleTick(); // Keep polling return; } @@ -861,7 +1104,13 @@ export function PrDetailPane({ stopAutoConvergePoller(); const convergence = snapshot.convergence; if (convergence.currentRound >= convergence.maxRounds) { - setAutoConverge(false); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + errorMessage: `Stopped after reaching the max of ${convergence.maxRounds} rounds.`, + lastStoppedAt: nowIso(), + }); return; // Max rounds reached } // Launch next round @@ -889,31 +1138,85 @@ export function PrDetailPane({ : settings.mergeMethod; const res = await window.ade.prs.land({ prId: pr.id, method }); if (res.success) { - setConvergenceMerged(true); - setAutoConverge(false); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "merged", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + }); await onRefreshRef.current(); } else { setActionError(res.error ?? "Auto-merge failed"); - setAutoConverge(false); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "converged", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + errorMessage: res.error ?? "Auto-merge failed", + }); } } catch (err: unknown) { setActionError( err instanceof Error ? err.message : "Auto-merge failed", ); - setAutoConverge(false); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "converged", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + errorMessage: err instanceof Error ? err.message : "Auto-merge failed", + }); } } else { // Checks not passing — cannot auto-merge setActionError("Auto-merge skipped: some checks are not passing"); - setAutoConverge(false); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "converged", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + errorMessage: "Auto-merge skipped: some checks are not passing", + }); } } else { - setAutoConverge(false); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "converged", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + }); } } else { + void saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "polling", + pollerStatus: currentCount > 0 ? "waiting_for_comments" : "polling", + pauseReason: null, + errorMessage: null, + lastPolledAt: nowIso(), + }).catch(() => {}); scheduleTick(); // Not yet stable, keep polling } } catch { + void saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "polling", + pollerStatus: "scheduled", + }).catch(() => {}); // Poll failed, schedule retry scheduleTick(); } @@ -921,25 +1224,90 @@ export function PrDetailPane({ }; scheduleTick(); - }, [pr.id, stopAutoConvergePoller]); + }, [nowIso, pr.id, saveConvergenceStateSafely, stopAutoConvergePoller]); // Listen for agent session completion to start polling React.useEffect(() => { if (!convergenceSessionId) return; const unsubscribe = window.ade.prs.onAiResolutionEvent((event) => { if (event.sessionId !== convergenceSessionId) return; - if (event.status === "completed" || event.status === "failed" || event.status === "cancelled") { - setConvergenceBusy(false); - setConvergenceSessionId(null); - void syncInventory(); - // Start polling if auto-converge is on and session completed successfully - if (autoConverge && event.status === "completed") { - startAutoConvergePoller(); + void (async () => { + const finishedAt = nowIso(); + if (event.status === "completed") { + await saveConvergenceStateSafely({ + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + status: autoConverge ? "polling" : "idle", + pollerStatus: autoConverge ? "scheduled" : "idle", + lastPolledAt: autoConverge ? finishedAt : convergenceState.lastPolledAt, + }); + void syncInventory(); + if (autoConverge) { + startAutoConvergePoller(); + } + return; } - } + + const nextError = event.status === "failed" + ? "The agent session failed during convergence." + : "The agent session was cancelled."; + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: nextError, + status: event.status === "failed" ? "failed" : "cancelled", + pollerStatus: "stopped", + lastStoppedAt: finishedAt, + }); + void syncInventory(); + })(); }); return unsubscribe; - }, [autoConverge, convergenceSessionId, startAutoConvergePoller, syncInventory]); + }, [ + autoConverge, + convergenceSessionId, + convergenceState.lastPolledAt, + nowIso, + saveConvergenceStateSafely, + startAutoConvergePoller, + syncInventory, + ]); + + React.useEffect(() => { + if (!autoConverge) return; + if (convergenceSessionId) return; + if (autoConvergePollerRef.current) return; + if ( + convergenceState.status === "merged" + || convergenceState.status === "converged" + || convergenceState.status === "paused" + || convergenceState.status === "failed" + || convergenceState.status === "cancelled" + || convergenceState.status === "stopped" + ) { + return; + } + if ( + convergenceState.pollerStatus === "scheduled" + || convergenceState.pollerStatus === "polling" + || convergenceState.pollerStatus === "waiting_for_checks" + || convergenceState.pollerStatus === "waiting_for_comments" + ) { + startAutoConvergePoller(); + } + }, [ + autoConverge, + convergenceSessionId, + convergenceState.pollerStatus, + convergenceState.status, + startAutoConvergePoller, + ]); // Cleanup poller on unmount React.useEffect(() => { @@ -957,17 +1325,35 @@ export function PrDetailPane({ }, [issueResolutionAvailability]); const handleRunNextRound = React.useCallback(async (additionalInstructions: string) => { - setConvergenceBusy(true); setActionError(null); try { const snapshot = await syncInventory(); if (!snapshot) throw new Error("Failed to sync inventory"); const hasNew = snapshot.items.some((item) => item.state === "new"); if (!hasNew) { - setConvergenceBusy(false); + await saveConvergenceStateSafely({ + autoConvergeEnabled: autoConvergeRef.current, + status: autoConvergeRef.current ? "converged" : "idle", + pollerStatus: autoConvergeRef.current ? "stopped" : "idle", + errorMessage: null, + }); return; } + const nextRound = Math.max( + snapshot.convergence.currentRound + 1, + convergenceState.currentRound > 0 ? convergenceState.currentRound : 0, + ); + await saveConvergenceStateSafely({ + autoConvergeEnabled: autoConvergeRef.current, + status: "launching", + pollerStatus: "idle", + currentRound: nextRound, + pauseReason: null, + errorMessage: null, + lastStartedAt: nowIso(), + }); + const result = await window.ade.prs.issueResolutionStart({ prId: pr.id, scope: resolveIssueScope(), @@ -977,13 +1363,44 @@ export function PrDetailPane({ additionalInstructions, }); - setConvergenceSessionId(result.sessionId); + await saveConvergenceStateSafely({ + autoConvergeEnabled: autoConvergeRef.current, + status: "running", + pollerStatus: "idle", + currentRound: nextRound, + activeSessionId: result.sessionId, + activeLaneId: result.laneId, + activeHref: result.href, + pauseReason: null, + errorMessage: null, + lastStartedAt: nowIso(), + }); void syncInventory(); } catch (err) { - setActionError(err instanceof Error ? err.message : "Failed to launch agent"); - setConvergenceBusy(false); + const message = err instanceof Error ? err.message : "Failed to launch agent"; + setActionError(message); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "failed", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + errorMessage: message, + lastStoppedAt: nowIso(), + }); } - }, [pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel, syncInventory, resolveIssueScope]); + }, [ + convergenceState.currentRound, + nowIso, + pr.id, + resolveIssueScope, + resolverModel, + resolverPermissionMode, + resolverReasoningLevel, + saveConvergenceStateSafely, + syncInventory, + ]); // Keep ref in sync for the auto-converge poller handleRunNextRoundRef.current = handleRunNextRound; @@ -1007,7 +1424,6 @@ export function PrDetailPane({ }, [pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel, resolveIssueScope]); const handleAutoConvergeToggle = React.useCallback((enabled: boolean) => { - setAutoConverge(enabled); if (!enabled) { stopAutoConvergePoller(); if (autoConvergeTimerRef.current) { @@ -1015,7 +1431,21 @@ export function PrDetailPane({ autoConvergeTimerRef.current = null; } } - }, [stopAutoConvergePoller]); + void saveConvergenceStateSafely({ + autoConvergeEnabled: enabled, + status: enabled + ? (convergenceState.status === "stopped" || convergenceState.status === "failed" || convergenceState.status === "cancelled" + ? "idle" + : convergenceState.status) + : (convergenceState.activeSessionId ? convergenceState.status : "stopped"), + pollerStatus: enabled + ? (convergenceState.activeSessionId ? convergenceState.pollerStatus : "idle") + : (convergenceState.activeSessionId ? "idle" : "stopped"), + pauseReason: null, + errorMessage: enabled ? null : convergenceState.errorMessage, + ...(enabled ? {} : { lastStoppedAt: nowIso() }), + }).catch(() => {}); + }, [convergenceState.activeSessionId, convergenceState.errorMessage, convergenceState.pollerStatus, convergenceState.status, nowIso, saveConvergenceStateSafely, stopAutoConvergePoller]); const handleMarkDismissed = React.useCallback(async (itemIds: string[], reason: string) => { try { @@ -1037,17 +1467,41 @@ export function PrDetailPane({ const handleResetInventory = React.useCallback(async () => { await window.ade.prs.issueInventoryReset(pr.id); + await resetConvergenceState(pr.id); setInventorySnapshot(null); - setAutoConverge(false); - setConvergenceSessionId(null); + setPathToMergeInstructions(""); if (autoConvergeTimerRef.current) { clearTimeout(autoConvergeTimerRef.current); autoConvergeTimerRef.current = null; } - }, [pr.id]); + stopAutoConvergePoller(); + }, [pr.id, resetConvergenceState, stopAutoConvergePoller]); + + const handleStopConvergence = React.useCallback(async () => { + const sessionId = convergenceSessionId; + try { + if (sessionId) { + await window.ade.prs.aiResolutionStop({ sessionId }); + } + } catch { + // Stopping is best-effort; local convergence state is still cleared. + } finally { + stopAutoConvergePoller(); + await saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + lastStoppedAt: nowIso(), + }); + } + }, [convergenceSessionId, nowIso, saveConvergenceStateSafely, stopAutoConvergePoller]); - const handleOpenConvergencePanel = React.useCallback(() => { - setShowConvergencePanel(true); + const handleOpenPathToMergeTab = React.useCallback(() => { + setActiveTab("path-to-merge"); void loadDetail(); void onRefresh(); }, [loadDetail, onRefresh]); @@ -1062,15 +1516,43 @@ export function PrDetailPane({ files: COLORS.info, checks: COLORS.success, activity: COLORS.warning, + "path-to-merge": COLORS.accent, }; const DETAIL_TABS: Array<{ id: DetailTab; label: string; icon: React.ElementType; count?: number }> = [ { id: "overview", label: "Overview", icon: Eye }, { id: "files", label: "Files", icon: Code, count: files.length }, { id: "checks", label: "CI / Checks", icon: Play, count: checks.length + actionRuns.reduce((sum, run) => sum + run.jobs.length, 0) }, + { id: "path-to-merge", label: "Path to Merge", icon: Sparkle, count: inventorySnapshot?.items.length ?? 0 }, { id: "activity", label: "Activity", icon: ClockCounterClockwise, count: activity.length > 0 ? activity.length : (comments.length + reviews.length) }, ]; + const pathToMergeRuntime = React.useMemo(() => { + return { + phase: mapPathToMergePhase(convergenceState.status), + currentRound: Math.max(convergenceState.currentRound, inventorySnapshot?.convergence.currentRound ?? 0), + maxRounds: pipelineSettings.maxRounds, + autoConverge, + agentSessionId: convergenceSessionId, + sessionHref: convergenceSessionHref, + sessionLaneId: convergenceState.activeLaneId ?? pr.laneId, + pauseReason: convergencePauseReason, + pollerPhase: mapPathToMergePollerPhase(convergenceState.pollerStatus), + }; + }, [ + autoConverge, + convergenceState.activeLaneId, + convergenceState.currentRound, + convergenceState.pollerStatus, + convergenceState.status, + convergencePauseReason, + convergenceSessionHref, + convergenceSessionId, + inventorySnapshot, + pipelineSettings.maxRounds, + pr.laneId, + ]); + return (
{/* ===== HEADER ===== */} @@ -1134,6 +1616,34 @@ export function PrDetailPane({
+ onNavigate(href)} + onStop={handleStopConvergence} + onResume={() => { + behindCountRef.current = 0; + void saveConvergenceStateSafely({ + autoConvergeEnabled: true, + status: "polling", + pollerStatus: "scheduled", + pauseReason: null, + errorMessage: null, + }).catch(() => {}); + startAutoConvergePoller(); + }} + onDismissPause={() => { + behindCountRef.current = 0; + stopAutoConvergePoller(); + void saveConvergenceStateSafely({ + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + pauseReason: null, + lastStoppedAt: nowIso(), + }).catch(() => {}); + }} + /> + {/* Sub-tab bar */}
{DETAIL_TABS.map((tab) => { @@ -1177,10 +1687,10 @@ export function PrDetailPane({ {/* Right-side action buttons */}
- {issueResolutionAvailability.hasAnyActionableIssues ? ( + {inventorySnapshot?.items.length || pathToMergeRuntime.autoConverge || pathToMergeRuntime.agentSessionId ? (
); } diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx index 78bc1da8c..9596f2eee 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx @@ -1,958 +1,616 @@ import React from "react"; import { - X, - CircleNotch, - CheckCircle, - Warning, + ArrowsClockwise, + ArrowSquareOut, + ArrowUp, ChatText, + CheckCircle, + CircleNotch, CopySimple, - ArrowsClockwise, Eye, - Trash, - ArrowUp, GitBranch, Play, + Sparkle, + Trash, + Warning, } from "@phosphor-icons/react"; -import type { AiPermissionMode, PipelineSettings, PrCheck } from "../../../../shared/types"; -import { - COLORS, - MONO_FONT, - SANS_FONT, - outlineButton, - primaryButton, -} from "../../lanes/laneDesignTokens"; +import type { + AiPermissionMode, + ConvergenceStatus, + IssueInventoryItem, + IssueInventorySnapshot, + IssueInventoryState, + PipelineSettings, + PrCheck, +} from "../../../../shared/types"; +import { COLORS, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, primaryButton, dangerButton } from "../../lanes/laneDesignTokens"; import { PrPipelineSettings } from "./PrPipelineSettings"; -import { AgentChatPane } from "../../chat/AgentChatPane"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type IssueItemSeverity = "critical" | "major" | "minor"; -export type IssueItemSource = "coderabbit" | "codex" | "copilot" | "human" | "ade"; -export type IssueItemState = "new" | "in_progress" | "fixed" | "dismissed" | "escalated"; - -export type IssueInventoryItem = { - id: string; - state: IssueItemState; - severity: IssueItemSeverity; - headline: string; - filePath: string | null; - line: number | null; - source: IssueItemSource; - dismissReason: string | null; - agentSessionId: string | null; -}; +import { PrResolverLaunchControls } from "./PrResolverLaunchControls"; -export type ConvergenceStatus = { - state: "not_started" | "converging" | "stalled" | "complete"; +export type PathToMergeRuntimeState = { + phase: "idle" | "launching" | "working" | "polling" | "paused" | "converged" | "merged" | "stopped" | "error"; currentRound: number; maxRounds: number; + autoConverge: boolean; + agentSessionId: string | null; + sessionHref: string | null; + sessionLaneId: string | null; + pauseReason: string | null; + pollerPhase: "idle" | "waiting_checks" | "waiting_comments" | "polling" | "paused"; }; export type PrConvergencePanelProps = { - open: boolean; prNumber: number; prTitle: string; headBranch: string; baseBranch: string; - items: IssueInventoryItem[]; - convergence: ConvergenceStatus; + snapshot: IssueInventorySnapshot | null; checks: PrCheck[]; + runtime: PathToMergeRuntimeState; modelId: string; reasoningEffort: string; permissionMode: AiPermissionMode; busy: boolean; - agentSessionId: string | null; - autoConverge: boolean; - pipelineSettings: PipelineSettings; - onPipelineSettingsChange: (settings: Partial) => void; - onOpenChange: (open: boolean) => void; + additionalInstructions: string; + onAdditionalInstructionsChange: (value: string) => void; onModelChange: (modelId: string) => void; onReasoningEffortChange: (value: string) => void; onPermissionModeChange: (mode: AiPermissionMode) => void; - onRunNextRound: (additionalInstructions: string) => Promise; onAutoConvergeChange: (enabled: boolean) => void; + onLaunchAgent: (additionalInstructions: string) => Promise; + onStartNextRound: (additionalInstructions: string) => Promise; onCopyPrompt: (additionalInstructions: string) => Promise; + onStop: () => Promise | void; + onViewSession?: (href: string) => void; onMarkDismissed: (itemIds: string[], reason: string) => void; onMarkEscalated: (itemIds: string[]) => void; onResetInventory: () => void; - pauseReason?: string | null; - onResumePause?: () => void; - onDismissPause?: () => void; - convergenceMerged?: boolean; - onDismissMerged?: () => void; + pipelineSettings: PipelineSettings; + onPipelineSettingsChange: (settings: Partial) => void; }; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- +type ItemGroupKey = IssueInventoryState | "sent_to_agent"; -const SEVERITY_COLORS: Record = { - critical: "#EF4444", - major: "#F59E0B", - minor: "#6B7280", +const SOURCE_META: Record = { + coderabbit: { label: "CodeRabbit", color: "#22C55E" }, + codex: { label: "Codex", color: "#3B82F6" }, + copilot: { label: "Copilot", color: "#A855F7" }, + ade: { label: "ADE", color: "#A78BFA" }, + human: { label: "Human", color: "#E5E7EB" }, + unknown: { label: "Unknown", color: "#9CA3AF" }, }; -const SOURCE_META: Record = { - coderabbit: { label: "CR", color: "#22C55E" }, - codex: { label: "CX", color: "#3B82F6" }, - copilot: { label: "CP", color: "#A855F7" }, - human: { label: "HM", color: "#E5E7EB" }, - ade: { label: "ADE", color: "#A78BFA" }, +const STATE_META: Record = { + new: { label: "New", accent: "#F59E0B", icon: }, + sent_to_agent: { label: "Working", accent: "#A78BFA", icon: }, + fixed: { label: "Fixed", accent: "#22C55E", icon: }, + dismissed: { label: "Dismissed", accent: "#6B7280", icon: }, + escalated: { label: "Escalated", accent: "#F97316", icon: }, }; -const STATE_META: Record< - IssueItemState, - { label: string; accent: string; defaultExpanded: boolean; icon: React.ReactNode } -> = { - new: { - label: "Review Comments", - accent: "#F59E0B", - defaultExpanded: true, - icon: , - }, - in_progress: { - label: "In Progress", - accent: "#A78BFA", - defaultExpanded: true, - icon: , - }, - fixed: { - label: "Fixed", - accent: "#22C55E", - defaultExpanded: false, - icon: , - }, - dismissed: { - label: "Dismissed", - accent: "#6B7280", - defaultExpanded: false, - icon: , - }, - escalated: { - label: "Escalated", - accent: "#F97316", - defaultExpanded: true, - icon: , - }, +const STATE_ORDER: ItemGroupKey[] = ["escalated", "new", "sent_to_agent", "fixed", "dismissed"]; + +const STATUS_META: Record = { + idle: { label: "Idle", color: COLORS.textMuted, background: "rgba(255,255,255,0.03)", border: COLORS.border }, + launching: { label: "Launching", color: COLORS.warning, background: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.25)" }, + working: { label: "Agent working", color: COLORS.accent, background: `${COLORS.accent}10`, border: `${COLORS.accent}25` }, + polling: { label: "Polling for replies", color: COLORS.warning, background: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.25)" }, + paused: { label: "Paused", color: "#F59E0B", background: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.25)" }, + converged: { label: "Converged", color: COLORS.success, background: "rgba(34,197,94,0.10)", border: "rgba(34,197,94,0.25)" }, + merged: { label: "Merged", color: COLORS.success, background: "rgba(34,197,94,0.10)", border: "rgba(34,197,94,0.25)" }, + stopped: { label: "Stopped", color: COLORS.textDim, background: "rgba(255,255,255,0.02)", border: COLORS.border }, + error: { label: "Error", color: COLORS.danger, background: "rgba(239,68,68,0.08)", border: "rgba(239,68,68,0.25)" }, }; -const STATE_ORDER: IssueItemState[] = ["escalated", "new", "in_progress", "fixed", "dismissed"]; +function itemState(item: IssueInventoryItem): ItemGroupKey { + return item.state; +} -const CONVERGENCE_STATUS_STYLE: Record< - ConvergenceStatus["state"], - { bg: string; color: string; borderColor: string; label: string; pulse: boolean } -> = { - not_started: { - bg: "rgba(107,114,128,0.12)", - color: "#9CA3AF", - borderColor: "rgba(107,114,128,0.25)", - label: "Not started", - pulse: false, - }, - converging: { - bg: "rgba(34,197,94,0.10)", - color: "#4ADE80", - borderColor: "rgba(34,197,94,0.30)", - label: "Converging", - pulse: true, - }, - stalled: { - bg: "rgba(245,158,11,0.10)", - color: "#FBBF24", - borderColor: "rgba(245,158,11,0.30)", - label: "Stalled", - pulse: false, - }, - complete: { - bg: "rgba(34,197,94,0.14)", - color: "#22C55E", - borderColor: "rgba(34,197,94,0.35)", - label: "Complete", - pulse: false, - }, -}; +function groupItems(items: IssueInventoryItem[]): Record { + const grouped: Record = { + new: [], + sent_to_agent: [], + fixed: [], + dismissed: [], + escalated: [], + }; + for (const item of items) { + grouped[itemState(item)].push(item); + } + return grouped; +} -// --------------------------------------------------------------------------- -// Keyframes (injected once) -// --------------------------------------------------------------------------- +function formatLocation(item: IssueInventoryItem): string | null { + if (!item.filePath) return null; + return item.line != null ? `${item.filePath}:${item.line}` : item.filePath; +} -const STYLE_ID = "pr-convergence-panel-keyframes"; +function bodyPreview(body: string | null): string | null { + const value = (body ?? "").trim(); + if (!value) return null; + return value.replace(/\s+/g, " "); +} -function ensureKeyframes() { - if (typeof document === "undefined") return; - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement("style"); - style.id = STYLE_ID; - style.textContent = ` - @keyframes convergePulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } - } - @keyframes convergeSpin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } - } - @keyframes convergeSlideDown { - from { opacity: 0; max-height: 0; } - to { opacity: 1; max-height: 2000px; } - } - @keyframes convergeFadeIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } - } - @keyframes convergeDotStep { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.35); } - } - `; - document.head.appendChild(style); +function itemSummary(item: IssueInventoryItem): string { + const latestAuthor = item.threadLatestCommentAuthor ?? item.author; + if (!latestAuthor) return "Latest reply"; + return `Latest reply by ${latestAuthor}`; } -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- +function sourceMeta(source: string | null | undefined): { label: string; color: string } { + if (!source) return SOURCE_META.unknown; + return SOURCE_META[source] ?? { label: source, color: COLORS.textMuted }; +} -function RoundIndicator({ current, max }: { current: number; max: number }) { - return ( -
- - Round {current} of {max} - -
- {Array.from({ length: max }, (_, i) => { - const isCurrent = i + 1 === current; - const isComplete = i + 1 < current; - let dotColor = "rgba(255,255,255,0.12)"; - if (isComplete) dotColor = COLORS.success; - else if (isCurrent) dotColor = COLORS.accent; - return ( -
- ); - })} -
-
- ); +function displaySourceMeta(item: IssueInventoryItem): { label: string; color: string } { + if (item.source !== "unknown" && item.source !== "human") return sourceMeta(item.source); + const author = (item.threadLatestCommentAuthor ?? item.author ?? "").trim(); + if (!author) return sourceMeta(item.source); + return { + label: author.replace(/\[bot\]$/i, ""), + color: item.source === "human" ? SOURCE_META.human.color : COLORS.textMuted, + }; } -function ConvergenceStatusPill({ status }: { status: ConvergenceStatus["state"] }) { - const meta = CONVERGENCE_STATUS_STYLE[status]; +function StatusPill({ phase }: { phase: PathToMergeRuntimeState["phase"] }) { + const meta = STATUS_META[phase]; return ( - {meta.pulse ? ( - + {phase === "working" || phase === "polling" || phase === "launching" ? ( + ) : null} {meta.label} ); } -function StatsBar({ items }: { items: IssueInventoryItem[] }) { - const counts: Record = { new: 0, in_progress: 0, fixed: 0, dismissed: 0, escalated: 0 }; - for (const item of items) { - counts[item.state]++; - } - - const stats: Array<{ label: string; count: number; color: string }> = [ - { label: "new", count: counts.new, color: "#F59E0B" }, - { label: "fixed", count: counts.fixed, color: "#22C55E" }, - { label: "dismissed", count: counts.dismissed, color: "#6B7280" }, - { label: "escalated", count: counts.escalated, color: "#F97316" }, - ]; - +function RuntimeSummary({ + runtime, + convergence, +}: { + runtime: PathToMergeRuntimeState; + convergence: ConvergenceStatus | null; +}) { + const currentRound = runtime.currentRound > 0 ? runtime.currentRound : convergence?.currentRound ?? 0; + const maxRounds = runtime.maxRounds > 0 ? runtime.maxRounds : convergence?.maxRounds ?? 5; return (
- {stats.map((stat) => ( -
+ + Round {currentRound || 0} of {maxRounds} + + + {runtime.autoConverge ? ( + Auto-converge + ) : ( + Manual launch + )} + {runtime.pauseReason ? ( + {runtime.pauseReason} + ) : null} +
+ ); +} + +function SummaryCounts({ items }: { items: IssueInventoryItem[] }) { + const grouped = groupItems(items); + const counts: Array<{ key: ItemGroupKey; color: string }> = [ + { key: "new", color: "#F59E0B" }, + { key: "sent_to_agent", color: "#A78BFA" }, + { key: "fixed", color: "#22C55E" }, + { key: "dismissed", color: "#6B7280" }, + { key: "escalated", color: "#F97316" }, + ]; + return ( +
+ {counts.map((entry) => ( + 0 ? 1 : 0.3, + background: entry.color, + opacity: grouped[entry.key].length > 0 ? 1 : 0.35, }} /> - 0 ? COLORS.textPrimary : COLORS.textDim, - }} - > - {stat.count} - - - {stat.label} - -
+ {STATE_META[entry.key].label} + {grouped[entry.key].length} + ))}
); } -function SeverityBadge({ severity }: { severity: IssueItemSeverity }) { - const color = SEVERITY_COLORS[severity]; - return ( - - {severity} - - ); -} - -function SourceTag({ source }: { source: IssueItemSource }) { - const meta = SOURCE_META[source]; - return ( - - {meta.label} - - ); -} - -function IssueRow({ +function InventoryRow({ item, - showAgent, + busy, onDismiss, onEscalate, }: { item: IssueInventoryItem; - showAgent?: boolean; - onDismiss?: (itemId: string) => void; - onEscalate?: (itemId: string) => void; + busy: boolean; + onDismiss: (itemId: string) => void; + onEscalate: (itemId: string) => void; }) { - let location: string | null = null; - if (item.filePath) { - location = item.line != null ? `${item.filePath}:${item.line}` : item.filePath; - } + const meta = displaySourceMeta(item); + const preview = bodyPreview(item.body); + const location = formatLocation(item); return (
- -
+
+ + {meta.label} +
{item.headline}
- {location ? ( -
- {location} -
+ + working + ) : null}
- {showAgent && item.agentSessionId ? ( - - - agent - - ) : null} - {item.state === "fixed" ? ( - + {preview} +
) : null} - {onDismiss && item.state !== "fixed" && item.state !== "dismissed" ? ( + +
+ {itemSummary(item)} + {location ? ( + {location} + ) : null} + {item.dismissReason ? ( + + {item.dismissReason} + + ) : null} +
+ +
- ) : null} - {onEscalate && item.state !== "escalated" && item.state !== "fixed" && item.state !== "dismissed" ? ( - ) : null} - +
); } -function FixedRow({ item }: { item: IssueInventoryItem }) { +function SectionHeader({ + title, + count, + accent, + icon, +}: { + title: string; + count: number; + accent: string; + icon: React.ReactNode; +}) { return (
- + {icon} - {item.headline} + {title} - + {count}
); } -function DismissedRow({ item }: { item: IssueInventoryItem }) { +function CheckRow({ check }: { check: PrCheck }) { + const isPassing = check.conclusion === "success"; + const isFailing = check.conclusion === "failure"; + const isRunning = check.status === "in_progress" || check.status === "queued"; + const color = isPassing ? COLORS.success : isFailing ? COLORS.danger : isRunning ? COLORS.warning : COLORS.textDim; + return (
- - + ) : isPassing ? ( + + ) : isFailing ? ( + + ) : ( + + )} +
- {item.headline} + {check.name} +
+ + {isRunning ? "running" : check.conclusion ?? check.status} - {item.dismissReason ? ( - - {item.dismissReason} - - ) : null}
); } -function CheckRow({ check }: { check: PrCheck }) { - const isPassing = check.conclusion === "success"; - const isFailing = check.conclusion === "failure"; - const isRunning = check.status === "in_progress"; - let statusColor: string = COLORS.textDim; - if (isPassing) statusColor = COLORS.success; - else if (isFailing) statusColor = COLORS.danger; - else if (isRunning) statusColor = COLORS.warning; - +function EmptyState({ + title, + description, +}: { + title: string; + description: string; +}) { return (
- {isRunning ? ( - - ) : isPassing ? ( - - ) : isFailing ? ( - - ) : ( - - )} - - {check.name} - - - {isRunning ? "running" : check.conclusion ?? check.status} - -
- ); -} - -function AutoConvergeSwitch({ - enabled, - onChange, - remainingRounds, - disabled, -}: { - enabled: boolean; - onChange: (enabled: boolean) => void; - remainingRounds: number; - disabled: boolean; -}) { - return ( -
- -
- - Auto-Converge - - {enabled ? ( - - Will auto-run up to {remainingRounds} more round{remainingRounds !== 1 ? "s" : ""} - - ) : null} -
+
{title}
+
{description}
); } -// --------------------------------------------------------------------------- -// Main component -// --------------------------------------------------------------------------- - export function PrConvergencePanel({ - open, prNumber, prTitle, headBranch, baseBranch, - items, - convergence, + snapshot, checks, + runtime, modelId, reasoningEffort, permissionMode, busy, - agentSessionId, - autoConverge, - pipelineSettings, - onPipelineSettingsChange, - onOpenChange, + additionalInstructions, + onAdditionalInstructionsChange, onModelChange, onReasoningEffortChange, onPermissionModeChange, - onRunNextRound, onAutoConvergeChange, + onLaunchAgent, + onStartNextRound, onCopyPrompt, + onStop, + onViewSession, onMarkDismissed, onMarkEscalated, onResetInventory, - pauseReason, - onResumePause, - onDismissPause, - convergenceMerged, - onDismissMerged, + pipelineSettings, + onPipelineSettingsChange, }: PrConvergencePanelProps) { - const [additionalInstructions, setAdditionalInstructions] = React.useState(""); - const scrollRef = React.useRef(null); - const previousFocusRef = React.useRef(null); - - React.useEffect(() => { - ensureKeyframes(); - }, []); - - React.useEffect(() => { - if (open) { - setAdditionalInstructions(""); - } - }, [open]); - - // Focus management: capture previously focused element and focus the dialog React.useEffect(() => { - if (!open) return; - - previousFocusRef.current = document.activeElement; - - // Focus the dialog container on next frame so the ref is attached - requestAnimationFrame(() => { - scrollRef.current?.focus(); - }); - - return () => { - // Restore focus to the previously focused element on unmount - if (previousFocusRef.current instanceof HTMLElement) { - previousFocusRef.current.focus(); + const styleId = "ptm-convergence-keyframes"; + if (typeof document === "undefined" || document.getElementById(styleId)) return; + const style = document.createElement("style"); + style.id = styleId; + style.textContent = ` + @keyframes ptmSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } - }; - }, [open]); - - // Escape key handler - React.useEffect(() => { - if (!open) return; - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape" && !busy) { - e.stopPropagation(); - onOpenChange(false); - } - } - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [open, busy, onOpenChange]); - - - if (!open) return null; - - // Group items by state - const grouped: Record = { - new: [], - in_progress: [], - fixed: [], - dismissed: [], - escalated: [], - }; - for (const item of items) { - grouped[item.state].push(item); - } - - const reviewCommentItems = [...grouped.escalated, ...grouped.new, ...grouped.in_progress, ...grouped.fixed, ...grouped.dismissed]; - const failingChecks = checks.filter((c) => c.conclusion === "failure"); - const runningChecks = checks.filter((c) => c.status === "in_progress"); - const allChecksPassing = failingChecks.length === 0 && runningChecks.length === 0; - const passingChecks = checks.filter((c) => c.conclusion === "success"); - const otherChecks = checks.filter( - (c) => c.conclusion !== "failure" && c.conclusion !== "success" && c.status !== "in_progress", - ); - const orderedChecks = [...failingChecks, ...runningChecks, ...otherChecks, ...passingChecks]; + `; + document.head.appendChild(style); + }, []); + const items = snapshot?.items ?? []; + const grouped = React.useMemo(() => groupItems(items), [items]); + const convergence = snapshot?.convergence ?? null; + const failingChecks = checks.filter((check) => check.conclusion === "failure"); + const pendingChecks = checks.filter((check) => check.status === "queued" || check.status === "in_progress"); + const allChecksPassing = checks.length > 0 && failingChecks.length === 0 && pendingChecks.length === 0; const hasNewItems = grouped.new.length > 0; - const atMaxRounds = convergence.currentRound >= convergence.maxRounds; - const canRunNext = hasNewItems && !atMaxRounds && !busy; - const remainingRounds = Math.max(0, convergence.maxRounds - convergence.currentRound); - - const truncatedTitle = - prTitle.length > 60 ? `${prTitle.slice(0, 59)}...` : prTitle; + const sessionActive = runtime.phase === "launching" || runtime.phase === "working" || runtime.phase === "polling"; + const actionDisabled = busy || sessionActive || !hasNewItems; + const actionLabel = busy || sessionActive + ? runtime.phase === "launching" + ? "Launching..." + : "Working..." + : runtime.autoConverge + ? "Start Next Round" + : "Launch Agent"; + const actionIcon = busy || sessionActive ? : ; return ( -
{ - if (!busy) onOpenChange(false); - }} - > -
e.stopPropagation()} - style={{ - width: "min(1200px, calc(100vw - 40px))", - maxHeight: "min(800px, calc(100vh - 64px))", - display: "flex", - flexDirection: "column", - background: "#0F0D14", - border: `1px solid rgba(255,255,255,0.07)`, - borderRadius: 18, - boxShadow: "0 40px 120px rgba(0,0,0,0.7), 0 0 1px rgba(255,255,255,0.08) inset", - overflow: "hidden", - animation: "convergeFadeIn 0.2s ease-out", - outline: "none", - }} - > - {/* ---- Header ---- */} -
-
-
- - Path to Merge - - {autoConverge && ( - <> - - - - - )} -
- -
+
+
+
+
+
#{prNumber} @@ -960,665 +618,350 @@ export function PrConvergencePanel({ - {truncatedTitle} + {prTitle}
- -
- +
{headBranch} - - into - + into {baseBranch}
- - +
- {/* ---- Stats bar ---- */} - + +
- {/* ---- Three-column body ---- */} -
- {/* Left: Review Comments */} -
-
- - - Review Comments - - +
+
+
+
+ + + Review comments + +
+ - ) : null} + + Reset +
-
- {reviewCommentItems.length > 0 ? ( - <> + +
+ {items.length > 0 ? ( +
{STATE_ORDER.map((state) => { const stateItems = grouped[state]; if (stateItems.length === 0) return null; const meta = STATE_META[state]; return ( -
-
- - {state === "in_progress" ? ( - - {meta.icon} - - ) : ( - meta.icon - )} - - - {meta.label} - - - {stateItems.length} - -
-
- {stateItems.map((item) => { - if (state === "fixed") return ; - if (state === "dismissed") return ; - return ( - onMarkDismissed([id], "Dismissed from UI")} - onEscalate={(id) => onMarkEscalated([id])} - /> - ); - })} +
+ +
+ {stateItems.map((item) => ( + onMarkDismissed([itemId], "Dismissed from Path to Merge")} + onEscalate={(itemId) => onMarkEscalated([itemId])} + /> + ))}
); })} - - ) : ( -
- - No issues have been inventoried yet. Run the first round to discover issues from review comments. -
+ ) : ( + )}
- {/* Middle: CI Checks */} -
-
- - - CI Checks - - - {allChecksPassing ? "all passing" : `${failingChecks.length} failing`} +
+
+ + + Working rules
-
- {orderedChecks.length > 0 ? ( -
- {orderedChecks.map((check) => ( - - ))} -
- ) : ( -
+
+ + {runtime.sessionHref ? ( +
- )} + + View agent session + + ) : null} + {runtime.agentSessionId ? ( + + ) : null} +
+
+ Keep the prompt narrow. The convergence loop only sends new issues to the agent, so the instruction box should add context, not repeat the full inventory. +
+
- {/* Right: Settings column */} -
- {/* Pipeline Settings */} -
-
- Pipeline Settings +
+
+
+
+ + + CI checks +
- -
- - {/* Additional Instructions */} -
-
- Additional Instructions -
-
- Add custom instructions that will be injected into the agent's prompt for this round. Use this to guide the agent's approach or add context. -
-