From f8aa8450d1c91c92ef5bc8e6f61585e9c60eeec4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 19:24:05 -0700 Subject: [PATCH 01/12] Add Review Sweep: one-click agentic review of unsettled work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sidebar-v2 button that reviews every unsettled thread across all connected environments: an LLM summarizes each thread, suggests a corrected title when the current one is misleading, and recommends settling threads whose work has concluded. Results stream into a new /review-sweep page with per-item Apply buttons and Apply All — nothing applies without a click. - contracts: ReviewThreadSummaryInput/Result, review.summarizeThread WS RPC, reviewSweep capability flag (old servers are skipped) - server: generateThreadReview TextGeneration op across all five providers, conservative prompt with transcript capping, and ReviewService.summarizeThread reading the projection - client-runtime: summarizeThread RPC command atom - web: reviewSweepStore runner (concurrency 3, runId-guarded, survives navigation), /review-sweep view, SidebarV2 entry button Settle recommendations are blocked for active threads at four layers: prompt rules, normalizeThreadReview, a server-side projection re-check (never trusts the client's canSettleNow), and a client canSettle re-check at apply time. Co-Authored-By: Claude Fable 5 --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/git/GitManager.test.ts | 1 + apps/server/src/review/ReviewService.test.ts | 272 ++++++++++++++++ apps/server/src/review/ReviewService.ts | 98 ++++++ apps/server/src/server.test.ts | 14 + apps/server/src/server.ts | 1 + .../textGeneration/ClaudeTextGeneration.ts | 29 +- .../src/textGeneration/CodexTextGeneration.ts | 33 +- .../textGeneration/CursorTextGeneration.ts | 26 +- .../src/textGeneration/GrokTextGeneration.ts | 26 +- .../textGeneration/OpenCodeTextGeneration.ts | 27 +- .../src/textGeneration/TextGeneration.test.ts | 2 + .../src/textGeneration/TextGeneration.ts | 36 ++- .../TextGenerationPrompts.test.ts | 90 +++++- .../textGeneration/TextGenerationPrompts.ts | 64 ++++ .../src/textGeneration/TextGenerationUtils.ts | 37 +++ apps/server/src/ws.ts | 5 + apps/web/src/components/SidebarV2.tsx | 31 ++ .../reviewSweep/ReviewSweepView.tsx | 287 +++++++++++++++++ apps/web/src/reviewSweepStore.ts | 297 ++++++++++++++++++ apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/review-sweep.tsx | 15 + packages/client-runtime/src/state/review.ts | 6 +- packages/contracts/src/environment.ts | 4 + packages/contracts/src/review.ts | 34 +- packages/contracts/src/rpc.ts | 13 + 26 files changed, 1457 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/reviewSweep/ReviewSweepView.tsx create mode 100644 apps/web/src/reviewSweepStore.ts create mode 100644 apps/web/src/routes/review-sweep.tsx diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 181237d76b6..ceea577330a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + reviewSweep: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 256f10fb3b8..83a1c868b37 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -365,6 +365,7 @@ function createTextGeneration( }), ), ), + generateThreadReview: () => Effect.die("generateThreadReview is not used by GitManager tests"), }; } diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 839eb73b2bb..a43202b0f77 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,17 +3,59 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import { + ThreadId, + type OrchestrationThread, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; + import { ServerConfig } from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ReviewService from "./ReviewService.ts"; +function makeShellFromThread( + thread: OrchestrationThread, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + session: thread.session, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + function makeLayer(input: { readonly workspaceRoot: string; readonly baseDir: string; readonly detectCalls?: Array<{ readonly cwd: string }>; + readonly thread?: OrchestrationThread; + readonly shellOverrides?: Partial; + readonly generateThreadReview?: TextGeneration.TextGeneration["Service"]["generateThreadReview"]; + readonly reviewCalls?: Array; }) { return ReviewService.layer.pipe( Layer.provide( @@ -28,11 +70,92 @@ function makeLayer(input: { }), ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getThreadDetailById: (threadId) => + Effect.succeed( + input.thread && input.thread.id === threadId + ? Option.some(input.thread) + : Option.none(), + ), + getThreadShellById: (threadId) => + Effect.succeed( + input.thread && input.thread.id === threadId + ? Option.some(makeShellFromThread(input.thread, input.shellOverrides)) + : Option.none(), + ), + getProjectShellById: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(TextGeneration.TextGeneration)({ + generateThreadReview: (reviewInput) => { + input.reviewCalls?.push(reviewInput); + return ( + input.generateThreadReview?.(reviewInput) ?? + Effect.succeed({ + summary: "Did the thing.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Work concluded.", + }) + ); + }, + }), + ), + Layer.provide(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), Layer.provideMerge(NodeServices.layer), ); } +function makeThread(overrides: Partial = {}): OrchestrationThread { + return { + id: ThreadId.make("thread_review_1"), + projectId: "project_1" as OrchestrationThread["projectId"], + title: "New thread", + modelSelection: { + instanceId: "codex" as OrchestrationThread["modelSelection"]["instanceId"], + model: "gpt-5", + options: [], + }, + runtimeMode: "local", + interactionMode: "chat", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-07-20T00:00:00.000Z", + updatedAt: "2026-07-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...overrides, + } as OrchestrationThread; +} + +function makeMessage(input: { + readonly id: string; + readonly role: "user" | "assistant" | "system"; + readonly text: string; + readonly streaming?: boolean; +}): OrchestrationThread["messages"][number] { + return { + id: input.id as OrchestrationThread["messages"][number]["id"], + role: input.role, + text: input.text, + turnId: null, + streaming: input.streaming ?? false, + createdAt: "2026-07-20T00:00:00.000Z", + updatedAt: "2026-07-20T00:00:00.000Z", + }; +} + describe("ReviewService", () => { it.effect("rejects diff preview cwd outside the configured workspace roots", () => Effect.gen(function* () { @@ -97,4 +220,153 @@ describe("ReviewService", () => { assert.deepStrictEqual(detectCalls, []); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("summarizeThread fails with ReviewThreadNotFoundError for unknown threads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review + .summarizeThread({ threadId: ThreadId.make("thread_missing"), canSettleNow: true }) + .pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir }))); + + assert.strictEqual(error._tag, "ReviewThreadNotFoundError"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread passes transcript context and returns the review", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({ + messages: [ + makeMessage({ id: "msg_1", role: "user", text: "Fix the settle default" }), + makeMessage({ id: "msg_2", role: "assistant", text: "Done, merged in PR #1" }), + makeMessage({ id: "msg_3", role: "assistant", text: "streaming...", streaming: true }), + ], + }); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + generateThreadReview: () => + Effect.succeed({ + summary: "Bumped the settle default; PR merged.", + suggestedTitle: "Bump settle default", + recommendSettle: true, + settleReason: "PR merged, nothing pending.", + }), + }), + ), + ); + + assert.strictEqual(result.threadId, thread.id); + assert.strictEqual(result.summary, "Bumped the settle default; PR merged."); + assert.strictEqual(result.suggestedTitle, "Bump settle default"); + assert.strictEqual(result.recommendSettle, true); + assert.strictEqual(result.settleReason, "PR merged, nothing pending."); + + assert.strictEqual(reviewCalls.length, 1); + const call = reviewCalls[0]!; + assert.strictEqual(call.title, "New thread"); + assert.strictEqual(call.isActive, false); + assert.strictEqual(call.firstUserMessage, "Fix the settle default"); + // Streaming messages are excluded from the transcript. + assert.deepStrictEqual( + call.recentMessages.map((message) => message.text), + ["Fix the settle default", "Done, merged in PR #1"], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread never recommends settling when canSettleNow is false", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({}); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: false }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + // Model misbehaves and recommends settle anyway. + generateThreadReview: () => + Effect.succeed({ + summary: "Still working.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Looks done to me.", + }), + }), + ), + ); + + assert.strictEqual(reviewCalls[0]?.isActive, true); + assert.strictEqual(result.recommendSettle, false); + assert.strictEqual(result.settleReason, null); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "summarizeThread overrides a stale client canSettleNow when the projection shows activity", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-review-workspace-", + }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({}); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + // Client believed the thread was settleable, but the server-side + // shell says approvals are pending. + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + shellOverrides: { hasPendingApprovals: true }, + generateThreadReview: () => + Effect.succeed({ + summary: "Looks finished.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Done.", + }), + }), + ), + ); + + assert.strictEqual(reviewCalls[0]?.isActive, true); + assert.strictEqual(result.recommendSettle, false); + assert.strictEqual(result.settleReason, null); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index db1dc5bc8d2..bea5233af44 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -3,17 +3,27 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { + ReviewThreadNotFoundError, + TextGenerationError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, type ReviewDiffPreviewError, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, + type ReviewThreadSummaryError, + type ReviewThreadSummaryInput, + type ReviewThreadSummaryResult, } from "@t3tools/contracts"; +import { resolveThreadWorkspaceCwd } from "../checkpointing/Utils.ts"; import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -23,6 +33,9 @@ export class ReviewService extends Context.Service< readonly getDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; + readonly summarizeThread: ( + input: ReviewThreadSummaryInput, + ) => Effect.Effect; } >()("t3/review/ReviewService") {} @@ -32,6 +45,9 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const textGeneration = yield* TextGeneration.TextGeneration; + const serverSettings = yield* ServerSettings.ServerSettingsService; const canonicalizePath = (value: string) => { const resolvedPath = path.resolve(value); @@ -106,8 +122,90 @@ export const make = Effect.gen(function* () { return yield* getDriverDiffPreview(input); }); + const summarizeThread: ReviewService["Service"]["summarizeThread"] = Effect.fn( + "ReviewService.summarizeThread", + )(function* (input) { + const mapProjectionError = (cause: unknown) => + new TextGenerationError({ + operation: "generateThreadReview", + detail: "Failed to read the thread projection for review.", + cause, + }); + + const threadOption = yield* projectionSnapshotQuery + .getThreadDetailById(input.threadId) + .pipe(Effect.mapError(mapProjectionError)); + if (Option.isNone(threadOption)) { + return yield* new ReviewThreadNotFoundError({ threadId: input.threadId }); + } + const thread = threadOption.value; + + const projectOption = yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.mapError(mapProjectionError)); + const cwd = + resolveThreadWorkspaceCwd({ + thread, + projects: Option.isSome(projectOption) ? [projectOption.value] : [], + }) ?? process.cwd(); + + const settled = thread.messages.filter((message) => !message.streaming); + const firstUserMessage = settled.find((message) => message.role === "user")?.text ?? null; + const recentMessages = settled.map((message) => ({ + role: message.role, + text: message.text, + })); + // Never trust the client's canSettleNow alone: re-derive activity from the + // server's own projection so a session that started (or approval/input + // that appeared) after the client read its shell still blocks settling. + const shellOption = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe(Effect.mapError(mapProjectionError)); + const shell = Option.isSome(shellOption) ? shellOption.value : null; + const serverSideActive = + shell === null || + shell.hasPendingApprovals || + shell.hasPendingUserInput || + shell.session?.status === "starting" || + shell.session?.status === "running"; + const canSettleNow = input.canSettleNow && !serverSideActive; + const isActive = !canSettleNow; + + const { textGenerationModelSelection: modelSelection } = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadReview", + detail: "Failed to load server settings for review generation.", + cause, + }), + ), + ); + + const generated = yield* textGeneration.generateThreadReview({ + cwd, + title: thread.title, + isActive, + firstUserMessage, + recentMessages, + modelSelection, + }); + + // Belt and braces on top of prompt rules + normalizeThreadReview: an + // active thread must never carry a settle recommendation. + const recommendSettle = generated.recommendSettle && canSettleNow; + return { + threadId: input.threadId, + summary: generated.summary, + suggestedTitle: generated.suggestedTitle, + recommendSettle, + settleReason: recommendSettle ? generated.settleReason : null, + }; + }); + return ReviewService.of({ getDiffPreview, + summarizeThread, }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..c98baf9333a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -87,6 +87,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -522,6 +523,19 @@ const buildAppUnderTest = (options?: { : ReviewService.layer.pipe( Layer.provideMerge(gitVcsDriverLayer), Layer.provide(vcsDriverRegistryLayer), + Layer.provide( + Layer.mock(TextGeneration.TextGeneration)({ + generateThreadReview: () => Effect.die("TextGeneration not stubbed in this test"), + }), + ), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getThreadDetailById: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + ...options?.layers?.projectionSnapshotQuery, + }), + ), + Layer.provide(ServerSettings.layerTest()), ); const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..48091ff3db3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -222,6 +222,7 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l const ReviewLayerLive = ReviewService.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provide(TextGeneration.layer), ); const VcsLayerLive = Layer.empty.pipe( diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 453bb62b728..0dcf38d1bd1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -23,10 +23,12 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -85,7 +87,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadReview", value: unknown, detail: string, ): Effect.Effect => @@ -115,7 +118,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -355,10 +359,31 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("ClaudeTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + }); + + const generated = yield* runClaudeJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6a5c0df43c7..9edf777fcb3 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -21,10 +21,12 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -98,7 +100,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadReview", value: unknown, ): Effect.Effect => encodeJsonString(value).pipe( @@ -117,7 +120,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadReview", attachments: TextGeneration.BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { @@ -159,7 +163,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -398,10 +403,32 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("CodexTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + }); + + const generated = yield* runCodexJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths: [], + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 6d1e5d02db3..35609b0be83 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -15,9 +15,11 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -54,7 +56,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -255,10 +258,31 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("CursorTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + }); + + const generated = yield* runCursorJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index fd367acdf4c..4cf4fcda84b 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -16,9 +16,11 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -52,7 +54,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -247,10 +250,31 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("GrokTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + }); + + const generated = yield* runGrokJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1f94f970692..62c493f63f6 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -22,10 +22,12 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -39,6 +41,7 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ "generatePrContent", "generateBranchName", "generateThreadTitle", + "generateThreadReview", ]); type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; @@ -253,7 +256,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; }) => sharedServerMutex.withPermit( Effect.gen(function* () { @@ -611,10 +615,31 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("OpenCodeTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + }); + + const generated = yield* runOpenCodeJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 9bccb9c1fc5..b83dd194dbf 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -21,6 +21,8 @@ const makeStubTextGeneration = ( generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateThreadReview: () => + Effect.die("generateThreadReview stub not configured for this test"), ...overrides, }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..7f218975723 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -67,6 +67,26 @@ export interface ThreadTitleGenerationResult { title: string; } +export interface ThreadReviewGenerationInput { + cwd: string; + title: string; + /** Active threads (running session or awaiting the user) never get a settle + recommendation. */ + isActive: boolean; + firstUserMessage: string | null; + recentMessages: ReadonlyArray<{ role: "user" | "assistant" | "system"; text: string }>; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface ThreadReviewGenerationResult { + summary: string; + /** Null when the current title is still accurate. */ + suggestedTitle: string | null; + recommendSettle: boolean; + settleReason: string | null; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, @@ -74,6 +94,7 @@ export interface TextGenerationService { generatePrContent(input: PrContentGenerationInput): Promise; generateBranchName(input: BranchNameGenerationInput): Promise; generateThreadTitle(input: ThreadTitleGenerationInput): Promise; + generateThreadReview(input: ThreadReviewGenerationInput): Promise; } /** @@ -109,6 +130,14 @@ export class TextGeneration extends Context.Service< readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** + * Review a thread transcript for the work-review sweep: summary, + * optional corrected title, and a settle recommendation. + */ + readonly generateThreadReview: ( + input: ThreadReviewGenerationInput, + ) => Effect.Effect; } >()("t3/textGeneration/TextGeneration") {} @@ -119,7 +148,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], @@ -159,6 +189,10 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), ), + generateThreadReview: (input) => + resolveInstance(registry, "generateThreadReview", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadReview(input)), + ), }); export const make = Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index b67e8b93c4a..f920c8b363f 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -4,9 +4,14 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { + normalizeCliError, + normalizeThreadReview, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; import { TextGenerationError } from "@t3tools/contracts"; describe("buildCommitMessagePrompt", () => { @@ -136,6 +141,89 @@ describe("buildThreadTitlePrompt", () => { }); }); +describe("buildThreadReviewPrompt", () => { + it("includes title, first user message, and transcript", () => { + const result = buildThreadReviewPrompt({ + title: "New thread", + isActive: false, + firstUserMessage: "Fix the settle default", + recentMessages: [ + { role: "user", text: "Fix the settle default" }, + { role: "assistant", text: "Done, merged." }, + ], + }); + + expect(result.prompt).toContain("Current title: New thread"); + expect(result.prompt).toContain("Fix the settle default"); + expect(result.prompt).toContain("[assistant] Done, merged."); + expect(result.prompt).not.toContain("ACTIVE"); + }); + + it("adds the never-settle rule for active threads", () => { + const result = buildThreadReviewPrompt({ + title: "Working thread", + isActive: true, + firstUserMessage: "Do a thing", + recentMessages: [], + }); + + expect(result.prompt).toContain("recommendSettle must be false"); + expect(result.prompt).toContain("(no messages)"); + }); + + it("caps the transcript to the most recent 20 messages", () => { + const result = buildThreadReviewPrompt({ + title: "Long thread", + isActive: false, + firstUserMessage: "start", + recentMessages: Array.from({ length: 30 }, (_, index) => ({ + role: "assistant" as const, + text: `message-${index}`, + })), + }); + + expect(result.prompt).not.toContain("message-9\n"); + expect(result.prompt).toContain("message-10"); + expect(result.prompt).toContain("message-29"); + }); +}); + +describe("normalizeThreadReview", () => { + const raw = { + summary: " Work finished. ", + suggestedTitle: '"Ship settle default"', + recommendSettle: true, + settleReason: " PR merged. ", + }; + + it("sanitizes fields and keeps settle recommendation for inactive threads", () => { + expect(normalizeThreadReview(raw, false)).toEqual({ + summary: "Work finished.", + suggestedTitle: "Ship settle default", + recommendSettle: true, + settleReason: "PR merged.", + }); + }); + + it("forces recommendSettle off for active threads even if the model says settle", () => { + const result = normalizeThreadReview(raw, true); + expect(result.recommendSettle).toBe(false); + expect(result.settleReason).toBeNull(); + }); + + it("drops empty and placeholder title suggestions", () => { + expect( + normalizeThreadReview({ ...raw, suggestedTitle: " " }, false).suggestedTitle, + ).toBeNull(); + expect( + normalizeThreadReview({ ...raw, suggestedTitle: "New thread" }, false).suggestedTitle, + ).toBeNull(); + expect( + normalizeThreadReview({ ...raw, suggestedTitle: null }, false).suggestedTitle, + ).toBeNull(); + }); +}); + describe("sanitizeThreadTitle", () => { it("truncates long titles with the shared sidebar-safe limit", () => { expect( diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d4..e501760fd4a 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -216,3 +216,67 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Thread review (work-review sweep) +// --------------------------------------------------------------------------- + +export interface ThreadReviewMessage { + role: "user" | "assistant" | "system"; + text: string; +} + +export interface ThreadReviewPromptInput { + title: string; + /** True when the thread has a running session or pending approvals/input. + Active threads must never get a settle recommendation. */ + isActive: boolean; + firstUserMessage: string | null; + recentMessages: ReadonlyArray; +} + +const THREAD_REVIEW_RECENT_MESSAGE_COUNT = 20; +const THREAD_REVIEW_MESSAGE_CHAR_LIMIT = 2_000; + +export function buildThreadReviewPrompt(input: ThreadReviewPromptInput) { + const recent = input.recentMessages.slice(-THREAD_REVIEW_RECENT_MESSAGE_COUNT); + const transcript = recent + .map( + (message) => + `[${message.role}] ${limitSection(message.text, THREAD_REVIEW_MESSAGE_CHAR_LIMIT)}`, + ) + .join("\n\n"); + + const prompt = [ + "You review a coding-agent conversation thread and report its state.", + "Return a JSON object with keys: summary, suggestedTitle, recommendSettle, settleReason.", + "Rules:", + "- summary: 1-2 sentences covering what was asked and where the work landed", + "- suggestedTitle: a corrected 3-8 word title ONLY if the current title is misleading or a placeholder like 'New thread'; otherwise null", + "- recommendSettle: true ONLY when the work clearly concluded (done, abandoned, or superseded) and nothing awaits the user", + "- settleReason: one short sentence justifying recommendSettle, or null when recommendSettle is false", + "- be conservative: when in doubt, do not suggest a title change and do not recommend settling", + ...(input.isActive + ? [ + "- this thread is still ACTIVE (running or awaiting the user): recommendSettle must be false", + ] + : []), + "", + `Current title: ${input.title}`, + "", + "First user message:", + limitSection(input.firstUserMessage ?? "(none)", THREAD_REVIEW_MESSAGE_CHAR_LIMIT), + "", + `Most recent messages (up to ${THREAD_REVIEW_RECENT_MESSAGE_COUNT}):`, + limitSection(transcript.length > 0 ? transcript : "(no messages)", 24_000), + ].join("\n"); + + const outputSchema = Schema.Struct({ + summary: Schema.String, + suggestedTitle: Schema.NullOr(Schema.String), + recommendSettle: Schema.Boolean, + settleReason: Schema.NullOr(Schema.String), + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index ad2911c20f7..8c7538aad0e 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -63,6 +63,43 @@ export function sanitizeThreadTitle(raw: string): string { return `${normalized.slice(0, 47).trimEnd()}...`; } +/** + * Normalise a raw thread-review generation into the service result shape: + * collapse whitespace, drop empty/no-op title suggestions, and force + * `recommendSettle` off for active threads regardless of model output. + */ +export function normalizeThreadReview( + raw: { + summary: string; + suggestedTitle: string | null; + recommendSettle: boolean; + settleReason: string | null; + }, + isActive: boolean, +): { + summary: string; + suggestedTitle: string | null; + recommendSettle: boolean; + settleReason: string | null; +} { + const summary = raw.summary.trim().replace(/\s+/g, " "); + const suggestedTitle = + raw.suggestedTitle && raw.suggestedTitle.trim().length > 0 + ? sanitizeThreadTitle(raw.suggestedTitle) + : null; + const recommendSettle = raw.recommendSettle && !isActive; + const settleReason = + recommendSettle && raw.settleReason && raw.settleReason.trim().length > 0 + ? raw.settleReason.trim().replace(/\s+/g, " ") + : null; + return { + summary: summary.length > 0 ? summary : "No summary available.", + suggestedTitle: suggestedTitle === "New thread" ? null : suggestedTitle, + recommendSettle, + settleReason, + }; +} + /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..c450c3a0abf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -332,6 +332,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], + [WS_METHODS.reviewSummarizeThread, AuthReviewWriteScope], [WS_METHODS.terminalOpen, AuthTerminalOperateScope], [WS_METHODS.terminalAttach, AuthTerminalOperateScope], [WS_METHODS.terminalWrite, AuthTerminalOperateScope], @@ -1852,6 +1853,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { "rpc.aggregate": "review", }), + [WS_METHODS.reviewSummarizeThread]: (input) => + observeRpcEffect(WS_METHODS.reviewSummarizeThread, review.summarizeThread(input), { + "rpc.aggregate": "review", + }), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 361c3460bfd..f75bd52a8cd 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -19,6 +19,7 @@ import { FolderPlusIcon, GitBranchIcon, EllipsisIcon, + ListChecksIcon, MessageSquareIcon, PlusIcon, SearchIcon, @@ -70,6 +71,7 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +import { startReviewSweep } from "../reviewSweepStore"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -1739,6 +1741,12 @@ export default function SidebarV2() { openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + const handleReviewSweepClick = useCallback(() => { + if (isMobile) setOpenMobile(false); + startReviewSweep(); + void router.navigate({ to: "/review-sweep" }); + }, [isMobile, router, setOpenMobile]); + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to // chat.new, no platform gating — web users have working shortcuts too. @@ -1772,6 +1780,29 @@ export default function SidebarV2() { ) : null} +
+ + + } + > + + + Review unsettled work + +
Review failed; + } + if (item.status !== "done" || item.result === null) { + return null; + } + if (item.result.recommendSettle) { + return item.settleApplied ? ( + Settled + ) : ( + Recommend settle + ); + } + return Keep active; +} + +function SweepItemCard({ item }: { item: SweepItem }) { + const key = scopedThreadKey(item.ref); + const [applyingTitle, setApplyingTitle] = useState(false); + const [applyingSettle, setApplyingSettle] = useState(false); + const result = item.result; + + return ( + +
+ {item.status === "pending" || item.status === "running" ? ( + + ) : null} + + {item.threadTitle} + + + +
+ + {item.status === "error" ? ( +
+ {item.errorMessage} + +
+ ) : null} + + {result !== null ? ( +

{result.summary}

+ ) : item.status !== "error" ? ( +

Reviewing…

+ ) : null} + + {result?.suggestedTitle && !item.titleApplied ? ( +
+ + Title + + {item.threadTitle} + + {result.suggestedTitle} + +
+ ) : null} + {result?.suggestedTitle && item.titleApplied ? ( +
+ Renamed to {result.suggestedTitle}. +
+ ) : null} + + {result?.recommendSettle && !item.settleApplied ? ( +
+ + Settle + + + {result.settleReason ?? "Work appears concluded."} + + +
+ ) : null} +
+ ); +} + +export function ReviewSweepView() { + const phase = useReviewSweepStore((state) => state.phase); + const order = useReviewSweepStore((state) => state.order); + const items = useReviewSweepStore((state) => state.items); + const truncatedCount = useReviewSweepStore((state) => state.truncatedCount); + const projects = useProjects(); + + const projectTitles = useMemo(() => { + const titles = new Map(); + for (const project of projects) { + titles.set(project.id, project.title); + } + return titles; + }, [projects]); + + const visibleItems = useMemo( + () => + order + .map((key) => items[key]) + .filter((item): item is SweepItem => item !== undefined && !item.dismissed), + [items, order], + ); + const groups = useMemo(() => { + const byProject = new Map(); + for (const item of visibleItems) { + const group = byProject.get(item.projectId); + if (group) { + group.push(item); + } else { + byProject.set(item.projectId, [item]); + } + } + return [...byProject.entries()]; + }, [visibleItems]); + + const reviewedCount = visibleItems.filter( + (item) => item.status === "done" || item.status === "error", + ).length; + const applicableCount = visibleItems.filter( + (item) => + (item.result?.suggestedTitle && !item.titleApplied) || + (item.result?.recommendSettle && !item.settleApplied), + ).length; + const running = phase === "running"; + const [applyingAll, setApplyingAll] = useState(false); + + return ( + +
+
+
+ Work review + {phase !== "idle" ? ( + + {running ? ( + <> + {reviewedCount} of {visibleItems.length} reviewed + + ) : ( + <>{visibleItems.length} threads reviewed + )} + + ) : null} +
+ + +
+
+
+ +
+ {visibleItems.length === 0 ? ( + + + + {phase === "idle" ? "Review your in-progress work" : "All caught up"} + + + {phase === "idle" + ? "Run a sweep to summarize every unsettled thread, catch stale titles, and find threads ready to settle." + : "No unsettled threads to review right now."} + + + {phase === "idle" ? ( + + ) : null} + + ) : ( +
+ {truncatedCount > 0 ? ( +

+ Reviewing the {visibleItems.length} most recent threads; {truncatedCount} more + were skipped this run. +

+ ) : null} + {groups.map(([projectId, groupItems]) => ( +
+

+ {projectTitles.get(projectId) ?? "Unknown project"} + + · {groupItems.length} {groupItems.length === 1 ? "thread" : "threads"} + +

+
+ {groupItems.map((item) => ( + + ))} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts new file mode 100644 index 00000000000..b5332f5fdea --- /dev/null +++ b/apps/web/src/reviewSweepStore.ts @@ -0,0 +1,297 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + canSettle, + effectiveSettled, + threadLastActivityAt, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { + EnvironmentId, + ProjectId, + ReviewThreadSummaryResult, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { stackedThreadToast, toastManager } from "./components/ui/toast"; +import { getClientSettings } from "./hooks/useSettings"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { readThreadShell, readThreadRefs } from "./state/entities"; +import { reviewEnvironment } from "./state/review"; +import { environmentServerConfigsAtom } from "./state/server"; +import { threadEnvironment } from "./state/threads"; + +const SWEEP_CONCURRENCY = 3; +/** Soft cap so a giant backlog doesn't spawn an unbounded number of LLM + generations in one click. */ +export const SWEEP_MAX_THREADS = 50; + +export type SweepItemStatus = "pending" | "running" | "done" | "error"; + +export interface SweepItem { + readonly ref: ScopedThreadRef; + readonly projectId: ProjectId; + readonly threadTitle: string; + readonly status: SweepItemStatus; + readonly result: ReviewThreadSummaryResult | null; + readonly errorMessage: string | null; + readonly titleApplied: boolean; + readonly settleApplied: boolean; + readonly dismissed: boolean; +} + +export type SweepPhase = "idle" | "running" | "complete"; + +interface ReviewSweepState { + phase: SweepPhase; + runId: number; + /** Scoped thread keys in review order. */ + order: ReadonlyArray; + items: Readonly>; + /** Count of candidates dropped by SWEEP_MAX_THREADS (0 = full coverage). */ + truncatedCount: number; + startRun: (input: { + runId: number; + items: ReadonlyArray; + truncatedCount: number; + }) => void; + patchItem: (key: string, patch: Partial) => void; + finishRun: (runId: number) => void; + reset: () => void; +} + +export const useReviewSweepStore = create((set) => ({ + phase: "idle", + runId: 0, + order: [], + items: {}, + truncatedCount: 0, + startRun: ({ runId, items, truncatedCount }) => + set({ + phase: "running", + runId, + order: items.map((item) => scopedThreadKey(item.ref)), + items: Object.fromEntries(items.map((item) => [scopedThreadKey(item.ref), item])), + truncatedCount, + }), + patchItem: (key, patch) => + set((state) => { + const existing = state.items[key]; + if (!existing) return state; + return { items: { ...state.items, [key]: { ...existing, ...patch } } }; + }), + finishRun: (runId) => set((state) => (state.runId === runId ? { phase: "complete" } : state)), + reset: () => set({ phase: "idle", order: [], items: {}, truncatedCount: 0 }), +})); + +function readServerCapabilities(environmentId: EnvironmentId) { + return appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment + .capabilities; +} + +/** All unsettled, unarchived threads across every connected environment that + supports the sweep, mirroring SidebarV2's active-card predicate. */ +function collectSweepCandidates(): EnvironmentThreadShell[] { + const now = new Date().toISOString(); + const autoSettleAfterDays = getClientSettings().sidebarAutoSettleAfterDays; + const candidates: EnvironmentThreadShell[] = []; + for (const ref of readThreadRefs()) { + const shell = readThreadShell(ref); + if (!shell || shell.archivedAt !== null) continue; + const capabilities = readServerCapabilities(shell.environmentId); + if (capabilities?.threadSettlement !== true || capabilities.reviewSweep !== true) continue; + // PR state is owned by per-row VCS subscriptions in the sidebar and isn't + // readable here; passing null may include a few threads the sidebar shows + // as settled (merged PR + idle). Reviewing them is harmless. + if (effectiveSettled(shell, { now, autoSettleAfterDays, changeRequestState: null })) continue; + candidates.push(shell); + } + // Most recently active first, so the SWEEP_MAX_THREADS cap drops the + // longest-idle threads rather than the oldest-created ones. + candidates.sort( + (a, b) => + Date.parse(threadLastActivityAt(b) ?? b.createdAt) - + Date.parse(threadLastActivityAt(a) ?? a.createdAt), + ); + return candidates; +} + +async function reviewOne(runId: number, item: SweepItem): Promise { + const store = useReviewSweepStore.getState(); + if (store.runId !== runId) return; + const key = scopedThreadKey(item.ref); + store.patchItem(key, { status: "running" }); + + const shell = readThreadShell(item.ref); + const canSettleNow = shell !== null && canSettle(shell, { now: new Date().toISOString() }); + const result = await runAtomCommand( + appAtomRegistry, + reviewEnvironment.summarizeThread, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId, canSettleNow }, + }, + { reportFailure: false, reportDefect: false }, + ); + + const current = useReviewSweepStore.getState(); + if (current.runId !== runId) return; + if (result._tag === "Success") { + current.patchItem(key, { status: "done", result: result.value, errorMessage: null }); + } else { + const error = squashAtomCommandFailure(result); + current.patchItem(key, { + status: "error", + errorMessage: error instanceof Error ? error.message : "Review failed.", + }); + } +} + +async function runPool(runId: number, items: ReadonlyArray): Promise { + let next = 0; + const workers = Array.from({ length: Math.min(SWEEP_CONCURRENCY, items.length) }, async () => { + while (next < items.length) { + if (useReviewSweepStore.getState().runId !== runId) return; + const item = items[next]; + next += 1; + if (item) await reviewOne(runId, item); + } + }); + await Promise.all(workers); + useReviewSweepStore.getState().finishRun(runId); +} + +/** Kick off a sweep over every unsettled thread. Runs outside React so it + survives navigation; results land in the store as they arrive. */ +export function startReviewSweep(): void { + const runId = useReviewSweepStore.getState().runId + 1; + const candidates = collectSweepCandidates(); + const reviewed = candidates.slice(0, SWEEP_MAX_THREADS); + const items = reviewed.map( + (shell): SweepItem => ({ + ref: scopeThreadRef(shell.environmentId, shell.id), + projectId: shell.projectId, + threadTitle: shell.title, + status: "pending", + result: null, + errorMessage: null, + titleApplied: false, + settleApplied: false, + dismissed: false, + }), + ); + useReviewSweepStore.getState().startRun({ + runId, + items, + truncatedCount: candidates.length - reviewed.length, + }); + if (items.length === 0) { + useReviewSweepStore.getState().finishRun(runId); + return; + } + void runPool(runId, items); +} + +export function retrySweepItem(key: string): void { + const store = useReviewSweepStore.getState(); + const item = store.items[key]; + if (!item || item.status === "running") return; + void reviewOne(store.runId, item); +} + +export async function applySweepTitle(key: string): Promise { + const store = useReviewSweepStore.getState(); + const item = store.items[key]; + const suggestedTitle = item?.result?.suggestedTitle; + if (!item || !suggestedTitle || item.titleApplied) return false; + const result = await runAtomCommand( + appAtomRegistry, + threadEnvironment.updateMetadata, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId, title: suggestedTitle }, + }, + { reportFailure: false }, + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + useReviewSweepStore + .getState() + .patchItem(key, { titleApplied: true, threadTitle: suggestedTitle }); + return true; +} + +export async function applySweepSettle(key: string): Promise { + const store = useReviewSweepStore.getState(); + const item = store.items[key]; + if (!item || !item.result?.recommendSettle || item.settleApplied) return false; + // Re-check against the live shell: the thread may have gone active since + // the review ran, and settling live work would hide it. + const shell = readThreadShell(item.ref); + if (!shell || !canSettle(shell, { now: new Date().toISOString() })) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Thread is active again", + description: "This thread picked up new activity since the review, so it stays active.", + }), + ); + return false; + } + const result = await runAtomCommand( + appAtomRegistry, + threadEnvironment.settle, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId }, + }, + { reportFailure: false }, + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + useReviewSweepStore.getState().patchItem(key, { settleApplied: true }); + return true; +} + +/** Sequentially apply every un-dismissed recommendation: titles first, then + settles, tolerating per-item failures (each failure already toasts). */ +export async function applyAllSweepRecommendations(): Promise { + const { order, items } = useReviewSweepStore.getState(); + for (const key of order) { + const item = items[key]; + if (!item || item.dismissed) continue; + if (item.result?.suggestedTitle && !item.titleApplied) { + await applySweepTitle(key); + } + } + for (const key of order) { + const item = useReviewSweepStore.getState().items[key]; + if (!item || item.dismissed) continue; + if (item.result?.recommendSettle && !item.settleApplied) { + await applySweepSettle(key); + } + } +} + +export function dismissSweepItem(key: string): void { + useReviewSweepStore.getState().patchItem(key, { dismissed: true }); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 563d1b43755..58ed61cd07c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as ReviewSweepRouteImport } from './routes/review-sweep' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' @@ -31,6 +32,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const ReviewSweepRoute = ReviewSweepRouteImport.update({ + id: '/review-sweep', + path: '/review-sweep', + getParentRoute: () => rootRouteImport, +} as any) const PairRoute = PairRouteImport.update({ id: '/pair', path: '/pair', @@ -111,6 +117,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/review-sweep': typeof ReviewSweepRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute @@ -127,6 +134,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/review-sweep': typeof ReviewSweepRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute @@ -146,6 +154,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/review-sweep': typeof ReviewSweepRoute '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute @@ -166,6 +175,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/pair' + | '/review-sweep' | '/settings' | '/connect/callback' | '/settings/archived' @@ -182,6 +192,7 @@ export interface FileRouteTypes { to: | '/connect' | '/pair' + | '/review-sweep' | '/settings' | '/connect/callback' | '/settings/archived' @@ -200,6 +211,7 @@ export interface FileRouteTypes { | '/_chat' | '/connect' | '/pair' + | '/review-sweep' | '/settings' | '/connect_/callback' | '/settings/archived' @@ -219,6 +231,7 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute + ReviewSweepRoute: typeof ReviewSweepRoute SettingsRoute: typeof SettingsRouteWithChildren ConnectCallbackRoute: typeof ConnectCallbackRoute } @@ -232,6 +245,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/review-sweep': { + id: '/review-sweep' + path: '/review-sweep' + fullPath: '/review-sweep' + preLoaderRoute: typeof ReviewSweepRouteImport + parentRoute: typeof rootRouteImport + } '/pair': { id: '/pair' path: '/pair' @@ -384,6 +404,7 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, PairRoute: PairRoute, + ReviewSweepRoute: ReviewSweepRoute, SettingsRoute: SettingsRouteWithChildren, ConnectCallbackRoute: ConnectCallbackRoute, } diff --git a/apps/web/src/routes/review-sweep.tsx b/apps/web/src/routes/review-sweep.tsx new file mode 100644 index 00000000000..219bc74e98b --- /dev/null +++ b/apps/web/src/routes/review-sweep.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { ReviewSweepView } from "../components/reviewSweep/ReviewSweepView"; + +export const Route = createFileRoute("/review-sweep")({ + beforeLoad: async ({ context }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: ReviewSweepView, +}); diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts index 0d78d6edd9f..11abb6a405e 100644 --- a/packages/client-runtime/src/state/review.ts +++ b/packages/client-runtime/src/state/review.ts @@ -1,7 +1,7 @@ import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; export function createReviewEnvironmentAtoms( @@ -13,5 +13,9 @@ export function createReviewEnvironmentAtoms( tag: WS_METHODS.reviewGetDiffPreview, staleTimeMs: 5_000, }), + summarizeThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:review:summarize-thread", + tag: WS_METHODS.reviewSummarizeThread, + }), }; } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 936b97f6c2d..d970151f992 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -44,6 +44,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** Server understands the review.summarizeThread RPC used by the sidebar + work-review sweep. Absent on older servers, so clients skip their + threads instead of sending an unknown method. */ + reviewSweep: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index a6b879a0c7f..d350bd9d9e5 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { GitCommandError } from "./git.ts"; +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { GitCommandError, TextGenerationError } from "./git.ts"; import { VcsError } from "./vcs.ts"; export const ReviewDiffPreviewInput = Schema.Struct({ @@ -34,3 +34,33 @@ export type ReviewDiffPreviewResult = typeof ReviewDiffPreviewResult.Type; export const ReviewDiffPreviewError = Schema.Union([VcsError, GitCommandError]); export type ReviewDiffPreviewError = typeof ReviewDiffPreviewError.Type; + +export const ReviewThreadSummaryInput = Schema.Struct({ + threadId: ThreadId, + /** Whether the thread is currently eligible to settle (no running session, + pending approvals, or pending user input). The server never recommends + settling when this is false, regardless of what the model says. */ + canSettleNow: Schema.Boolean, +}); +export type ReviewThreadSummaryInput = typeof ReviewThreadSummaryInput.Type; + +export const ReviewThreadSummaryResult = Schema.Struct({ + threadId: ThreadId, + summary: TrimmedNonEmptyString, + /** Null when the current title is still accurate. */ + suggestedTitle: Schema.NullOr(TrimmedNonEmptyString), + recommendSettle: Schema.Boolean, + settleReason: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ReviewThreadSummaryResult = typeof ReviewThreadSummaryResult.Type; + +export class ReviewThreadNotFoundError extends Schema.TaggedErrorClass()( + "ReviewThreadNotFoundError", + { threadId: ThreadId }, +) {} + +export const ReviewThreadSummaryError = Schema.Union([ + TextGenerationError, + ReviewThreadNotFoundError, +]); +export type ReviewThreadSummaryError = typeof ReviewThreadSummaryError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..a5e125152b7 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -43,6 +43,9 @@ import { ReviewDiffPreviewError, ReviewDiffPreviewInput, ReviewDiffPreviewResult, + ReviewThreadSummaryError, + ReviewThreadSummaryInput, + ReviewThreadSummaryResult, } from "./review.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; import { @@ -181,6 +184,7 @@ export const WS_METHODS = { // Review methods reviewGetDiffPreview: "review.getDiffPreview", + reviewSummarizeThread: "review.summarizeThread", // Terminal methods terminalOpen: "terminal.open", @@ -495,6 +499,14 @@ export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPrevie error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); +/** One-shot agentic review of a single thread for the sidebar work-review + sweep: summary, optional corrected title, and a settle recommendation. */ +export const WsReviewSummarizeThreadRpc = Rpc.make(WS_METHODS.reviewSummarizeThread, { + payload: ReviewThreadSummaryInput, + success: ReviewThreadSummaryResult, + error: Schema.Union([ReviewThreadSummaryError, EnvironmentAuthorizationError]), +}); + export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, @@ -738,6 +750,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsSwitchRefRpc, WsVcsInitRpc, WsReviewGetDiffPreviewRpc, + WsReviewSummarizeThreadRpc, WsTerminalOpenRpc, WsTerminalAttachRpc, WsTerminalWriteRpc, From 8aad08dc689371556d457a673529c09b6c18842d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 19:33:45 -0700 Subject: [PATCH 02/12] Address bot review feedback on review sweep - macroscope: guard apply-title/apply-settle completions with the runId captured before the RPC so a Re-run mid-flight never stamps applied state onto the new run's cards; applyAll re-reads items from the store each step so mid-apply dismissals are respected, and bails out entirely when the run changes. - cursor: the server's activity re-check now also treats a queued turn start (user message no session has adopted yet) as active, mirroring the decider's thread.settle guard; sweep candidate ordering uses NaN-safe toSortableTimestamp instead of raw Date.parse. Co-Authored-By: Claude Fable 5 --- apps/server/src/review/ReviewService.test.ts | 42 +++++++++++++++++++ apps/server/src/review/ReviewService.ts | 39 +++++++++++++++++- apps/web/src/reviewSweepStore.ts | 43 ++++++++++++++------ 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index a43202b0f77..e97764a7a54 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -369,4 +369,46 @@ describe("ReviewService", () => { assert.strictEqual(result.settleReason, null); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("summarizeThread treats a queued turn start as active", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({}); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + // A just-sent user message with no adopting turn: session still + // null, but the thread has in-flight work. it.effect runs on the + // TestClock (epoch 0), so "just sent" is the epoch. + shellOverrides: { + latestUserMessageAt: "1970-01-01T00:00:00.000Z", + latestTurn: null, + session: null, + }, + generateThreadReview: () => + Effect.succeed({ + summary: "Looks finished.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Done.", + }), + }), + ), + ); + + assert.strictEqual(reviewCalls[0]?.isActive, true); + assert.strictEqual(result.recommendSettle, false); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index bea5233af44..43efedf6347 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -39,6 +39,41 @@ export class ReviewService extends Context.Service< } >()("t3/review/ReviewService") {} +// Mirrors QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts and +// the decider's thread.settle guard. +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** A user message no turn has adopted yet is in-flight work even though the + session is still null. Mirrors the decider's settle guard and the + client's hasQueuedTurnStart: newest user message strictly newer than + every latestTurn timestamp, within the adoption grace window, and not + already surfaced as a failed session start. */ +function hasQueuedTurnStart( + shell: { + readonly latestUserMessageAt: string | null; + readonly latestTurn: { + readonly requestedAt: string | null; + readonly startedAt: string | null; + readonly completedAt: string | null; + } | null; + readonly session: { readonly status: string } | null; + }, + nowMs: number, +): boolean { + if (shell.latestUserMessageAt === null) return false; + if (shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + // Bounded on both sides: message timestamps originate on client devices, + // so a clock ahead of the server must not hold the queued state open. + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = shell.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate === null || Date.parse(candidate) < messageAt, + ); +} + export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -162,12 +197,14 @@ export const make = Effect.gen(function* () { .getThreadShellById(input.threadId) .pipe(Effect.mapError(mapProjectionError)); const shell = Option.isSome(shellOption) ? shellOption.value : null; + const now = yield* DateTime.now; const serverSideActive = shell === null || shell.hasPendingApprovals || shell.hasPendingUserInput || shell.session?.status === "starting" || - shell.session?.status === "running"; + shell.session?.status === "running" || + hasQueuedTurnStart(shell, DateTime.toEpochMillis(now)); const canSettleNow = input.canSettleNow && !serverSideActive; const isActive = !canSettleNow; diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts index b5332f5fdea..b98e81741ad 100644 --- a/apps/web/src/reviewSweepStore.ts +++ b/apps/web/src/reviewSweepStore.ts @@ -5,6 +5,7 @@ import { effectiveSettled, threadLastActivityAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import type { EnvironmentId, @@ -109,11 +110,11 @@ function collectSweepCandidates(): EnvironmentThreadShell[] { } // Most recently active first, so the SWEEP_MAX_THREADS cap drops the // longest-idle threads rather than the oldest-created ones. - candidates.sort( - (a, b) => - Date.parse(threadLastActivityAt(b) ?? b.createdAt) - - Date.parse(threadLastActivityAt(a) ?? a.createdAt), - ); + const activityMs = (shell: EnvironmentThreadShell): number => + toSortableTimestamp(threadLastActivityAt(shell) ?? undefined) ?? + toSortableTimestamp(shell.createdAt) ?? + 0; + candidates.sort((a, b) => activityMs(b) - activityMs(a)); return candidates; } @@ -202,6 +203,7 @@ export function retrySweepItem(key: string): void { export async function applySweepTitle(key: string): Promise { const store = useReviewSweepStore.getState(); + const runId = store.runId; const item = store.items[key]; const suggestedTitle = item?.result?.suggestedTitle; if (!item || !suggestedTitle || item.titleApplied) return false; @@ -225,14 +227,19 @@ export async function applySweepTitle(key: string): Promise { ); return false; } - useReviewSweepStore - .getState() - .patchItem(key, { titleApplied: true, threadTitle: suggestedTitle }); + // The rename itself succeeded either way, but a Re-run mid-flight replaces + // the item set with fresh reviews — don't stamp stale applied-state onto + // the new run's card. + const current = useReviewSweepStore.getState(); + if (current.runId === runId) { + current.patchItem(key, { titleApplied: true, threadTitle: suggestedTitle }); + } return true; } export async function applySweepSettle(key: string): Promise { const store = useReviewSweepStore.getState(); + const runId = store.runId; const item = store.items[key]; if (!item || !item.result?.recommendSettle || item.settleApplied) return false; // Re-check against the live shell: the thread may have gone active since @@ -268,23 +275,33 @@ export async function applySweepSettle(key: string): Promise { ); return false; } - useReviewSweepStore.getState().patchItem(key, { settleApplied: true }); + // Same staleness guard as applySweepTitle: never patch a newer run's item. + const current = useReviewSweepStore.getState(); + if (current.runId === runId) { + current.patchItem(key, { settleApplied: true }); + } return true; } /** Sequentially apply every un-dismissed recommendation: titles first, then - settles, tolerating per-item failures (each failure already toasts). */ + settles, tolerating per-item failures (each failure already toasts). + Items are re-read from the store on every step so dismissals (and + re-runs) mid-apply are respected. */ export async function applyAllSweepRecommendations(): Promise { - const { order, items } = useReviewSweepStore.getState(); + const { order, runId } = useReviewSweepStore.getState(); for (const key of order) { - const item = items[key]; + const current = useReviewSweepStore.getState(); + if (current.runId !== runId) return; + const item = current.items[key]; if (!item || item.dismissed) continue; if (item.result?.suggestedTitle && !item.titleApplied) { await applySweepTitle(key); } } for (const key of order) { - const item = useReviewSweepStore.getState().items[key]; + const current = useReviewSweepStore.getState(); + if (current.runId !== runId) return; + const item = current.items[key]; if (!item || item.dismissed) continue; if (item.result?.recommendSettle && !item.settleApplied) { await applySweepSettle(key); From dc66ff1d5ce54c01433cd7e8599fac74c844c65a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 19:38:32 -0700 Subject: [PATCH 03/12] Add pre-run confirmation screen to review sweep The sidebar button now only navigates to /review-sweep; the sweep no longer auto-starts. The idle screen shows exactly what a run will do before any model call happens: how many threads will be reviewed (and how many the cap would skip), which text-generation model each environment's server will use, and a cost/latency heads-up when the run is large (15+ threads). Starting requires an explicit click. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/SidebarV2.tsx | 4 +- .../reviewSweep/ReviewSweepView.tsx | 159 +++++++++++++----- apps/web/src/reviewSweepStore.ts | 30 +++- 3 files changed, 146 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f75bd52a8cd..677b4cab660 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -71,7 +71,6 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { startReviewSweep } from "../reviewSweepStore"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -1741,9 +1740,10 @@ export default function SidebarV2() { openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + // Navigate only — the review-sweep page shows a pre-run summary (thread + // count + models that will be used) and the sweep starts on explicit click. const handleReviewSweepClick = useCallback(() => { if (isMobile) setOpenMobile(false); - startReviewSweep(); void router.navigate({ to: "/review-sweep" }); }, [isMobile, router, setOpenMobile]); diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx index 1e44beeefe6..0986e3b882e 100644 --- a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -11,12 +11,15 @@ import { applySweepSettle, applySweepTitle, dismissSweepItem, + isSweepCandidate, retrySweepItem, startReviewSweep, + SWEEP_MAX_THREADS, useReviewSweepStore, type SweepItem, } from "../../reviewSweepStore"; -import { useProjects } from "../../state/entities"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useProjects, useServerConfigs, useThreadShells } from "../../state/entities"; import { buildThreadRouteParams } from "../../threadRoutes"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -142,6 +145,94 @@ function SweepItemCard({ item }: { item: SweepItem }) { ); } +/** Cost-transparency threshold: at or above this many candidate threads the + pre-run screen calls out that the sweep may be slow/expensive. */ +const SWEEP_COST_NOTE_THRESHOLD = 15; + +/** Pre-run summary: how many threads a sweep would cover and which model + each environment's server will use, so starting is an informed choice. */ +function SweepPreRunSummary() { + const shells = useThreadShells(); + const serverConfigs = useServerConfigs(); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + + const { candidateCount, modelsByEnvironment } = useMemo(() => { + const now = new Date().toISOString(); + let count = 0; + const models = new Map(); + for (const shell of shells) { + const config = serverConfigs.get(shell.environmentId); + if (!isSweepCandidate(shell, config?.environment.capabilities, { now, autoSettleAfterDays })) + continue; + count += 1; + const selection = config?.settings.textGenerationModelSelection; + const entry = models.get(shell.environmentId); + if (entry) { + entry.threads += 1; + } else { + models.set(shell.environmentId, { + label: config?.environment.label ?? shell.environmentId, + model: selection ? `${selection.model}` : "server default", + threads: 1, + }); + } + } + return { candidateCount: count, modelsByEnvironment: [...models.values()] }; + }, [autoSettleAfterDays, serverConfigs, shells]); + + const reviewedCount = Math.min(candidateCount, SWEEP_MAX_THREADS); + + if (candidateCount === 0) { + return ( + + + All caught up + No unsettled threads to review right now. + + + ); + } + + return ( + + + Review your in-progress work + + This sweep runs one AI review per thread to summarize it, catch stale titles, and find + threads ready to settle. Nothing is applied without your click. + + +
+
+ {reviewedCount}{" "} + {reviewedCount === 1 ? "thread" : "threads"} will be reviewed + {candidateCount > SWEEP_MAX_THREADS + ? ` (${candidateCount - SWEEP_MAX_THREADS} least-recently-active skipped)` + : ""} +
+ {modelsByEnvironment.map((entry) => ( +
+ {entry.label}: {entry.model} · {entry.threads}{" "} + {entry.threads === 1 ? "thread" : "threads"} +
+ ))} + {reviewedCount >= SWEEP_COST_NOTE_THRESHOLD ? ( +
+ Heads up: {reviewedCount} model calls may take a few minutes and use noticeable credits. + You can change the model under Settings → Models. +
+ ) : ( +
Model is configurable under Settings → Models (text generation).
+ )} +
+ +
+ ); +} + export function ReviewSweepView() { const phase = useReviewSweepStore((state) => state.phase); const order = useReviewSweepStore((state) => state.order); @@ -210,50 +301,42 @@ export function ReviewSweepView() { )} ) : null} -
- - -
+ {phase !== "idle" ? ( +
+ + +
+ ) : null}
- {visibleItems.length === 0 ? ( + {phase === "idle" ? ( + + ) : visibleItems.length === 0 ? ( - - {phase === "idle" ? "Review your in-progress work" : "All caught up"} - - - {phase === "idle" - ? "Run a sweep to summarize every unsettled thread, catch stale titles, and find threads ready to settle." - : "No unsettled threads to review right now."} - + All caught up + No unsettled threads to review right now. - {phase === "idle" ? ( - - ) : null} ) : (
diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts index b98e81741ad..d543ab10892 100644 --- a/apps/web/src/reviewSweepStore.ts +++ b/apps/web/src/reviewSweepStore.ts @@ -91,21 +91,37 @@ function readServerCapabilities(environmentId: EnvironmentId) { .capabilities; } +/** The sweep-eligibility predicate, mirroring SidebarV2's active-card + partition. Exported so the pre-run confirmation screen counts exactly + the threads a run would review. */ +export function isSweepCandidate( + shell: EnvironmentThreadShell, + capabilities: { threadSettlement?: boolean; reviewSweep?: boolean } | undefined, + options: { readonly now: string; readonly autoSettleAfterDays: number | null }, +): boolean { + if (shell.archivedAt !== null) return false; + if (capabilities?.threadSettlement !== true || capabilities.reviewSweep !== true) return false; + // PR state is owned by per-row VCS subscriptions in the sidebar and isn't + // readable here; passing null may include a few threads the sidebar shows + // as settled (merged PR + idle). Reviewing them is harmless. + return !effectiveSettled(shell, { + now: options.now, + autoSettleAfterDays: options.autoSettleAfterDays, + changeRequestState: null, + }); +} + /** All unsettled, unarchived threads across every connected environment that - supports the sweep, mirroring SidebarV2's active-card predicate. */ + supports the sweep. */ function collectSweepCandidates(): EnvironmentThreadShell[] { const now = new Date().toISOString(); const autoSettleAfterDays = getClientSettings().sidebarAutoSettleAfterDays; const candidates: EnvironmentThreadShell[] = []; for (const ref of readThreadRefs()) { const shell = readThreadShell(ref); - if (!shell || shell.archivedAt !== null) continue; + if (!shell) continue; const capabilities = readServerCapabilities(shell.environmentId); - if (capabilities?.threadSettlement !== true || capabilities.reviewSweep !== true) continue; - // PR state is owned by per-row VCS subscriptions in the sidebar and isn't - // readable here; passing null may include a few threads the sidebar shows - // as settled (merged PR + idle). Reviewing them is harmless. - if (effectiveSettled(shell, { now, autoSettleAfterDays, changeRequestState: null })) continue; + if (!isSweepCandidate(shell, capabilities, { now, autoSettleAfterDays })) continue; candidates.push(shell); } // Most recently active first, so the SWEEP_MAX_THREADS cap drops the From f8b9d79934bbf50b76173ee63dbfd097bc2779c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 19:42:11 -0700 Subject: [PATCH 04/12] Make pre-run summary reflect the capped selection exactly The environment/model breakdown previously aggregated every sweep candidate, so with more than SWEEP_MAX_THREADS unsettled threads it could list environments whose threads would all be skipped and row totals exceeding the actual call count. The summary now sorts and caps candidates with the same helpers startReviewSweep uses, so the rows always describe exactly the calls a run would make. Co-Authored-By: Claude Fable 5 --- .../reviewSweep/ReviewSweepView.tsx | 31 +++++++++++++------ apps/web/src/reviewSweepStore.ts | 19 ++++++++---- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx index 0986e3b882e..4c74b04a193 100644 --- a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -13,6 +13,7 @@ import { dismissSweepItem, isSweepCandidate, retrySweepItem, + sortSweepCandidates, startReviewSweep, SWEEP_MAX_THREADS, useReviewSweepStore, @@ -156,20 +157,28 @@ function SweepPreRunSummary() { const serverConfigs = useServerConfigs(); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const { candidateCount, modelsByEnvironment } = useMemo(() => { + const { candidateCount, reviewedCount, modelsByEnvironment } = useMemo(() => { const now = new Date().toISOString(); - let count = 0; + const candidates = sortSweepCandidates( + shells.filter((shell) => + isSweepCandidate(shell, serverConfigs.get(shell.environmentId)?.environment.capabilities, { + now, + autoSettleAfterDays, + }), + ), + ); + // Aggregate only what a run would actually review: the cap is applied + // here, in the same most-recently-active order startReviewSweep uses, + // so the environment/model rows never overstate the calls. + const reviewed = candidates.slice(0, SWEEP_MAX_THREADS); const models = new Map(); - for (const shell of shells) { + for (const shell of reviewed) { const config = serverConfigs.get(shell.environmentId); - if (!isSweepCandidate(shell, config?.environment.capabilities, { now, autoSettleAfterDays })) - continue; - count += 1; - const selection = config?.settings.textGenerationModelSelection; const entry = models.get(shell.environmentId); if (entry) { entry.threads += 1; } else { + const selection = config?.settings.textGenerationModelSelection; models.set(shell.environmentId, { label: config?.environment.label ?? shell.environmentId, model: selection ? `${selection.model}` : "server default", @@ -177,11 +186,13 @@ function SweepPreRunSummary() { }); } } - return { candidateCount: count, modelsByEnvironment: [...models.values()] }; + return { + candidateCount: candidates.length, + reviewedCount: reviewed.length, + modelsByEnvironment: [...models.values()], + }; }, [autoSettleAfterDays, serverConfigs, shells]); - const reviewedCount = Math.min(candidateCount, SWEEP_MAX_THREADS); - if (candidateCount === 0) { return ( diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts index d543ab10892..548b9f7632b 100644 --- a/apps/web/src/reviewSweepStore.ts +++ b/apps/web/src/reviewSweepStore.ts @@ -112,8 +112,9 @@ export function isSweepCandidate( } /** All unsettled, unarchived threads across every connected environment that - supports the sweep. */ -function collectSweepCandidates(): EnvironmentThreadShell[] { + supports the sweep, most recently active first — the exact order the + SWEEP_MAX_THREADS cap is applied in. */ +export function collectSweepCandidates(): EnvironmentThreadShell[] { const now = new Date().toISOString(); const autoSettleAfterDays = getClientSettings().sidebarAutoSettleAfterDays; const candidates: EnvironmentThreadShell[] = []; @@ -124,14 +125,20 @@ function collectSweepCandidates(): EnvironmentThreadShell[] { if (!isSweepCandidate(shell, capabilities, { now, autoSettleAfterDays })) continue; candidates.push(shell); } - // Most recently active first, so the SWEEP_MAX_THREADS cap drops the - // longest-idle threads rather than the oldest-created ones. + return sortSweepCandidates(candidates); +} + +/** Most recently active first, so the SWEEP_MAX_THREADS cap drops the + longest-idle threads rather than the oldest-created ones. Exported so + the pre-run summary previews the exact capped selection. */ +export function sortSweepCandidates( + candidates: ReadonlyArray, +): EnvironmentThreadShell[] { const activityMs = (shell: EnvironmentThreadShell): number => toSortableTimestamp(threadLastActivityAt(shell) ?? undefined) ?? toSortableTimestamp(shell.createdAt) ?? 0; - candidates.sort((a, b) => activityMs(b) - activityMs(a)); - return candidates; + return [...candidates].sort((a, b) => activityMs(b) - activityMs(a)); } async function reviewOne(runId: number, item: SweepItem): Promise { From f8e63b222d9f97bc6d264371c1ccb4f2d5ce6028 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 19:42:58 -0700 Subject: [PATCH 05/12] Key pre-run model rows by environment id, not label Environment labels can collide (e.g. identical hostnames); keying the rows by label would make React mis-reconcile them. Key by the unique environment id instead. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/reviewSweep/ReviewSweepView.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx index 4c74b04a193..7fd948e83a1 100644 --- a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -171,7 +171,10 @@ function SweepPreRunSummary() { // here, in the same most-recently-active order startReviewSweep uses, // so the environment/model rows never overstate the calls. const reviewed = candidates.slice(0, SWEEP_MAX_THREADS); - const models = new Map(); + const models = new Map< + string, + { environmentId: string; label: string; model: string; threads: number } + >(); for (const shell of reviewed) { const config = serverConfigs.get(shell.environmentId); const entry = models.get(shell.environmentId); @@ -180,6 +183,7 @@ function SweepPreRunSummary() { } else { const selection = config?.settings.textGenerationModelSelection; models.set(shell.environmentId, { + environmentId: shell.environmentId, label: config?.environment.label ?? shell.environmentId, model: selection ? `${selection.model}` : "server default", threads: 1, @@ -222,7 +226,7 @@ function SweepPreRunSummary() { : ""}
{modelsByEnvironment.map((entry) => ( -
+
{entry.label}: {entry.model} · {entry.threads}{" "} {entry.threads === 1 ? "thread" : "threads"}
From 3afe22dd313483192cc50125034d3944bc0360fb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 22:12:37 -0700 Subject: [PATCH 06/12] Fail thread review loudly when workspace cwd is unresolvable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider CLI spawn reads local context files (AGENTS.md, CLAUDE.md) from its cwd, so silently falling back to process.cwd() when a thread has no worktree and its project shell is missing would mix the server's own directory contents into an external LLM prompt. Return a typed TextGenerationError instead — the sweep UI already renders it as a retry-able error card. Co-Authored-By: Claude Fable 5 --- apps/server/src/review/ReviewService.test.ts | 26 +++++++++++++++++++- apps/server/src/review/ReviewService.ts | 20 +++++++++++---- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index e97764a7a54..e1a9ebdb726 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -122,7 +122,9 @@ function makeThread(overrides: Partial = {}): Orchestration runtimeMode: "local", interactionMode: "chat", branch: null, - worktreePath: null, + // Tests mock getProjectShellById to None, so cwd resolution relies on + // the worktree path; a thread with neither now fails loudly by design. + worktreePath: "/tmp/t3-review-test-worktree", latestTurn: null, createdAt: "2026-07-20T00:00:00.000Z", updatedAt: "2026-07-20T00:00:00.000Z", @@ -370,6 +372,28 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("summarizeThread fails loudly when no workspace cwd can be resolved", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + // No worktree and (per the mock) no project shell: cwd is unresolvable. + const thread = makeThread({ worktreePath: null }); + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review + .summarizeThread({ threadId: thread.id, canSettleNow: true }) + .pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, thread }))); + + assert.strictEqual(error._tag, "TextGenerationError"); + if (error._tag === "TextGenerationError") { + assert.match(error.detail, /Unable to resolve a workspace directory/); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("summarizeThread treats a queued turn start as active", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 43efedf6347..a79e6729ba6 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -178,11 +178,21 @@ export const make = Effect.gen(function* () { const projectOption = yield* projectionSnapshotQuery .getProjectShellById(thread.projectId) .pipe(Effect.mapError(mapProjectionError)); - const cwd = - resolveThreadWorkspaceCwd({ - thread, - projects: Option.isSome(projectOption) ? [projectOption.value] : [], - }) ?? process.cwd(); + // No silent process.cwd() fallback: the cwd is handed to the provider + // CLI spawn, which reads local context files (AGENTS.md, CLAUDE.md) from + // it — reviewing "the server's own directory" would leak unrelated + // content into an external LLM prompt. Fail the item instead; the client + // shows it as a retry-able error card. + const cwd = resolveThreadWorkspaceCwd({ + thread, + projects: Option.isSome(projectOption) ? [projectOption.value] : [], + }); + if (cwd === undefined) { + return yield* new TextGenerationError({ + operation: "generateThreadReview", + detail: `Unable to resolve a workspace directory for thread '${input.threadId}'.`, + }); + } const settled = thread.messages.filter((message) => !message.streaming); const firstUserMessage = settled.find((message) => message.role === "user")?.text ?? null; From c68e9fa23cfc9eed4c0377ddd64de19f1c387924 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 22:25:20 -0700 Subject: [PATCH 07/12] Address cursor round-3 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Group sweep cards and resolve project titles by scoped (environmentId, projectId) key — bare project ids can collide across connected environments. - Withdraw a settle recommendation from the card when the apply-time canSettle guard refuses it, instead of leaving a CTA the guard will keep rejecting. - Document why summarizeThread's two projection reads are ordered transcript-then-shell: reading the activity view last catches sessions that start mid-read, and the reverse skew only affects summary staleness — settles are re-validated at apply time and by the decider, so no consistency window can settle live work. Co-Authored-By: Claude Fable 5 --- apps/server/src/review/ReviewService.ts | 6 ++++ .../reviewSweep/ReviewSweepView.tsx | 29 ++++++++++++------- apps/web/src/reviewSweepStore.ts | 5 ++++ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index a79e6729ba6..c1a66d5e9e1 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -203,6 +203,12 @@ export const make = Effect.gen(function* () { // Never trust the client's canSettleNow alone: re-derive activity from the // server's own projection so a session that started (or approval/input // that appeared) after the client read its shell still blocks settling. + // The shell is deliberately read AFTER the transcript: the two reads are + // separate transactions, and reading the activity view last means a + // session starting in between is still caught. The reverse skew (activity + // ending in between) only staleness-affects the summary text — an actual + // settle is re-validated by the client at apply time and by the decider's + // thread.settle guards, so a recommendation can never settle live work. const shellOption = yield* projectionSnapshotQuery .getThreadShellById(input.threadId) .pipe(Effect.mapError(mapProjectionError)); diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx index 7fd948e83a1..95f9b92b0f1 100644 --- a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -1,5 +1,8 @@ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ProjectId } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, +} from "@t3tools/client-runtime/environment"; import { Link } from "@tanstack/react-router"; import { ArrowRightIcon, ListChecksIcon, RotateCcwIcon, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -255,10 +258,15 @@ export function ReviewSweepView() { const truncatedCount = useReviewSweepStore((state) => state.truncatedCount); const projects = useProjects(); + // Projects are only unique per (environmentId, projectId) — ids can + // collide across connected environments. const projectTitles = useMemo(() => { - const titles = new Map(); + const titles = new Map(); for (const project of projects) { - titles.set(project.id, project.title); + titles.set( + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + project.title, + ); } return titles; }, [projects]); @@ -271,13 +279,14 @@ export function ReviewSweepView() { [items, order], ); const groups = useMemo(() => { - const byProject = new Map(); + const byProject = new Map(); for (const item of visibleItems) { - const group = byProject.get(item.projectId); + const key = scopedProjectKey(scopeProjectRef(item.ref.environmentId, item.projectId)); + const group = byProject.get(key); if (group) { group.push(item); } else { - byProject.set(item.projectId, [item]); + byProject.set(key, [item]); } } return [...byProject.entries()]; @@ -361,10 +370,10 @@ export function ReviewSweepView() { were skipped this run.

) : null} - {groups.map(([projectId, groupItems]) => ( -
+ {groups.map(([projectKey, groupItems]) => ( +

- {projectTitles.get(projectId) ?? "Unknown project"} + {projectTitles.get(projectKey) ?? "Unknown project"} · {groupItems.length} {groupItems.length === 1 ? "thread" : "threads"} diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts index 548b9f7632b..e3e095d3d4a 100644 --- a/apps/web/src/reviewSweepStore.ts +++ b/apps/web/src/reviewSweepStore.ts @@ -276,6 +276,11 @@ export async function applySweepSettle(key: string): Promise { description: "This thread picked up new activity since the review, so it stays active.", }), ); + // The recommendation is now known-stale — withdraw it so the card stops + // advertising a settle the live guard just refused. + store.patchItem(key, { + result: { ...item.result, recommendSettle: false, settleReason: null }, + }); return false; } const result = await runAtomCommand( From 4faf0e941814eccf497f32673c47cd54941e6fd9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 04:48:22 -0700 Subject: [PATCH 08/12] Make sweep respect merged-PR auto-settle like the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-data testing surfaced the gap the plan had flagged: the sweep passed changeRequestState: null, so threads the sidebar auto-settles (merged/closed PR + idle) counted as unsettled — 18 candidates vs ~7 sidebar cards. SidebarV2 now mirrors its per-row PR-state map into the sweep store, isSweepCandidate consults it, and the pre-run summary subscribes to a version counter so its count tracks PR states as they stream in. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/SidebarV2.tsx | 6 ++++ .../reviewSweep/ReviewSweepView.tsx | 5 ++- apps/web/src/reviewSweepStore.ts | 35 ++++++++++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 82b412539e0..22725af1fea 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -81,6 +81,7 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +import { publishSweepChangeRequestStates } from "../reviewSweepStore"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -1163,6 +1164,11 @@ export default function SidebarV2() { }, [], ); + // Mirror PR states to the review-sweep store so its unsettled-candidate + // predicate matches this sidebar's partition (merged-PR auto-settle). + useEffect(() => { + publishSweepChangeRequestStates(changeRequestStateByKey); + }, [changeRequestStateByKey]); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx index 95f9b92b0f1..ace5aa1f62c 100644 --- a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -159,6 +159,9 @@ function SweepPreRunSummary() { const shells = useThreadShells(); const serverConfigs = useServerConfigs(); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + // Recompute when the sidebar publishes fresh PR states — merged-PR + // auto-settle changes which threads count as unsettled. + const candidateVersion = useReviewSweepStore((state) => state.candidateVersion); const { candidateCount, reviewedCount, modelsByEnvironment } = useMemo(() => { const now = new Date().toISOString(); @@ -198,7 +201,7 @@ function SweepPreRunSummary() { reviewedCount: reviewed.length, modelsByEnvironment: [...models.values()], }; - }, [autoSettleAfterDays, serverConfigs, shells]); + }, [autoSettleAfterDays, candidateVersion, serverConfigs, shells]); if (candidateCount === 0) { return ( diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts index e3e095d3d4a..9a9b17e6bb6 100644 --- a/apps/web/src/reviewSweepStore.ts +++ b/apps/web/src/reviewSweepStore.ts @@ -23,6 +23,22 @@ import { reviewEnvironment } from "./state/review"; import { environmentServerConfigsAtom } from "./state/server"; import { threadEnvironment } from "./state/threads"; +/** PR states mirrored from SidebarV2's per-row VCS subscriptions (keyed by + scoped thread key). The sidebar auto-settles merged/closed+idle threads + using this data; the sweep must see the same states or it will count + those threads as unsettled. Module-level because the sidebar is mounted + for the app's lifetime and owns the subscriptions. */ +let changeRequestStateByKey: ReadonlyMap = new Map(); + +export function publishSweepChangeRequestStates( + states: ReadonlyMap, +): void { + changeRequestStateByKey = states; + // Bump the store version so the pre-run summary recomputes its candidate + // count as PR states stream in from the sidebar rows. + useReviewSweepStore.getState().bumpCandidateVersion(); +} + const SWEEP_CONCURRENCY = 3; /** Soft cap so a giant backlog doesn't spawn an unbounded number of LLM generations in one click. */ @@ -52,6 +68,9 @@ interface ReviewSweepState { items: Readonly>; /** Count of candidates dropped by SWEEP_MAX_THREADS (0 = full coverage). */ truncatedCount: number; + /** Bumped when sidebar-owned PR states change; the pre-run summary + subscribes so its candidate count tracks the sidebar's partition. */ + candidateVersion: number; startRun: (input: { runId: number; items: ReadonlyArray; @@ -59,6 +78,7 @@ interface ReviewSweepState { }) => void; patchItem: (key: string, patch: Partial) => void; finishRun: (runId: number) => void; + bumpCandidateVersion: () => void; reset: () => void; } @@ -68,6 +88,7 @@ export const useReviewSweepStore = create((set) => ({ order: [], items: {}, truncatedCount: 0, + candidateVersion: 0, startRun: ({ runId, items, truncatedCount }) => set({ phase: "running", @@ -83,6 +104,7 @@ export const useReviewSweepStore = create((set) => ({ return { items: { ...state.items, [key]: { ...existing, ...patch } } }; }), finishRun: (runId) => set((state) => (state.runId === runId ? { phase: "complete" } : state)), + bumpCandidateVersion: () => set((state) => ({ candidateVersion: state.candidateVersion + 1 })), reset: () => set({ phase: "idle", order: [], items: {}, truncatedCount: 0 }), })); @@ -101,13 +123,18 @@ export function isSweepCandidate( ): boolean { if (shell.archivedAt !== null) return false; if (capabilities?.threadSettlement !== true || capabilities.reviewSweep !== true) return false; - // PR state is owned by per-row VCS subscriptions in the sidebar and isn't - // readable here; passing null may include a few threads the sidebar shows - // as settled (merged PR + idle). Reviewing them is harmless. + // PR state comes from SidebarV2's per-row subscriptions via + // publishSweepChangeRequestStates, so merged-PR+idle threads classify as + // settled here exactly like they do in the sidebar. Rows that haven't + // reported yet fall back to null (treated as unsettled) — same as a + // freshly mounted sidebar. + const changeRequestState = + changeRequestStateByKey.get(scopedThreadKey(scopeThreadRef(shell.environmentId, shell.id))) ?? + null; return !effectiveSettled(shell, { now: options.now, autoSettleAfterDays: options.autoSettleAfterDays, - changeRequestState: null, + changeRequestState, }); } From f508f4221533d9122729505db836652585b07712 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 04:57:36 -0700 Subject: [PATCH 09/12] Redesign review sweep as a triage board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-data feedback: uniform gray cards grouped by project buried the actionable items. The results view is now organized by what the user should do, ordered easiest-to-clear first: 1. Ready to settle (one-click, emerald accent) 2. Title fixes (sky) 3. Needs your attention (amber — awaiting review/input) 4. In flight / review-failed / still-reviewing tails Cards are denser, carry a per-bucket accent stripe and a single primary action, and gain a metadata row: project favicon + name, branch, thread age, and diff size. Diff stats (+adds/−dels/files) come from the thread's latest ready checkpoint, returned by the review RPC as a new optional diffStats field — no extra client requests. Within each bucket, smallest diff sorts first to build clearing momentum. Co-Authored-By: Claude Fable 5 --- apps/server/src/review/ReviewService.ts | 15 + .../reviewSweep/ReviewSweepView.tsx | 397 +++++++++++++----- apps/web/src/reviewSweepStore.ts | 5 + packages/contracts/src/review.ts | 11 + 4 files changed, 314 insertions(+), 114 deletions(-) diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index c1a66d5e9e1..b62bf0d941c 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -247,12 +247,27 @@ export const make = Effect.gen(function* () { // Belt and braces on top of prompt rules + normalizeThreadReview: an // active thread must never carry a settle recommendation. const recommendSettle = generated.recommendSettle && canSettleNow; + + // Latest ready checkpoint approximates the thread's cumulative diff — + // checkpoints stack per turn, so the newest one reflects total change. + const latestCheckpoint = thread.checkpoints + .toReversed() + .find((checkpoint) => checkpoint.status === "ready"); + const diffStats = latestCheckpoint + ? { + files: latestCheckpoint.files.length, + additions: latestCheckpoint.files.reduce((sum, file) => sum + file.additions, 0), + deletions: latestCheckpoint.files.reduce((sum, file) => sum + file.deletions, 0), + } + : undefined; + return { threadId: input.threadId, summary: generated.summary, suggestedTitle: generated.suggestedTitle, recommendSettle, settleReason: recommendSettle ? generated.settleReason : null, + ...(diffStats !== undefined ? { diffStats } : {}), }; }); diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx index ace5aa1f62c..795279256ad 100644 --- a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -4,11 +4,21 @@ import { scopeProjectRef, } from "@t3tools/client-runtime/environment"; import { Link } from "@tanstack/react-router"; -import { ArrowRightIcon, ListChecksIcon, RotateCcwIcon, XIcon } from "lucide-react"; +import { + ArchiveIcon, + ArrowRightIcon, + CircleAlertIcon, + ListChecksIcon, + LoaderIcon, + PencilLineIcon, + RotateCcwIcon, + XIcon, +} from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import { useClientSettings } from "../../hooks/useSettings"; import { applyAllSweepRecommendations, applySweepSettle, @@ -22,9 +32,15 @@ import { useReviewSweepStore, type SweepItem, } from "../../reviewSweepStore"; -import { useClientSettings } from "../../hooks/useSettings"; -import { useProjects, useServerConfigs, useThreadShells } from "../../state/entities"; +import { + readThreadShell, + useProjects, + useServerConfigs, + useThreadShells, +} from "../../state/entities"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { buildThreadRouteParams } from "../../threadRoutes"; +import { ProjectFavicon } from "../ProjectFavicon"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Card } from "../ui/card"; @@ -32,31 +48,179 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; import { SidebarInset } from "../ui/sidebar"; import { Spinner } from "../ui/spinner"; -function SweepRecommendationBadge({ item }: { item: SweepItem }) { - if (item.status === "error") { - return Review failed; - } - if (item.status !== "done" || item.result === null) { - return null; - } - if (item.result.recommendSettle) { - return item.settleApplied ? ( - Settled - ) : ( - Recommend settle - ); - } - return Keep active; +// --------------------------------------------------------------------------- +// Triage buckets: ordered by "how fast can you clear this" — one-click +// settles first, quick title fixes second, then threads that need real +// reading, then no-action-possible tails. +// --------------------------------------------------------------------------- + +type SweepBucket = "settle" | "title" | "attention" | "inFlight" | "failed" | "reviewing"; + +const BUCKET_ORDER: readonly SweepBucket[] = [ + "settle", + "title", + "attention", + "inFlight", + "failed", + "reviewing", +]; + +const BUCKET_META: Record< + SweepBucket, + { title: string; hint: string; accent: string; icon: typeof ArchiveIcon } +> = { + settle: { + title: "Ready to settle", + hint: "Work concluded — one click clears each from your active list.", + accent: "border-s-emerald-500/70", + icon: ArchiveIcon, + }, + title: { + title: "Title fixes", + hint: "Still in progress, but the name no longer matches the work.", + accent: "border-s-sky-500/70", + icon: PencilLineIcon, + }, + attention: { + title: "Needs your attention", + hint: "Waiting on you — review, input, or a decision.", + accent: "border-s-amber-500/70", + icon: CircleAlertIcon, + }, + inFlight: { + title: "In flight", + hint: "Agents are still working; nothing to do yet.", + accent: "border-s-border", + icon: LoaderIcon, + }, + failed: { + title: "Review failed", + hint: "The reviewer couldn't process these — retry or open them directly.", + accent: "border-s-red-500/70", + icon: CircleAlertIcon, + }, + reviewing: { + title: "Still reviewing", + hint: "", + accent: "border-s-border", + icon: LoaderIcon, + }, +}; + +function classifySweepItem(item: SweepItem): SweepBucket { + if (item.status === "error") return "failed"; + if (item.status !== "done" || item.result === null) return "reviewing"; + if (item.result.recommendSettle && !item.settleApplied) return "settle"; + if (item.result.suggestedTitle && !item.titleApplied) return "title"; + // Live shell wins over review-time knowledge: a thread blocked on the user + // right now belongs in "attention" even if the review predates the block. + const shell = readThreadShell(item.ref); + const working = shell?.session?.status === "running" || shell?.session?.status === "starting"; + const blocked = shell?.hasPendingApprovals === true || shell?.hasPendingUserInput === true; + if (blocked) return "attention"; + if (working) return "inFlight"; + return "attention"; } -function SweepItemCard({ item }: { item: SweepItem }) { +function DiffStatsLabel({ item }: { item: SweepItem }) { + const stats = item.result?.diffStats; + if (!stats || stats.files === 0) return null; + return ( + + +{stats.additions} + −{stats.deletions} + + · {stats.files} {stats.files === 1 ? "file" : "files"} + + + ); +} + +function SweepItemMetaRow({ + item, + projectTitle, + workspaceRoot, +}: { + item: SweepItem; + projectTitle: string; + workspaceRoot: string | null; +}) { + const age = formatRelativeTimeLabel(item.lastActivityAt); + return ( +
+ + {workspaceRoot ? ( + + ) : null} + {projectTitle} + + {item.branch ? ( + + {item.branch} + + ) : null} + {age ? {age} : null} + +
+ ); +} + +function SweepItemCard({ + item, + bucket, + projectTitle, + workspaceRoot, +}: { + item: SweepItem; + bucket: SweepBucket; + projectTitle: string; + workspaceRoot: string | null; +}) { const key = scopedThreadKey(item.ref); - const [applyingTitle, setApplyingTitle] = useState(false); - const [applyingSettle, setApplyingSettle] = useState(false); + const [applying, setApplying] = useState(false); const result = item.result; + const meta = BUCKET_META[bucket]; + + const primaryAction = + bucket === "settle" ? ( + + ) : bucket === "title" ? ( + + ) : bucket === "failed" ? ( + + ) : null; return ( - +
{item.status === "pending" || item.status === "running" ? ( @@ -64,86 +228,52 @@ function SweepItemCard({ item }: { item: SweepItem }) { {item.threadTitle} - - -
- - {item.status === "error" ? ( -
- {item.errorMessage} -
- ) : null} +

- {result !== null ? ( + + + {item.status === "error" ? ( +

{item.errorMessage}

+ ) : result !== null ? (

{result.summary}

- ) : item.status !== "error" ? ( + ) : (

Reviewing…

+ )} + + {bucket === "settle" && result?.settleReason ? ( +

{result.settleReason}

) : null} - {result?.suggestedTitle && !item.titleApplied ? ( -
- - Title - - {item.threadTitle} + {bucket === "title" && result?.suggestedTitle ? ( +
+ {item.threadTitle} {result.suggestedTitle} -
) : null} {result?.suggestedTitle && item.titleApplied ? ( -
+

Renamed to {result.suggestedTitle}. -

- ) : null} - - {result?.recommendSettle && !item.settleApplied ? ( -
- - Settle - - - {result.settleReason ?? "Work appears concluded."} - - -
+

) : null} ); @@ -263,15 +393,15 @@ export function ReviewSweepView() { // Projects are only unique per (environmentId, projectId) — ids can // collide across connected environments. - const projectTitles = useMemo(() => { - const titles = new Map(); + const projectsByKey = useMemo(() => { + const byKey = new Map(); for (const project of projects) { - titles.set( - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - project.title, - ); + byKey.set(scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), { + title: project.title, + workspaceRoot: project.workspaceRoot, + }); } - return titles; + return byKey; }, [projects]); const visibleItems = useMemo( @@ -281,18 +411,33 @@ export function ReviewSweepView() { .filter((item): item is SweepItem => item !== undefined && !item.dismissed), [items, order], ); - const groups = useMemo(() => { - const byProject = new Map(); + + const buckets = useMemo(() => { + const grouped = new Map(); for (const item of visibleItems) { - const key = scopedProjectKey(scopeProjectRef(item.ref.environmentId, item.projectId)); - const group = byProject.get(key); + const bucket = classifySweepItem(item); + const group = grouped.get(bucket); if (group) { group.push(item); } else { - byProject.set(key, [item]); + grouped.set(bucket, [item]); } } - return [...byProject.entries()]; + // Within a bucket, smallest diff first — clear the easy ones, build + // momentum. Unknown diff sizes sink to the bottom. + for (const group of grouped.values()) { + group.sort((a, b) => { + const sizeOf = (item: SweepItem) => { + const stats = item.result?.diffStats; + return stats ? stats.additions + stats.deletions : Number.MAX_SAFE_INTEGER; + }; + return sizeOf(a) - sizeOf(b); + }); + } + return BUCKET_ORDER.flatMap((bucket) => { + const group = grouped.get(bucket); + return group && group.length > 0 ? [[bucket, group] as const] : []; + }); }, [visibleItems]); const reviewedCount = visibleItems.filter( @@ -373,21 +518,45 @@ export function ReviewSweepView() { were skipped this run.

) : null} - {groups.map(([projectKey, groupItems]) => ( -
-

- {projectTitles.get(projectKey) ?? "Unknown project"} - - · {groupItems.length} {groupItems.length === 1 ? "thread" : "threads"} - -

-
- {groupItems.map((item) => ( - - ))} -
-
- ))} + {buckets.map(([bucket, bucketItems]) => { + const meta = BUCKET_META[bucket]; + const BucketIcon = meta.icon; + return ( +
+
+

+ + {meta.title} + + · {bucketItems.length} + +

+ {meta.hint ? ( + + {meta.hint} + + ) : null} +
+
+ {bucketItems.map((item) => { + const projectKey = scopedProjectKey( + scopeProjectRef(item.ref.environmentId, item.projectId), + ); + const project = projectsByKey.get(projectKey); + return ( + + ); + })} +
+
+ ); + })}
)}
diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts index 9a9b17e6bb6..76d54349f77 100644 --- a/apps/web/src/reviewSweepStore.ts +++ b/apps/web/src/reviewSweepStore.ts @@ -50,6 +50,9 @@ export interface SweepItem { readonly ref: ScopedThreadRef; readonly projectId: ProjectId; readonly threadTitle: string; + readonly branch: string | null; + /** Last activity (falls back to createdAt) for the age label. */ + readonly lastActivityAt: string; readonly status: SweepItemStatus; readonly result: ReviewThreadSummaryResult | null; readonly errorMessage: string | null; @@ -224,6 +227,8 @@ export function startReviewSweep(): void { ref: scopeThreadRef(shell.environmentId, shell.id), projectId: shell.projectId, threadTitle: shell.title, + branch: shell.branch, + lastActivityAt: threadLastActivityAt(shell) ?? shell.createdAt, status: "pending", result: null, errorMessage: null, diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index d350bd9d9e5..705621cac49 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -44,6 +44,15 @@ export const ReviewThreadSummaryInput = Schema.Struct({ }); export type ReviewThreadSummaryInput = typeof ReviewThreadSummaryInput.Type; +/** Size of the thread's latest ready checkpoint diff — a cheap effort proxy + for the review UI ("how big is this thread's change"). */ +export const ReviewThreadDiffStats = Schema.Struct({ + files: Schema.Int, + additions: Schema.Int, + deletions: Schema.Int, +}); +export type ReviewThreadDiffStats = typeof ReviewThreadDiffStats.Type; + export const ReviewThreadSummaryResult = Schema.Struct({ threadId: ThreadId, summary: TrimmedNonEmptyString, @@ -51,6 +60,8 @@ export const ReviewThreadSummaryResult = Schema.Struct({ suggestedTitle: Schema.NullOr(TrimmedNonEmptyString), recommendSettle: Schema.Boolean, settleReason: Schema.NullOr(TrimmedNonEmptyString), + /** Absent when the thread has no ready checkpoint (or pre-diff servers). */ + diffStats: Schema.optionalKey(ReviewThreadDiffStats), }); export type ReviewThreadSummaryResult = typeof ReviewThreadSummaryResult.Type; From b763709a3e2cced5ab8f35ec956b4b52cb15ba66 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 05:05:01 -0700 Subject: [PATCH 10/12] Sweep UI v3: grid, reveal-when-done, one-line next steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-data feedback round two: - Results render as a responsive grid (1/2/3 columns) instead of a full-width list. - While the sweep runs, the page shows only a progress count ("Reviewing your work — 7 of 18"); results reveal all at once when every thread is done, so triage happens in one pass over a stable layout instead of chasing cards as they pop in. - The model now returns a one-sentence imperative nextStep ("Review and merge PR #4415.") which becomes the card body — a direct answer to "what should I do here". Summaries are capped at one sentence and demoted to an info-icon tooltip. Co-Authored-By: Claude Fable 5 --- apps/server/src/review/ReviewService.test.ts | 5 + apps/server/src/review/ReviewService.ts | 1 + .../src/textGeneration/TextGeneration.ts | 2 + .../TextGenerationPrompts.test.ts | 2 + .../textGeneration/TextGenerationPrompts.ts | 6 +- .../src/textGeneration/TextGenerationUtils.ts | 4 + .../reviewSweep/ReviewSweepView.tsx | 176 +++++++++++------- packages/contracts/src/review.ts | 3 + 8 files changed, 129 insertions(+), 70 deletions(-) diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index e1a9ebdb726..fdc86638314 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -95,6 +95,7 @@ function makeLayer(input: { input.generateThreadReview?.(reviewInput) ?? Effect.succeed({ summary: "Did the thing.", + nextStep: "Settle this thread.", suggestedTitle: null, recommendSettle: true, settleReason: "Work concluded.", @@ -267,6 +268,7 @@ describe("ReviewService", () => { generateThreadReview: () => Effect.succeed({ summary: "Bumped the settle default; PR merged.", + nextStep: "Nothing left — settle this thread.", suggestedTitle: "Bump settle default", recommendSettle: true, settleReason: "PR merged, nothing pending.", @@ -316,6 +318,7 @@ describe("ReviewService", () => { generateThreadReview: () => Effect.succeed({ summary: "Still working.", + nextStep: "Wait for the agent.", suggestedTitle: null, recommendSettle: true, settleReason: "Looks done to me.", @@ -358,6 +361,7 @@ describe("ReviewService", () => { generateThreadReview: () => Effect.succeed({ summary: "Looks finished.", + nextStep: "Settle this thread.", suggestedTitle: null, recommendSettle: true, settleReason: "Done.", @@ -423,6 +427,7 @@ describe("ReviewService", () => { generateThreadReview: () => Effect.succeed({ summary: "Looks finished.", + nextStep: "Settle this thread.", suggestedTitle: null, recommendSettle: true, settleReason: "Done.", diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index b62bf0d941c..e7bd8859a1d 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -264,6 +264,7 @@ export const make = Effect.gen(function* () { return { threadId: input.threadId, summary: generated.summary, + nextStep: generated.nextStep, suggestedTitle: generated.suggestedTitle, recommendSettle, settleReason: recommendSettle ? generated.settleReason : null, diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 7f218975723..14cdaed03a5 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -81,6 +81,8 @@ export interface ThreadReviewGenerationInput { export interface ThreadReviewGenerationResult { summary: string; + /** One imperative sentence: the user's single next action. */ + nextStep: string; /** Null when the current title is still accurate. */ suggestedTitle: string | null; recommendSettle: boolean; diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index f920c8b363f..e568801fd56 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -191,6 +191,7 @@ describe("buildThreadReviewPrompt", () => { describe("normalizeThreadReview", () => { const raw = { summary: " Work finished. ", + nextStep: " Settle it. ", suggestedTitle: '"Ship settle default"', recommendSettle: true, settleReason: " PR merged. ", @@ -199,6 +200,7 @@ describe("normalizeThreadReview", () => { it("sanitizes fields and keeps settle recommendation for inactive threads", () => { expect(normalizeThreadReview(raw, false)).toEqual({ summary: "Work finished.", + nextStep: "Settle it.", suggestedTitle: "Ship settle default", recommendSettle: true, settleReason: "PR merged.", diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index e501760fd4a..a7227ae37a1 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -249,9 +249,10 @@ export function buildThreadReviewPrompt(input: ThreadReviewPromptInput) { const prompt = [ "You review a coding-agent conversation thread and report its state.", - "Return a JSON object with keys: summary, suggestedTitle, recommendSettle, settleReason.", + "Return a JSON object with keys: summary, nextStep, suggestedTitle, recommendSettle, settleReason.", "Rules:", - "- summary: 1-2 sentences covering what was asked and where the work landed", + "- summary: ONE sentence, max ~25 words: what was asked and where it landed", + "- nextStep: ONE short imperative sentence with the user's single next action (e.g. 'Review and merge PR #123.', 'Answer the agent's question about auth scopes.', 'Nothing left — settle this thread.')", "- suggestedTitle: a corrected 3-8 word title ONLY if the current title is misleading or a placeholder like 'New thread'; otherwise null", "- recommendSettle: true ONLY when the work clearly concluded (done, abandoned, or superseded) and nothing awaits the user", "- settleReason: one short sentence justifying recommendSettle, or null when recommendSettle is false", @@ -273,6 +274,7 @@ export function buildThreadReviewPrompt(input: ThreadReviewPromptInput) { const outputSchema = Schema.Struct({ summary: Schema.String, + nextStep: Schema.String, suggestedTitle: Schema.NullOr(Schema.String), recommendSettle: Schema.Boolean, settleReason: Schema.NullOr(Schema.String), diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index 8c7538aad0e..7a57c23a0f9 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -71,6 +71,7 @@ export function sanitizeThreadTitle(raw: string): string { export function normalizeThreadReview( raw: { summary: string; + nextStep: string; suggestedTitle: string | null; recommendSettle: boolean; settleReason: string | null; @@ -78,11 +79,13 @@ export function normalizeThreadReview( isActive: boolean, ): { summary: string; + nextStep: string; suggestedTitle: string | null; recommendSettle: boolean; settleReason: string | null; } { const summary = raw.summary.trim().replace(/\s+/g, " "); + const nextStep = raw.nextStep.trim().replace(/\s+/g, " "); const suggestedTitle = raw.suggestedTitle && raw.suggestedTitle.trim().length > 0 ? sanitizeThreadTitle(raw.suggestedTitle) @@ -94,6 +97,7 @@ export function normalizeThreadReview( : null; return { summary: summary.length > 0 ? summary : "No summary available.", + nextStep: nextStep.length > 0 ? nextStep : "Review this thread.", suggestedTitle: suggestedTitle === "New thread" ? null : suggestedTitle, recommendSettle, settleReason, diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx index 795279256ad..6655c23bb0e 100644 --- a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -8,6 +8,7 @@ import { ArchiveIcon, ArrowRightIcon, CircleAlertIcon, + InfoIcon, ListChecksIcon, LoaderIcon, PencilLineIcon, @@ -47,6 +48,7 @@ import { Card } from "../ui/card"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; import { SidebarInset } from "../ui/sidebar"; import { Spinner } from "../ui/spinner"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; // --------------------------------------------------------------------------- // Triage buckets: ordered by "how fast can you clear this" — one-click @@ -219,12 +221,17 @@ function SweepItemCard({ ) : null; + // The one-line "what should I do" is the card body; the full summary + // lives behind the info icon so the grid stays scannable. + const nextStep = + item.status === "error" + ? null + : (result?.nextStep ?? + (bucket === "settle" && result?.settleReason ? result.settleReason : null)); + return ( - -
- {item.status === "pending" || item.status === "running" ? ( - - ) : null} + +
Settled ) : null} -
- {primaryAction} +
+ {result?.summary ? ( + + + } + > + + + + {result.summary} + + + ) : null} + ) : null + ) : bucket === "settle" ? ( @@ -479,6 +559,7 @@ export function ReviewSweepView() { ).length; const running = phase === "running"; const [applyingAll, setApplyingAll] = useState(false); + const [mergingAll, setMergingAll] = useState(false); return ( @@ -564,6 +645,12 @@ export function ReviewSweepView() { {buckets.map(([bucket, bucketItems]) => { const meta = BUCKET_META[bucket]; const BucketIcon = meta.icon; + const mergeableCount = + bucket === "mergeReady" + ? bucketItems.filter( + (item) => item.mergeStatus === "idle" || item.mergeStatus === "queued", + ).length + : 0; return (
@@ -579,6 +666,21 @@ export function ReviewSweepView() { {meta.hint} ) : null} + {bucket === "mergeReady" && mergeableCount > 0 ? ( + + ) : null}
{bucketItems.map((item) => { diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts index 76d54349f77..52f754a1b66 100644 --- a/apps/web/src/reviewSweepStore.ts +++ b/apps/web/src/reviewSweepStore.ts @@ -7,16 +7,19 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import type { - EnvironmentId, - ProjectId, - ReviewThreadSummaryResult, - ScopedThreadRef, +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type EnvironmentId, + type ProjectId, + type ReviewThreadSummaryResult, + type ScopedThreadRef, } from "@t3tools/contracts"; import { create } from "zustand"; import { stackedThreadToast, toastManager } from "./components/ui/toast"; import { getClientSettings } from "./hooks/useSettings"; +import { newMessageId } from "~/lib/utils"; import { appAtomRegistry } from "./rpc/atomRegistry"; import { readThreadShell, readThreadRefs } from "./state/entities"; import { reviewEnvironment } from "./state/review"; @@ -46,6 +49,10 @@ export const SWEEP_MAX_THREADS = 50; export type SweepItemStatus = "pending" | "running" | "done" | "error"; +/** Merge-queue lifecycle for merge-ready items. "conflicted" means the + merge failed and the thread's agent has been asked to resolve it. */ +export type SweepMergeStatus = "idle" | "queued" | "merging" | "merged" | "conflicted" | "failed"; + export interface SweepItem { readonly ref: ScopedThreadRef; readonly projectId: ProjectId; @@ -59,6 +66,8 @@ export interface SweepItem { readonly titleApplied: boolean; readonly settleApplied: boolean; readonly dismissed: boolean; + readonly mergeStatus: SweepMergeStatus; + readonly mergeDetail: string | null; } export type SweepPhase = "idle" | "running" | "complete"; @@ -202,6 +211,17 @@ async function reviewOne(runId: number, item: SweepItem): Promise { } } +/** Navigation callback installed by the app shell so store-level toasts can + deep-link to /review-sweep without importing the router. */ +let navigateToSweepResults: (() => void) | null = null; +export function setSweepResultsNavigator(navigate: (() => void) | null): void { + navigateToSweepResults = navigate; +} + +function isViewingSweepResults(): boolean { + return window.location.pathname === "/review-sweep"; +} + async function runPool(runId: number, items: ReadonlyArray): Promise { let next = 0; const workers = Array.from({ length: Math.min(SWEEP_CONCURRENCY, items.length) }, async () => { @@ -213,7 +233,35 @@ async function runPool(runId: number, items: ReadonlyArray): Promise< } }); await Promise.all(workers); - useReviewSweepStore.getState().finishRun(runId); + const store = useReviewSweepStore.getState(); + if (store.runId !== runId) return; + store.finishRun(runId); + // The sweep runs in the background; only toast when the user isn't + // already staring at the results page. + if (!isViewingSweepResults()) { + const actionable = Object.values(store.items).filter( + (item) => + !item.dismissed && + ((item.result?.suggestedTitle && !item.titleApplied) || + (item.result?.recommendSettle && !item.settleApplied) || + item.result?.prStatus?.mergeReady === true), + ).length; + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Work review finished", + description: + actionable > 0 + ? `${actionable} ${actionable === 1 ? "thread has" : "threads have"} recommended actions.` + : "No actions recommended — you're caught up.", + actionVariant: "outline", + actionProps: { + children: "View results", + onClick: () => navigateToSweepResults?.(), + }, + }), + ); + } } /** Kick off a sweep over every unsettled thread. Runs outside React so it @@ -235,6 +283,8 @@ export function startReviewSweep(): void { titleApplied: false, settleApplied: false, dismissed: false, + mergeStatus: "idle", + mergeDetail: null, }), ); useReviewSweepStore.getState().startRun({ @@ -372,3 +422,163 @@ export async function applyAllSweepRecommendations(): Promise { export function dismissSweepItem(key: string): void { useReviewSweepStore.getState().patchItem(key, { dismissed: true }); } + +// --------------------------------------------------------------------------- +// Merge queue +// --------------------------------------------------------------------------- + +/** Message sent to a thread's agent when its PR stopped merging cleanly + because earlier queue entries landed first. */ +function conflictHandoffMessage(prNumber: number): string { + return [ + `PR #${prNumber} no longer merges cleanly — other pull requests were merged into the base branch ahead of it.`, + "Please rebase this branch onto the latest base branch, resolve any merge conflicts, verify the build still passes, and push the updated branch.", + ].join(" "); +} + +function isMergeQueueCandidate(item: SweepItem): boolean { + return ( + !item.dismissed && + item.result?.prStatus?.mergeReady === true && + (item.mergeStatus === "idle" || item.mergeStatus === "queued") + ); +} + +let mergeQueueActive = false; + +/** Merge one item's PR and settle its thread on success. On conflict, mark + the card and hand the rebase to the thread's agent. Returns the outcome + for queue accounting. */ +async function mergeOne( + runId: number, + key: string, +): Promise<"merged" | "conflict" | "skipped" | "failed"> { + const store = useReviewSweepStore.getState(); + if (store.runId !== runId) return "skipped"; + const item = store.items[key]; + const prNumber = item?.result?.prStatus?.number; + if (!item || prNumber === undefined) return "skipped"; + store.patchItem(key, { mergeStatus: "merging" }); + + const result = await runAtomCommand( + appAtomRegistry, + reviewEnvironment.mergePullRequest, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId, pullRequestNumber: prNumber }, + }, + { reportFailure: false, reportDefect: false }, + ); + + const after = useReviewSweepStore.getState(); + if (after.runId !== runId) return "skipped"; + + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + after.patchItem(key, { + mergeStatus: "failed", + mergeDetail: error instanceof Error ? error.message : "Merge failed.", + }); + return "failed"; + } + + const outcome = result.value.outcome; + if (outcome === "merged" || outcome === "already-closed") { + after.patchItem(key, { mergeStatus: "merged", mergeDetail: result.value.detail }); + // Merged PRs are concluded work: settle right away so the sidebar + // reflects the merge without waiting for auto-settle. + const settleResult = await runAtomCommand( + appAtomRegistry, + threadEnvironment.settle, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId }, + }, + { reportFailure: false }, + ); + if (settleResult._tag === "Success") { + useReviewSweepStore.getState().patchItem(key, { settleApplied: true }); + } + return "merged"; + } + + if (outcome === "conflict") { + after.patchItem(key, { mergeStatus: "conflicted", mergeDetail: result.value.detail }); + // Hand the conflict to the thread's own agent: it has the branch + // checked out and the full context to rebase and resolve. + await runAtomCommand( + appAtomRegistry, + threadEnvironment.startTurn, + { + environmentId: item.ref.environmentId, + input: { + threadId: item.ref.threadId, + message: { + messageId: newMessageId(), + role: "user" as const, + text: conflictHandoffMessage(prNumber), + attachments: [], + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: new Date().toISOString(), + }, + }, + { reportFailure: false }, + ); + return "conflict"; + } + + after.patchItem(key, { mergeStatus: "failed", mergeDetail: result.value.detail }); + return "failed"; +} + +/** Merge a single card's PR (per-item button). */ +export async function mergeSweepItem(key: string): Promise { + const { runId } = useReviewSweepStore.getState(); + await mergeOne(runId, key); +} + +/** Merge every merge-ready PR SEQUENTIALLY — one at a time, so each merge + sees the base branch the previous one produced and the server re-check + catches freshly introduced conflicts. Conflicts don't stop the queue: + the item is marked conflicted and its thread's agent is asked to rebase + and resolve; re-run the sweep once it pushes to pick the PR back up. */ +export async function runMergeQueue(): Promise { + if (mergeQueueActive) return; + mergeQueueActive = true; + try { + const { order, items, runId } = useReviewSweepStore.getState(); + const queueKeys = order.filter((key) => { + const item = items[key]; + return item !== undefined && isMergeQueueCandidate(item); + }); + for (const key of queueKeys) { + useReviewSweepStore.getState().patchItem(key, { mergeStatus: "queued" }); + } + + let mergedCount = 0; + let conflictCount = 0; + for (const key of queueKeys) { + const outcome = await mergeOne(runId, key); + if (outcome === "merged") mergedCount += 1; + if (outcome === "conflict") conflictCount += 1; + if (useReviewSweepStore.getState().runId !== runId) return; + } + + if (queueKeys.length > 0) { + toastManager.add( + stackedThreadToast({ + type: conflictCount > 0 ? "warning" : "success", + title: `Merge queue finished: ${mergedCount} of ${queueKeys.length} merged`, + description: + conflictCount > 0 + ? `${conflictCount} ${conflictCount === 1 ? "PR hit a conflict and was" : "PRs hit conflicts and were"} handed to ${conflictCount === 1 ? "its" : "their"} thread's agent to rebase.` + : undefined, + }), + ); + } + } finally { + mergeQueueActive = false; + } +} diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts index 11abb6a405e..d834b190b67 100644 --- a/packages/client-runtime/src/state/review.ts +++ b/packages/client-runtime/src/state/review.ts @@ -17,5 +17,9 @@ export function createReviewEnvironmentAtoms( label: "environment-data:review:summarize-thread", tag: WS_METHODS.reviewSummarizeThread, }), + mergePullRequest: createEnvironmentRpcCommand(runtime, { + label: "environment-data:review:merge-pull-request", + tag: WS_METHODS.reviewMergePullRequest, + }), }; } diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index 7bb4f804cb8..a0a11c126a9 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -53,6 +53,25 @@ export const ReviewThreadDiffStats = Schema.Struct({ }); export type ReviewThreadDiffStats = typeof ReviewThreadDiffStats.Type; +/** Live pull-request context gathered during the review: state, review + verdict, CI, and whether the PR is mergeable as-is. */ +export const ReviewThreadPrStatus = Schema.Struct({ + number: Schema.Int, + url: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed", "merged"]), + /** GitHub reviewDecision: APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / + empty (no required reviews). */ + reviewDecision: Schema.NullOr(TrimmedNonEmptyString), + /** Rolled-up CI outcome for the head commit. */ + checksPassing: Schema.NullOr(Schema.Boolean), + /** GitHub mergeable: whether the branch applies cleanly onto base. */ + mergeable: Schema.NullOr(Schema.Boolean), + /** Open + clean merge + CI green + not blocked by requested changes. */ + mergeReady: Schema.Boolean, + recentCommentCount: Schema.Int, +}); +export type ReviewThreadPrStatus = typeof ReviewThreadPrStatus.Type; + export const ReviewThreadSummaryResult = Schema.Struct({ threadId: ThreadId, summary: TrimmedNonEmptyString, @@ -65,6 +84,8 @@ export const ReviewThreadSummaryResult = Schema.Struct({ settleReason: Schema.NullOr(TrimmedNonEmptyString), /** Absent when the thread has no ready checkpoint (or pre-diff servers). */ diffStats: Schema.optionalKey(ReviewThreadDiffStats), + /** Absent when the thread has no PR, the lookup failed, or pre-PR servers. */ + prStatus: Schema.optionalKey(ReviewThreadPrStatus), }); export type ReviewThreadSummaryResult = typeof ReviewThreadSummaryResult.Type; @@ -78,3 +99,41 @@ export const ReviewThreadSummaryError = Schema.Union([ ReviewThreadNotFoundError, ]); export type ReviewThreadSummaryError = typeof ReviewThreadSummaryError.Type; + +export const ReviewMergePullRequestInput = Schema.Struct({ + threadId: ThreadId, + /** The PR number the client saw at review time; the server re-validates + merge-readiness against live GitHub state before merging. */ + pullRequestNumber: Schema.Int, +}); +export type ReviewMergePullRequestInput = typeof ReviewMergePullRequestInput.Type; + +export const ReviewMergePullRequestResult = Schema.Struct({ + threadId: ThreadId, + outcome: Schema.Literals([ + "merged", + /** Branch no longer applies cleanly (e.g. an earlier queue merge landed + first). The client hands the conflict to the thread's agent. */ + "conflict", + /** PR already merged or closed since the review ran. */ + "already-closed", + /** No longer merge-ready (CI regressed, review dismissed, ...). */ + "not-ready", + ]), + detail: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ReviewMergePullRequestResult = typeof ReviewMergePullRequestResult.Type; + +export class ReviewMergeError extends Schema.TaggedErrorClass()( + "ReviewMergeError", + { + threadId: ThreadId, + detail: TrimmedNonEmptyString, + }, +) {} + +export const ReviewMergePullRequestError = Schema.Union([ + ReviewThreadNotFoundError, + ReviewMergeError, +]); +export type ReviewMergePullRequestError = typeof ReviewMergePullRequestError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a5e125152b7..cfb6645d0d6 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -43,6 +43,9 @@ import { ReviewDiffPreviewError, ReviewDiffPreviewInput, ReviewDiffPreviewResult, + ReviewMergePullRequestError, + ReviewMergePullRequestInput, + ReviewMergePullRequestResult, ReviewThreadSummaryError, ReviewThreadSummaryInput, ReviewThreadSummaryResult, @@ -185,6 +188,7 @@ export const WS_METHODS = { // Review methods reviewGetDiffPreview: "review.getDiffPreview", reviewSummarizeThread: "review.summarizeThread", + reviewMergePullRequest: "review.mergePullRequest", // Terminal methods terminalOpen: "terminal.open", @@ -507,6 +511,16 @@ export const WsReviewSummarizeThreadRpc = Rpc.make(WS_METHODS.reviewSummarizeThr error: Schema.Union([ReviewThreadSummaryError, EnvironmentAuthorizationError]), }); +/** Merge a review-sweep thread's PR after re-validating merge readiness + against live GitHub state. Non-exceptional failures (conflict, already + closed, no longer ready) are outcomes, not errors, so the client's merge + queue can branch on them. */ +export const WsReviewMergePullRequestRpc = Rpc.make(WS_METHODS.reviewMergePullRequest, { + payload: ReviewMergePullRequestInput, + success: ReviewMergePullRequestResult, + error: Schema.Union([ReviewMergePullRequestError, EnvironmentAuthorizationError]), +}); + export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, @@ -751,6 +765,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsInitRpc, WsReviewGetDiffPreviewRpc, WsReviewSummarizeThreadRpc, + WsReviewMergePullRequestRpc, WsTerminalOpenRpc, WsTerminalAttachRpc, WsTerminalWriteRpc,