From 40e903d8339c3fb741cdbbcd66c04301c29febd1 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 16 May 2026 22:56:42 -0700
Subject: [PATCH] fix: address review findings (#2138)
---
src/main/ipc/filesystem.ts | 98 +++++-
src/main/ipc/hosted-review.ts | 24 +-
src/main/runtime/orca-runtime-git.ts | 82 ++++-
src/main/runtime/orca-runtime.test.ts | 99 +++++-
src/main/runtime/orca-runtime.ts | 35 +-
src/main/runtime/rpc/methods/git-params.ts | 114 +++++++
src/main/runtime/rpc/methods/git.ts | 162 ++++------
.../runtime/rpc/methods/hosted-review.test.ts | 4 +
src/main/runtime/rpc/methods/hosted-review.ts | 4 +
.../commit-message-text-generation.test.ts | 91 ++++++
.../commit-message-text-generation.ts | 118 +++++--
.../text-generation/pull-request-context.ts | 64 ++++
src/preload/api-types.ts | 19 ++
src/preload/index.ts | 12 +
.../components/right-sidebar/ChecksPanel.tsx | 5 +
.../right-sidebar/CreatePullRequestDialog.tsx | 185 ++++++-----
.../right-sidebar/SourceControl.tsx | 18 +-
.../source-control-dropdown-items.test.ts | 15 +
.../source-control-dropdown-items.ts | 22 +-
.../source-control-primary-action.test.ts | 14 +-
.../source-control-primary-action.ts | 16 +-
.../useCreatePullRequestDialogFields.ts | 299 ++++++++++++++++++
.../src/runtime/runtime-git-client.ts | 51 +++
.../store/slices/hosted-review-cache.test.ts | 171 ++++++++++
.../src/store/slices/hosted-review.test.ts | 227 ++++++-------
.../src/store/slices/hosted-review.ts | 15 +-
src/renderer/src/web/web-preload-api.ts | 5 +
src/shared/hosted-review.ts | 2 +
src/shared/pull-request-generation.test.ts | 56 ++++
src/shared/pull-request-generation.ts | 114 +++++++
30 files changed, 1775 insertions(+), 366 deletions(-)
create mode 100644 src/main/runtime/rpc/methods/git-params.ts
create mode 100644 src/main/text-generation/pull-request-context.ts
create mode 100644 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
create mode 100644 src/renderer/src/store/slices/hosted-review-cache.test.ts
create mode 100644 src/shared/pull-request-generation.test.ts
create mode 100644 src/shared/pull-request-generation.ts
diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts
index ed896826d..d96f9a733 100644
--- a/src/main/ipc/filesystem.ts
+++ b/src/main/ipc/filesystem.ts
@@ -3,7 +3,7 @@ import { ipcMain, shell } from 'electron'
import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises'
import { extname, join } from 'path'
import type { ChildProcess } from 'child_process'
-import { wslAwareSpawn } from '../git/runner'
+import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
import { parseWslPath, toWindowsWslPath } from '../wsl'
import type { Store } from '../persistence'
import type {
@@ -48,10 +48,14 @@ import {
import { getHistory } from '../git/history'
import {
cancelGenerateCommitMessageLocal,
+ cancelGeneratePullRequestFieldsLocal,
generateCommitMessageFromContext,
+ generatePullRequestFieldsFromContext,
resolveCommitMessageSettings,
- type GenerateCommitMessageResult
+ type GenerateCommitMessageResult,
+ type GeneratePullRequestFieldsResult
} from '../text-generation/commit-message-text-generation'
+import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
import { getUpstreamStatus } from '../git/upstream'
import { gitFetch, gitPull, gitPush } from '../git/remote'
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
@@ -691,6 +695,96 @@ export function registerFilesystemHandlers(
}
)
+ ipcMain.handle(
+ 'git:generatePullRequestFields',
+ async (
+ _event,
+ args: {
+ worktreePath: string
+ base: string
+ title: string
+ body: string
+ draft: boolean
+ connectionId?: string
+ }
+ ): Promise => {
+ const resolvedSettings = resolveCommitMessageSettings(store.getSettings())
+ if (!resolvedSettings.ok) {
+ return { success: false, error: resolvedSettings.error }
+ }
+ if (args.connectionId) {
+ const provider = getSshGitProvider(args.connectionId)
+ if (!provider) {
+ return {
+ success: false,
+ error: `No git provider for connection "${args.connectionId}"`
+ }
+ }
+ const context = await getPullRequestDraftContext(
+ (argv) => provider.exec(argv, args.worktreePath),
+ {
+ base: args.base,
+ currentTitle: args.title,
+ currentBody: args.body,
+ currentDraft: args.draft
+ }
+ )
+ if (!context) {
+ return { success: false, error: 'No branch changes to summarize.' }
+ }
+ return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
+ kind: 'remote',
+ cwd: args.worktreePath,
+ execute: (plan, cwd, timeoutMs) =>
+ provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
+ missingBinaryLocation: 'remote PATH'
+ })
+ }
+
+ const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
+ const context = await getPullRequestDraftContext(
+ (argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }),
+ {
+ base: args.base,
+ currentTitle: args.title,
+ currentBody: args.body,
+ currentDraft: args.draft
+ }
+ )
+ if (!context) {
+ return { success: false, error: 'No branch changes to summarize.' }
+ }
+ const localEnv = await prepareLocalCommitMessageAgentEnv(
+ resolvedSettings.params.agentId,
+ commitMessageAgentEnv
+ )
+ if (!localEnv.ok) {
+ return { success: false, error: localEnv.error }
+ }
+ return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
+ kind: 'local',
+ cwd: worktreePath,
+ ...(localEnv.env ? { env: localEnv.env } : {})
+ })
+ }
+ )
+
+ ipcMain.handle(
+ 'git:cancelGeneratePullRequestFields',
+ async (_event, args: { worktreePath: string; connectionId?: string }): Promise => {
+ if (args.connectionId) {
+ const provider = getSshGitProvider(args.connectionId)
+ if (!provider) {
+ return
+ }
+ await provider.cancelGenerateCommitMessage(args.worktreePath)
+ return
+ }
+ const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
+ cancelGeneratePullRequestFieldsLocal(worktreePath)
+ }
+ )
+
ipcMain.handle(
'git:branchCompare',
async (
diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts
index 909eec869..d972334bf 100644
--- a/src/main/ipc/hosted-review.ts
+++ b/src/main/ipc/hosted-review.ts
@@ -13,6 +13,8 @@ import {
getHostedReviewCreationEligibility
} from '../source-control/hosted-review-creation'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
+import { resolveRegisteredWorktreePath } from './filesystem-auth'
+import { listRepoWorktrees } from '../repo-worktrees'
function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo {
if (repoId) {
@@ -30,6 +32,22 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string):
return repo
}
+async function resolveHostedReviewWorktreePath(
+ repo: Repo,
+ store: Store,
+ worktreePath?: string
+): Promise {
+ if (!worktreePath) {
+ return repo.path
+ }
+ const resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store)
+ const repoWorktrees = await listRepoWorktrees(repo)
+ if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) {
+ throw new Error('Access denied: worktree does not belong to repository')
+ }
+ return resolvedWorktreePath
+}
+
export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void {
ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => {
const repo = assertRegisteredRepo(args.repoPath, store, args.repoId)
@@ -71,7 +89,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
body: null
}
}
- return getHostedReviewCreationEligibility({ ...args, repoPath: repo.path })
+ const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
+ return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath })
}
)
@@ -84,7 +103,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
error: 'Creating pull requests from SSH worktrees is not supported yet.'
}
}
- const result = await createHostedReview(repo.path, {
+ const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
+ const result = await createHostedReview(worktreePath, {
provider: args.provider,
base: args.base,
head: args.head,
diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts
index f5fa5ada3..46c1f30e9 100644
--- a/src/main/runtime/orca-runtime-git.ts
+++ b/src/main/runtime/orca-runtime-git.ts
@@ -37,13 +37,18 @@ import { gitFetch, gitPull, gitPush } from '../git/remote'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
cancelGenerateCommitMessageLocal,
+ cancelGeneratePullRequestFieldsLocal,
generateCommitMessageFromContext,
+ generatePullRequestFieldsFromContext,
resolveCommitMessageSettings,
- type GenerateCommitMessageResult
+ type GenerateCommitMessageResult,
+ type GeneratePullRequestFieldsResult
} from '../text-generation/commit-message-text-generation'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment'
+import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
+import { gitExecFileAsync } from '../git/runner'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
type RuntimeCommitMessageSettingsOverride = Partial<
@@ -379,6 +384,81 @@ export class RuntimeGitCommands {
return { ok: true }
}
+ async generateRuntimePullRequestFields(
+ worktreeSelector: string,
+ input: { base: string; title: string; body: string; draft: boolean },
+ settingsOverride?: RuntimeCommitMessageSettingsOverride
+ ): Promise {
+ const resolvedSettings = resolveCommitMessageSettings({
+ ...this.host.getRuntimeSettings(),
+ ...settingsOverride
+ })
+ if (!resolvedSettings.ok) {
+ return { success: false, error: resolvedSettings.error }
+ }
+
+ const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
+ const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
+ if (target.connectionId && !provider) {
+ return {
+ success: false,
+ error: `No git provider for connection "${target.connectionId}"`
+ }
+ }
+ const context = target.connectionId
+ ? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), {
+ base: input.base,
+ currentTitle: input.title,
+ currentBody: input.body,
+ currentDraft: input.draft
+ })
+ : await getPullRequestDraftContext(
+ (argv, options) => gitExecFileAsync(argv, { cwd: target.worktree.path, ...options }),
+ {
+ base: input.base,
+ currentTitle: input.title,
+ currentBody: input.body,
+ currentDraft: input.draft
+ }
+ )
+ if (!context) {
+ return { success: false, error: 'No branch changes to summarize.' }
+ }
+
+ if (target.connectionId) {
+ return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
+ kind: 'remote',
+ cwd: target.worktree.path,
+ execute: (plan, cwd, timeoutMs) => provider!.executeCommitMessagePlan(plan, cwd, timeoutMs),
+ missingBinaryLocation: 'remote PATH'
+ })
+ }
+
+ const localEnv = await prepareLocalCommitMessageAgentEnv(
+ resolvedSettings.params.agentId,
+ this.host.getCommitMessageAgentEnvironment?.()
+ )
+ if (!localEnv.ok) {
+ return { success: false, error: localEnv.error }
+ }
+ return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
+ kind: 'local',
+ cwd: target.worktree.path,
+ ...(localEnv.env ? { env: localEnv.env } : {})
+ })
+ }
+
+ async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> {
+ const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
+ const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
+ if (target.connectionId) {
+ await provider?.cancelGenerateCommitMessage(target.worktree.path)
+ return { ok: true }
+ }
+ cancelGeneratePullRequestFieldsLocal(target.worktree.path)
+ return { ok: true }
+ }
+
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index cae344efb..a34ef8c00 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -38,7 +38,9 @@ const {
getSshGitProviderMock,
registerSshGitProviderMock,
unregisterSshGitProviderMock,
- invalidateAuthorizedRootsCacheMock
+ invalidateAuthorizedRootsCacheMock,
+ createHostedReviewMock,
+ getHostedReviewCreationEligibilityMock
} = vi.hoisted(() => {
// Why: SSH runtime tests register providers through the public dispatcher API,
// so the mock needs the same registry semantics as the real module.
@@ -66,7 +68,9 @@ const {
unregisterSshGitProviderMock: vi.fn((connectionId: string) => {
sshGitProviders.delete(connectionId)
}),
- invalidateAuthorizedRootsCacheMock: vi.fn()
+ invalidateAuthorizedRootsCacheMock: vi.fn(),
+ createHostedReviewMock: vi.fn(),
+ getHostedReviewCreationEligibilityMock: vi.fn()
}
})
@@ -110,6 +114,11 @@ vi.mock('../ipc/filesystem-auth', () => ({
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
}))
+vi.mock('../source-control/hosted-review-creation', () => ({
+ createHostedReview: createHostedReviewMock,
+ getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock
+}))
+
// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a
// fallback base branch. Real resolution shells out to `git` against the
// test's fabricated repo path, which has no refs, so we stub it to a
@@ -157,6 +166,25 @@ afterEach(() => {
computeWorktreePathMock.mockReset()
ensurePathWithinWorkspaceMock.mockReset()
invalidateAuthorizedRootsCacheMock.mockReset()
+ createHostedReviewMock.mockReset()
+ createHostedReviewMock.mockResolvedValue({
+ ok: true,
+ provider: 'github',
+ number: 1,
+ url: 'https://example.com/pull/1'
+ })
+ getHostedReviewCreationEligibilityMock.mockReset()
+ getHostedReviewCreationEligibilityMock.mockResolvedValue({
+ provider: 'github',
+ review: null,
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null,
+ defaultBaseRef: 'main',
+ head: 'feature/foo',
+ title: null,
+ body: null
+ })
})
function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void {
@@ -810,6 +838,73 @@ describe('OrcaRuntimeService', () => {
)
})
+ it('rejects hosted review worktree selectors outside the selected repo', async () => {
+ vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => {
+ if (repoPath === '/tmp/repo-b') {
+ return [
+ {
+ path: '/tmp/worktree-b',
+ head: 'def',
+ branch: 'feature/bar',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ]
+ }
+ return MOCK_GIT_WORKTREES
+ })
+ const repos = [
+ {
+ id: TEST_REPO_ID,
+ path: TEST_REPO_PATH,
+ displayName: 'repo',
+ badgeColor: 'blue',
+ addedAt: 1
+ },
+ {
+ id: 'repo-2',
+ path: '/tmp/repo-b',
+ displayName: 'repo-b',
+ badgeColor: 'green',
+ addedAt: 2
+ }
+ ]
+ const multiRepoStore = {
+ ...store,
+ getRepos: () => repos,
+ getRepo: (id: string) => repos.find((repo) => repo.id === id)
+ }
+ const runtime = new OrcaRuntimeService(multiRepoStore as never)
+
+ await expect(
+ runtime.getHostedReviewCreationEligibility({
+ repoSelector: 'id:repo-1',
+ worktreeSelector: 'id:repo-2::/tmp/worktree-b',
+ branch: 'feature/bar',
+ base: 'main',
+ hasUncommittedChanges: false,
+ hasUpstream: true,
+ ahead: 1,
+ behind: 0
+ })
+ ).rejects.toThrow('Access denied: worktree does not belong to repository')
+ await expect(
+ runtime.createHostedReview({
+ repoSelector: 'id:repo-1',
+ worktreeSelector: 'id:repo-2::/tmp/worktree-b',
+ provider: 'github',
+ base: 'main',
+ head: 'feature/bar',
+ title: 'Create PR',
+ body: '',
+ draft: false
+ })
+ ).rejects.toThrow('Access denied: worktree does not belong to repository')
+
+ expect(getHostedReviewCreationEligibilityMock).not.toHaveBeenCalled()
+ expect(createHostedReviewMock).not.toHaveBeenCalled()
+ })
+
it('treats SSH worktree drift as unknown without local git probes', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(getDefaultBaseRef).mockClear()
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 660cd4832..632bfc1ba 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -1239,6 +1239,10 @@ export class OrcaRuntimeService {
this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands)
cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] =
this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands)
+ generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] =
+ this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands)
+ cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] =
+ this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands)
stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] =
this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands)
unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] =
@@ -4467,6 +4471,22 @@ export class OrcaRuntimeService {
}
}
+ private async resolveHostedReviewTarget(args: {
+ repoSelector: string
+ worktreeSelector?: string
+ }): Promise<{ repo: Repo; repoPath: string }> {
+ const repo = await this.resolveRepoSelector(args.repoSelector)
+ if (!args.worktreeSelector) {
+ return { repo, repoPath: repo.path }
+ }
+
+ const worktree = await this.resolveWorktreeSelector(args.worktreeSelector)
+ if (worktree.repoId !== repo.id) {
+ throw new Error('Access denied: worktree does not belong to repository')
+ }
+ return { repo, repoPath: worktree.path }
+ }
+
async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> {
const repo = await this.resolveRepoSelector(repoSelector)
this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug')
@@ -4583,12 +4603,15 @@ export class OrcaRuntimeService {
}
async getHostedReviewCreationEligibility(
- args: Omit & { repoSelector: string }
+ args: Omit & {
+ repoSelector: string
+ worktreeSelector?: string
+ }
): Promise {
- const repo = await this.resolveRepoSelector(args.repoSelector)
+ const { repo, repoPath } = await this.resolveHostedReviewTarget(args)
this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review')
return getHostedReviewCreationEligibilityFromRepo({
- repoPath: repo.path,
+ repoPath,
branch: args.branch,
base: args.base ?? null,
hasUncommittedChanges: args.hasUncommittedChanges,
@@ -4604,11 +4627,11 @@ export class OrcaRuntimeService {
}
async createHostedReview(
- args: CreateHostedReviewInput & { repoSelector: string }
+ args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string }
): Promise {
- const repo = await this.resolveRepoSelector(args.repoSelector)
+ const { repo, repoPath } = await this.resolveHostedReviewTarget(args)
this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review')
- const result = await createHostedReviewFromRepo(repo.path, {
+ const result = await createHostedReviewFromRepo(repoPath, {
provider: args.provider,
base: args.base,
head: args.head,
diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts
new file mode 100644
index 000000000..69b2c21f5
--- /dev/null
+++ b/src/main/runtime/rpc/methods/git-params.ts
@@ -0,0 +1,114 @@
+import { z } from 'zod'
+
+export const WorktreeSelector = z.object({
+ worktree: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing worktree selector'))
+})
+
+export const GitStatusParams = WorktreeSelector.extend({
+ includeIgnored: z.boolean().optional()
+})
+
+export const GitFilePath = WorktreeSelector.extend({
+ filePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing file path'))
+})
+
+export const GitDiff = GitFilePath.extend({
+ staged: z.boolean(),
+ compareAgainstHead: z.boolean().optional()
+})
+
+export const GitBranchCompare = WorktreeSelector.extend({
+ baseRef: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(
+ z
+ .string()
+ .min(1, 'Missing base ref')
+ .refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
+ )
+})
+
+const FullGitObjectId = z
+ .string()
+ .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
+
+export const GitCommitCompare = WorktreeSelector.extend({
+ commitId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(FullGitObjectId)
+})
+
+export const GitHistory = WorktreeSelector.extend({
+ limit: z.number().int().min(1).max(200).optional(),
+ baseRef: z.string().nullable().optional()
+})
+
+export const GitBranchDiff = GitFilePath.extend({
+ compare: z.object({
+ baseRef: z.string().optional(),
+ baseOid: FullGitObjectId.optional(),
+ headOid: FullGitObjectId,
+ mergeBase: FullGitObjectId
+ }),
+ oldPath: z.string().optional()
+})
+
+export const GitCommitDiff = GitFilePath.extend({
+ commitOid: FullGitObjectId,
+ parentOid: FullGitObjectId.nullable().optional(),
+ oldPath: z.string().optional()
+})
+
+export const GitCommit = WorktreeSelector.extend({
+ message: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing commit message'))
+})
+
+const CommitMessageAiSettings = z.object({
+ enabled: z.boolean(),
+ agentId: z.string().nullable(),
+ selectedModelByAgent: z.record(z.string(), z.string()),
+ selectedThinkingByModel: z.record(z.string(), z.string()),
+ customPrompt: z.string(),
+ customAgentCommand: z.string()
+})
+
+export const GitGenerateCommitMessage = WorktreeSelector.extend({
+ commitMessageAi: CommitMessageAiSettings.optional(),
+ agentCmdOverrides: z.record(z.string(), z.string()).optional(),
+ enableGitHubAttribution: z.boolean().optional()
+})
+
+export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
+ base: z.string().min(1, 'Missing base branch'),
+ title: z.string(),
+ body: z.string(),
+ draft: z.boolean()
+})
+
+export const GitBulkPaths = WorktreeSelector.extend({
+ filePaths: z.array(z.string().min(1, 'Missing file path'))
+})
+
+export const GitPush = WorktreeSelector.extend({
+ publish: z.boolean().optional(),
+ pushTarget: z.unknown().optional()
+})
+
+export const GitRemoteFileUrl = WorktreeSelector.extend({
+ relativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing relative path')),
+ line: z.number().int().min(1)
+})
diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts
index c6db872eb..2eda43d67 100644
--- a/src/main/runtime/rpc/methods/git.ts
+++ b/src/main/runtime/rpc/methods/git.ts
@@ -1,112 +1,22 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import type { GlobalSettings } from '../../../../shared/types'
-
-const WorktreeSelector = z.object({
- worktree: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing worktree selector'))
-})
-
-const GitStatusParams = WorktreeSelector.extend({
- includeIgnored: z.boolean().optional()
-})
-
-const GitFilePath = WorktreeSelector.extend({
- filePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing file path'))
-})
-
-const GitDiff = GitFilePath.extend({
- staged: z.boolean(),
- compareAgainstHead: z.boolean().optional()
-})
-
-const GitBranchCompare = WorktreeSelector.extend({
- baseRef: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(
- z
- .string()
- .min(1, 'Missing base ref')
- .refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
- )
-})
-
-const FullGitObjectId = z
- .string()
- .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
-
-const GitCommitCompare = WorktreeSelector.extend({
- commitId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(FullGitObjectId)
-})
-
-const GitHistory = WorktreeSelector.extend({
- limit: z.number().int().min(1).max(200).optional(),
- baseRef: z.string().nullable().optional()
-})
-
-const GitBranchDiff = GitFilePath.extend({
- compare: z.object({
- baseRef: z.string().optional(),
- baseOid: FullGitObjectId.optional(),
- headOid: FullGitObjectId,
- mergeBase: FullGitObjectId
- }),
- oldPath: z.string().optional()
-})
-
-const GitCommitDiff = GitFilePath.extend({
- commitOid: FullGitObjectId,
- parentOid: FullGitObjectId.nullable().optional(),
- oldPath: z.string().optional()
-})
-
-const GitCommit = WorktreeSelector.extend({
- message: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing commit message'))
-})
-
-const CommitMessageAiSettings = z.object({
- enabled: z.boolean(),
- agentId: z.string().nullable(),
- selectedModelByAgent: z.record(z.string(), z.string()),
- selectedThinkingByModel: z.record(z.string(), z.string()),
- customPrompt: z.string(),
- customAgentCommand: z.string()
-})
-
-const GitGenerateCommitMessage = WorktreeSelector.extend({
- commitMessageAi: CommitMessageAiSettings.optional(),
- agentCmdOverrides: z.record(z.string(), z.string()).optional(),
- enableGitHubAttribution: z.boolean().optional()
-})
-
-const GitBulkPaths = WorktreeSelector.extend({
- filePaths: z.array(z.string().min(1, 'Missing file path'))
-})
-
-const GitPush = WorktreeSelector.extend({
- publish: z.boolean().optional(),
- pushTarget: z.unknown().optional()
-})
-
-const GitRemoteFileUrl = WorktreeSelector.extend({
- relativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing relative path')),
- line: z.number().int().min(1)
-})
+import {
+ GitBranchCompare,
+ GitBranchDiff,
+ GitBulkPaths,
+ GitCommit,
+ GitCommitCompare,
+ GitCommitDiff,
+ GitDiff,
+ GitFilePath,
+ GitGenerateCommitMessage,
+ GitGeneratePullRequestFields,
+ GitHistory,
+ GitPush,
+ GitRemoteFileUrl,
+ GitStatusParams,
+ WorktreeSelector
+} from './git-params'
export const GIT_METHODS: RpcMethod[] = [
defineMethod({
@@ -235,6 +145,44 @@ export const GIT_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.cancelRuntimeGenerateCommitMessage(params.worktree)
}),
+ defineMethod({
+ name: 'git.generatePullRequestFields',
+ params: GitGeneratePullRequestFields,
+ handler: async (params, { runtime }) => {
+ const input = {
+ base: params.base,
+ title: params.title,
+ body: params.body,
+ draft: params.draft
+ }
+ if (
+ params.commitMessageAi === undefined &&
+ params.agentCmdOverrides === undefined &&
+ params.enableGitHubAttribution === undefined
+ ) {
+ return runtime.generateRuntimePullRequestFields(params.worktree, input)
+ }
+ return runtime.generateRuntimePullRequestFields(params.worktree, input, {
+ ...(params.commitMessageAi !== undefined
+ ? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] }
+ : {}),
+ ...(params.agentCmdOverrides !== undefined
+ ? {
+ agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
+ }
+ : {}),
+ ...(params.enableGitHubAttribution !== undefined
+ ? { enableGitHubAttribution: params.enableGitHubAttribution }
+ : {})
+ })
+ }
+ }),
+ defineMethod({
+ name: 'git.cancelGeneratePullRequestFields',
+ params: WorktreeSelector,
+ handler: async (params, { runtime }) =>
+ runtime.cancelRuntimeGeneratePullRequestFields(params.worktree)
+ }),
defineMethod({
name: 'git.stage',
params: GitFilePath,
diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts
index 28730ce1e..63f3d2a8d 100644
--- a/src/main/runtime/rpc/methods/hosted-review.test.ts
+++ b/src/main/runtime/rpc/methods/hosted-review.test.ts
@@ -67,6 +67,7 @@ describe('hosted review RPC methods', () => {
const response = await dispatcher.dispatch(
makeRequest('hostedReview.getCreationEligibility', {
repo: 'repo-1',
+ worktree: 'path:/worktrees/feature',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
@@ -79,6 +80,7 @@ describe('hosted review RPC methods', () => {
expect(runtime.getHostedReviewCreationEligibility).toHaveBeenCalledWith({
repoSelector: 'repo-1',
+ worktreeSelector: 'path:/worktrees/feature',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
@@ -111,6 +113,7 @@ describe('hosted review RPC methods', () => {
const response = await dispatcher.dispatch(
makeRequest('hostedReview.create', {
repo: 'repo-1',
+ worktree: 'path:/worktrees/feature',
provider: 'github',
base: 'main',
head: 'feature/create-pr',
@@ -122,6 +125,7 @@ describe('hosted review RPC methods', () => {
expect(runtime.createHostedReview).toHaveBeenCalledWith({
repoSelector: 'repo-1',
+ worktreeSelector: 'path:/worktrees/feature',
provider: 'github',
base: 'main',
head: 'feature/create-pr',
diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts
index e4d239a38..cd2ccf455 100644
--- a/src/main/runtime/rpc/methods/hosted-review.ts
+++ b/src/main/runtime/rpc/methods/hosted-review.ts
@@ -14,6 +14,7 @@ const HostedReviewForBranch = z.object({
const HostedReviewCreationEligibility = z.object({
repo: requiredString('Missing repo selector'),
+ worktree: z.string().min(1, 'Missing worktree selector').optional(),
branch: requiredString('Missing branch'),
base: z.string().nullable().optional(),
hasUncommittedChanges: z.boolean().optional(),
@@ -29,6 +30,7 @@ const HostedReviewCreationEligibility = z.object({
const HostedReviewCreate = z.object({
repo: requiredString('Missing repo selector'),
+ worktree: z.string().min(1, 'Missing worktree selector').optional(),
provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']),
base: requiredString('Missing base branch'),
head: z.string().optional(),
@@ -58,6 +60,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.getHostedReviewCreationEligibility({
repoSelector: params.repo,
+ worktreeSelector: params.worktree,
branch: params.branch,
base: params.base ?? null,
hasUncommittedChanges: params.hasUncommittedChanges,
@@ -77,6 +80,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.createHostedReview({
repoSelector: params.repo,
+ worktreeSelector: params.worktree,
provider: params.provider,
base: params.base,
head: params.head,
diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts
index 709e254b5..c18f91bad 100644
--- a/src/main/text-generation/commit-message-text-generation.test.ts
+++ b/src/main/text-generation/commit-message-text-generation.test.ts
@@ -6,7 +6,10 @@ import type * as ChildProcess from 'child_process'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import {
+ cancelGenerateCommitMessageLocal,
+ cancelGeneratePullRequestFieldsLocal,
generateCommitMessageFromContext,
+ generatePullRequestFieldsFromContext,
resolveCommitMessageSettings,
trimGeneratedCommitMessage
} from './commit-message-text-generation'
@@ -390,6 +393,94 @@ describe('generateCommitMessageFromContext', () => {
)
})
+ it('keeps local commit-message and pull-request cancellation lanes separate', async () => {
+ const children: {
+ kill: ReturnType
+ listeners: Map void>
+ }[] = []
+ spawnMock.mockImplementation(() => {
+ const listeners = new Map void>()
+ const child = {
+ pid: 123 + children.length,
+ kill: vi.fn(),
+ stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
+ stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
+ stdin: { end: vi.fn() },
+ on: vi.fn((event, callback) => listeners.set(event, callback))
+ }
+ children.push({ kill: child.kill, listeners })
+ return child as never
+ })
+
+ const commit = generateCommitMessageFromContext(
+ {
+ branch: 'main',
+ stagedSummary: 'M\tREADME.md',
+ stagedPatch: '+hello'
+ },
+ {
+ agentId: 'custom',
+ model: '',
+ customAgentCommand: 'agent'
+ },
+ {
+ kind: 'local',
+ cwd: '/repo'
+ }
+ )
+ const pullRequest = generatePullRequestFieldsFromContext(
+ {
+ branch: 'feature/pr-fields',
+ base: 'main',
+ currentTitle: '',
+ currentBody: '',
+ currentDraft: false,
+ commitSummary: '- feat: update README',
+ changeSummary: 'M\tREADME.md',
+ patch: '+hello'
+ },
+ {
+ agentId: 'custom',
+ model: '',
+ customAgentCommand: 'agent'
+ },
+ {
+ kind: 'local',
+ cwd: '/repo'
+ }
+ )
+
+ cancelGenerateCommitMessageLocal('/repo')
+
+ expect(children[0]?.kill).toHaveBeenCalledWith('SIGKILL')
+ expect(children[1]?.kill).not.toHaveBeenCalled()
+
+ children[0]?.listeners.get('close')?.(null)
+ const pullRequestStdout = children[1]?.listeners.get('stdout:data')
+ pullRequestStdout?.(
+ Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}')
+ )
+ children[1]?.listeners.get('close')?.(0)
+
+ await expect(commit).resolves.toEqual({
+ success: false,
+ error: 'Generation canceled.',
+ canceled: true
+ })
+ await expect(pullRequest).resolves.toMatchObject({
+ success: true,
+ fields: {
+ base: 'main',
+ title: 'Update README',
+ body: 'Details',
+ draft: false
+ }
+ })
+
+ cancelGeneratePullRequestFieldsLocal('/repo')
+ expect(children[1]?.kill).not.toHaveBeenCalled()
+ })
+
it('routes Windows batch-script agent commands through cmd.exe', async () => {
const originalComSpec = process.env.ComSpec
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts
index cb6aa1c31..09949e281 100644
--- a/src/main/text-generation/commit-message-text-generation.ts
+++ b/src/main/text-generation/commit-message-text-generation.ts
@@ -9,6 +9,12 @@ import {
type CommitMessageDraftContext,
type GeneratedCommitMessage
} from '../../shared/commit-message-generation'
+import {
+ buildPullRequestFieldsPrompt,
+ parseGeneratedPullRequestFields,
+ type GeneratedPullRequestFields,
+ type PullRequestDraftContext
+} from '../../shared/pull-request-generation'
import {
cleanGeneratedCommitMessage,
extractAgentErrorMessage
@@ -47,6 +53,10 @@ export type GenerateCommitMessageResult =
| { success: true; message: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
+export type GeneratePullRequestFieldsResult =
+ | { success: true; fields: GeneratedPullRequestFields; agentLabel?: string }
+ | { success: false; error: string; canceled?: boolean }
+
export type RemoteCommitMessageExecResult = {
stdout: string
stderr: string
@@ -73,8 +83,8 @@ type ResolveCommitMessageSettingsResult =
| { ok: true; params: GenerateCommitMessageParams }
| { ok: false; error: string }
-type InternalCommitMessageGenerationResult =
- | { success: true; commitMessage: GeneratedCommitMessage; agentLabel?: string }
+type InternalTextGenerationResult =
+ | { success: true; rawOutput: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
export function trimGeneratedCommitMessage(message: string): string {
@@ -190,23 +200,27 @@ function killProcessTree(child: ChildProcess): void {
}
}
-// Keying by `local:${cwd}` keeps local cancellation independent from any SSH
-// worktree with the same remote path.
+type LocalGenerationOperation = 'commit-message' | 'pull-request-fields'
+
+// Keying by operation plus `local:${cwd}` keeps local cancellation independent
+// from SSH worktrees and from other generation features in the same worktree.
const cancelTokensByLane = new Map void>()
-function localLaneKey(cwd: string): string {
- return `local:${cwd}`
+function localLaneKey(operation: LocalGenerationOperation, cwd: string): string {
+ return `${operation}:local:${cwd}`
}
export function cancelGenerateCommitMessageLocal(cwd: string): void {
- cancelTokensByLane.get(localLaneKey(cwd))?.()
+ cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.()
}
async function runLocalPlan(
plan: CommitMessagePlan,
cwd: string,
- env: NodeJS.ProcessEnv | undefined
-): Promise {
+ env: NodeJS.ProcessEnv | undefined,
+ emptyResultName = 'message',
+ operation: LocalGenerationOperation = 'commit-message'
+): Promise {
const { binary, args, stdinPayload, label } = plan
return new Promise((resolve) => {
let child: ChildProcess
@@ -246,20 +260,24 @@ async function runLocalPlan(
let outputLimitExceeded = false
let settled = false
let canceledByUser = false
- const laneKey = localLaneKey(cwd)
- const finalize = (result: InternalCommitMessageGenerationResult): void => {
+ const laneKey = localLaneKey(operation, cwd)
+ let cancelToken: (() => void) | null = null
+ const finalize = (result: InternalTextGenerationResult): void => {
if (settled) {
return
}
settled = true
- cancelTokensByLane.delete(laneKey)
+ if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) {
+ cancelTokensByLane.delete(laneKey)
+ }
resolve(result)
}
- cancelTokensByLane.set(laneKey, () => {
+ cancelToken = () => {
canceledByUser = true
killProcessTree(child)
- })
+ }
+ cancelTokensByLane.set(laneKey, cancelToken)
const timer = setTimeout(() => {
killProcessTree(child)
@@ -313,7 +331,7 @@ async function runLocalPlan(
finalize({ success: false, error: userFacingAgentFailure(label) })
return
}
- finalizeFromAgentOutput({ code, stdout, stderr, label, finalize })
+ finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize })
})
child.stdin?.end(stdinPayload ?? undefined)
@@ -325,9 +343,10 @@ function finalizeFromAgentOutput(args: {
stdout: string
stderr: string
label: string
- finalize: (result: InternalCommitMessageGenerationResult) => void
+ emptyResultName: string
+ finalize: (result: InternalTextGenerationResult) => void
}): void {
- const { code, stdout, stderr, label, finalize } = args
+ const { code, stdout, stderr, label, emptyResultName, finalize } = args
if (code !== 0) {
const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
console.error('[commit-message] Generator failed:', {
@@ -342,21 +361,21 @@ function finalizeFromAgentOutput(args: {
}
const cleaned = cleanGeneratedCommitMessage(stdout)
if (!cleaned) {
- finalize({ success: false, error: `${label} returned an empty message.` })
+ finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` })
return
}
- const commitMessage = splitGeneratedCommitMessage(cleaned)
finalize({
success: true,
- commitMessage,
+ rawOutput: cleaned,
agentLabel: label
})
}
async function runRemotePlan(
plan: CommitMessagePlan,
- target: Extract
-): Promise {
+ target: Extract,
+ emptyResultName = 'message'
+): Promise {
const { binary, label } = plan
let result: RemoteCommitMessageExecResult
try {
@@ -403,20 +422,27 @@ async function runRemotePlan(
stdout: result.stdout,
stderr: result.stderr,
label,
+ emptyResultName,
finalize: resolve
})
})
}
function formatCommitMessageGenerationResult(
- result: InternalCommitMessageGenerationResult
+ result: InternalTextGenerationResult
): GenerateCommitMessageResult {
if (!result.success) {
return result
}
+ let commitMessage: GeneratedCommitMessage
+ try {
+ commitMessage = splitGeneratedCommitMessage(result.rawOutput)
+ } catch {
+ return { success: false, error: 'Generated commit message could not be parsed.' }
+ }
return {
success: true,
- message: trimGeneratedCommitMessage(result.commitMessage.message),
+ message: trimGeneratedCommitMessage(commitMessage.message),
agentLabel: result.agentLabel
}
}
@@ -434,7 +460,47 @@ export async function generateCommitMessageFromContext(
const internalResult =
target.kind === 'remote'
- ? await runRemotePlan(planned.plan, target)
- : await runLocalPlan(planned.plan, target.cwd, target.env)
+ ? await runRemotePlan(planned.plan, target, 'details')
+ : await runLocalPlan(planned.plan, target.cwd, target.env, 'details')
return formatCommitMessageGenerationResult(internalResult)
}
+
+export function cancelGeneratePullRequestFieldsLocal(cwd: string): void {
+ cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.()
+}
+
+function formatPullRequestFieldsGenerationResult(
+ result: InternalTextGenerationResult,
+ context: PullRequestDraftContext
+): GeneratePullRequestFieldsResult {
+ if (!result.success) {
+ return result
+ }
+ try {
+ return {
+ success: true,
+ fields: parseGeneratedPullRequestFields(result.rawOutput, context),
+ agentLabel: result.agentLabel
+ }
+ } catch {
+ return { success: false, error: 'Generated pull request details could not be parsed.' }
+ }
+}
+
+export async function generatePullRequestFieldsFromContext(
+ context: PullRequestDraftContext,
+ params: GenerateCommitMessageParams,
+ target: CommitMessageGenerationTarget
+): Promise {
+ const prompt = buildPullRequestFieldsPrompt(context, params.customPrompt ?? '')
+ const planned = planCommitMessageGeneration(params, prompt)
+ if (!planned.ok) {
+ return { success: false, error: planned.error }
+ }
+
+ const internalResult =
+ target.kind === 'remote'
+ ? await runRemotePlan(planned.plan, target)
+ : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields')
+ return formatPullRequestFieldsGenerationResult(internalResult, context)
+}
diff --git a/src/main/text-generation/pull-request-context.ts b/src/main/text-generation/pull-request-context.ts
new file mode 100644
index 000000000..12aa93846
--- /dev/null
+++ b/src/main/text-generation/pull-request-context.ts
@@ -0,0 +1,64 @@
+import type { PullRequestDraftContext } from '../../shared/pull-request-generation'
+
+const MAX_PULL_REQUEST_CONTEXT_BYTES = 10 * 1024 * 1024
+
+type GitExec = (
+ args: string[],
+ options?: { maxBuffer?: number }
+) => Promise<{ stdout: string; stderr?: string }>
+
+export type PullRequestContextInput = {
+ base: string
+ currentTitle: string
+ currentBody: string
+ currentDraft: boolean
+}
+
+async function safeExec(execGit: GitExec, args: string[]): Promise {
+ try {
+ const { stdout } = await execGit(args, { maxBuffer: MAX_PULL_REQUEST_CONTEXT_BYTES })
+ return stdout.trim()
+ } catch {
+ return ''
+ }
+}
+
+export async function getPullRequestDraftContext(
+ execGit: GitExec,
+ input: PullRequestContextInput
+): Promise {
+ const base = input.base.trim()
+ if (!base || base.startsWith('-')) {
+ return null
+ }
+
+ const [branch, mergeBase] = await Promise.all([
+ safeExec(execGit, ['branch', '--show-current']),
+ safeExec(execGit, ['merge-base', base, 'HEAD'])
+ ])
+ if (!mergeBase) {
+ return null
+ }
+
+ const range = `${mergeBase}..HEAD`
+ const [commitSummary, changeSummary, patch] = await Promise.all([
+ safeExec(execGit, ['log', '--pretty=format:- %s', '--max-count=50', range]),
+ safeExec(execGit, ['diff', '--name-status', range]),
+ safeExec(execGit, ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', range])
+ ])
+
+ if (!commitSummary && !changeSummary && !patch) {
+ return null
+ }
+
+ return {
+ branch: branch || null,
+ base,
+ currentTitle: input.currentTitle,
+ currentBody: input.currentBody,
+ currentDraft: input.currentDraft,
+ commitSummary,
+ changeSummary,
+ patch
+ }
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 12d5a4fc0..ff54e93b0 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1431,6 +1431,25 @@ export type PreloadApi = {
worktreePath: string
connectionId?: string
}) => Promise
+ generatePullRequestFields: (args: {
+ worktreePath: string
+ base: string
+ title: string
+ body: string
+ draft: boolean
+ connectionId?: string
+ }) => Promise<
+ | {
+ success: true
+ fields: { base: string; title: string; body: string; draft: boolean }
+ agentLabel?: string
+ }
+ | { success: false; error: string; canceled?: boolean }
+ >
+ cancelGeneratePullRequestFields: (args: {
+ worktreePath: string
+ connectionId?: string
+ }) => Promise
stage: (args: {
worktreePath: string
filePath: string
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 63936248c..cf75c0818 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1972,6 +1972,18 @@ const api = {
worktreePath: string
connectionId?: string
}): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args),
+ generatePullRequestFields: (args: {
+ worktreePath: string
+ base: string
+ title: string
+ body: string
+ draft: boolean
+ connectionId?: string
+ }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args),
+ cancelGeneratePullRequestFields: (args: {
+ worktreePath: string
+ connectionId?: string
+ }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args),
stage: (args: {
worktreePath: string
filePath: string
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
index 2f8b2b8cd..3071e5d27 100644
--- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
@@ -138,6 +138,7 @@ export default function ChecksPanel(): React.JSX.Element {
// differs from the PR's head ref) resolve via the number-based fallback.
const linkedPR = activeWorktree?.linkedPR ?? null
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
+ const activeWorktreePath = activeWorktree?.path ?? null
const stateRequestKey = repo && branch ? checksPanelAsyncResultKey(repo.id, branch, prNumber) : ''
asyncResultKeyRef.current = stateRequestKey
@@ -160,6 +161,7 @@ export default function ChecksPanel(): React.JSX.Element {
let stale = false
void getHostedReviewCreationEligibility({
repoPath: repo.path,
+ ...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}),
branch,
base: repo.worktreeBaseRef ?? null,
hasUncommittedChanges,
@@ -182,6 +184,7 @@ export default function ChecksPanel(): React.JSX.Element {
stale = true
}
}, [
+ activeWorktreePath,
branch,
getHostedReviewCreationEligibility,
hasUncommittedChanges,
@@ -792,6 +795,8 @@ export default function ChecksPanel(): React.JSX.Element {
open={createPrDialogOpen}
repoId={repo.id}
repoPath={repo.path}
+ worktreeId={activeWorktreeId}
+ worktreePath={activeWorktreePath ?? repo.path}
branch={branch}
eligibility={hostedReviewCreation}
pushBeforeCreate={createPrPushFirst}
diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx
index 318328d00..fe7692ba2 100644
--- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx
+++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
-import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
+import { Check, ChevronsUpDown, Loader2, Sparkles, Square, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
@@ -12,25 +12,22 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
-import {
- getRuntimeRepoBaseRefDefault,
- searchRuntimeRepoBaseRefs
-} from '@/runtime/runtime-repo-client'
import type {
CreateHostedReviewResult,
HostedReviewCreationEligibility
} from '../../../../shared/hosted-review'
-import {
- normalizeHostedReviewBaseRef,
- normalizeHostedReviewHeadRef
-} from '../../../../shared/hosted-review-refs'
+import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
+import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
type CreatePullRequestDialogProps = {
open: boolean
repoId: string
repoPath: string
+ worktreeId: string | null
+ worktreePath: string
branch: string
eligibility: HostedReviewCreationEligibility | null
pushBeforeCreate: boolean
@@ -39,10 +36,6 @@ type CreatePullRequestDialogProps = {
onCreated: (result: { number: number; url: string }) => Promise
}
-function stripBaseRef(ref: string): string {
- return normalizeHostedReviewBaseRef(ref)
-}
-
function formatCreateError(result: CreateHostedReviewResult, pushed: boolean): string {
if (result.ok) {
return ''
@@ -57,6 +50,8 @@ export function CreatePullRequestDialog({
open,
repoId,
repoPath,
+ worktreeId,
+ worktreePath,
branch,
eligibility,
pushBeforeCreate,
@@ -67,93 +62,52 @@ export function CreatePullRequestDialog({
const settings = useAppStore((s) => s.settings)
const createHostedReview = useAppStore((s) => s.createHostedReview)
const submitInFlightRef = useRef(false)
- const initializedFromEligibilityRef = useRef(null)
- const [base, setBase] = useState('')
- const [title, setTitle] = useState('')
- const [body, setBody] = useState('')
- const [draft, setDraft] = useState(false)
- const [baseQuery, setBaseQuery] = useState('')
- const [baseResults, setBaseResults] = useState([])
- const [baseSearchError, setBaseSearchError] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
+ const {
+ aiGenerationEnabled,
+ base,
+ setBase,
+ title,
+ setTitle,
+ body,
+ setBody,
+ draft,
+ setDraft,
+ baseQuery,
+ setBaseQuery,
+ baseResults,
+ setBaseResults,
+ baseSearchError,
+ generating,
+ generateError,
+ generateDisabled,
+ generateDisabledReason,
+ handleGenerate,
+ handleCancelGenerate
+ } = useCreatePullRequestDialogFields({
+ open,
+ repoId,
+ worktreeId,
+ worktreePath,
+ branch,
+ eligibility,
+ settings,
+ submitting
+ })
useEffect(() => {
- if (!open) {
- submitInFlightRef.current = false
- initializedFromEligibilityRef.current = null
- setSubmitting(false)
- setError(null)
+ if (open) {
return
}
- if (!eligibility) {
- return
- }
- const initializationKey = `${repoId}:${branch}`
- if (initializedFromEligibilityRef.current === initializationKey) {
- return
- }
- // Why: eligibility refreshes while the dialog is open; only seed fields
- // once per branch so late refreshes (including populated→null transitions
- // when a background eligibility fetch errors out) do not overwrite user edits.
- initializedFromEligibilityRef.current = initializationKey
- const initialBase = eligibility.defaultBaseRef ?? ''
- setBase(stripBaseRef(initialBase))
- setTitle(eligibility.title ?? '')
- setBody(eligibility.body ?? '')
- setDraft(false)
- setBaseQuery('')
- setBaseResults([])
- setBaseSearchError(null)
- }, [branch, eligibility, open, repoId])
-
- useEffect(() => {
- if (!open || base) {
- return
- }
- let stale = false
- void getRuntimeRepoBaseRefDefault(settings, repoId)
- .then((result) => {
- if (!stale && result.defaultBaseRef) {
- setBase(stripBaseRef(result.defaultBaseRef))
- }
- })
- .catch(() => undefined)
- return () => {
- stale = true
- }
- }, [base, open, repoId, settings])
-
- useEffect(() => {
- if (!open || baseQuery.trim().length < 2) {
- setBaseResults([])
- setBaseSearchError(null)
- return
- }
- let stale = false
- const timer = window.setTimeout(() => {
- void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20)
- .then((results) => {
- if (!stale) {
- setBaseResults(results.map(stripBaseRef))
- setBaseSearchError(null)
- }
- })
- .catch(() => {
- if (!stale) {
- setBaseResults([])
- setBaseSearchError('Branch discovery failed.')
- }
- })
- }, 200)
- return () => {
- stale = true
- window.clearTimeout(timer)
- }
- }, [baseQuery, open, repoId, settings])
+ submitInFlightRef.current = false
+ setSubmitting(false)
+ setError(null)
+ }, [open])
const submitDisabled =
submitting ||
+ generating ||
title.trim().length === 0 ||
base.trim().length === 0 ||
stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase()
@@ -181,7 +135,8 @@ export function CreatePullRequestDialog({
head: normalizeHostedReviewHeadRef(branch),
title: title.trim(),
body,
- draft
+ draft,
+ worktreePath
})
if (result.ok) {
toast.success(`Pull request #${result.number} created`, {
@@ -228,7 +183,8 @@ export function CreatePullRequestDialog({
pushBeforeCreate,
repoPath,
submitDisabled,
- title
+ title,
+ worktreePath
])
const handleOpenChange = useCallback(
@@ -247,7 +203,47 @@ export function CreatePullRequestDialog({
) : null}
+ {generateError ? {generateError}
: null}
{error ? {error}
: null}
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 4d86a39ad..afcc064b9 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -700,6 +700,7 @@ function SourceControlInner(): React.JSX.Element {
let stale = false
void getHostedReviewCreationEligibility({
repoPath: activeRepo.path,
+ ...(worktreePath ? { worktreePath } : {}),
branch: branchName,
base: effectiveBaseRef ?? null,
hasUncommittedChanges: hasUncommittedEntries,
@@ -735,7 +736,8 @@ function SourceControlInner(): React.JSX.Element {
linkedGitLabMR,
remoteStatus?.ahead,
remoteStatus?.behind,
- remoteStatus?.hasUpstream
+ remoteStatus?.hasUpstream,
+ worktreePath
])
const grouped = useMemo(() => {
@@ -1306,7 +1308,9 @@ function SourceControlInner(): React.JSX.Element {
prState: hostedReview?.state ?? null,
isPRStateLoading: isHostedReviewStateLoading,
inFlightRemoteOpKind,
- hostedReviewCreation
+ hostedReviewCreation,
+ branchCommitsAhead:
+ branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined
}),
[
commitMessage,
@@ -1319,6 +1323,8 @@ function SourceControlInner(): React.JSX.Element {
hostedReviewCreation,
isHostedReviewStateLoading,
hostedReview?.state,
+ branchSummary?.commitsAhead,
+ branchSummary?.status,
remoteStatus,
unresolvedConflicts.length
]
@@ -1338,7 +1344,9 @@ function SourceControlInner(): React.JSX.Element {
prState: hostedReview?.state ?? null,
isPRStateLoading: isHostedReviewStateLoading,
inFlightRemoteOpKind,
- hostedReviewCreation
+ hostedReviewCreation,
+ branchCommitsAhead:
+ branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined
}),
[
commitMessage,
@@ -1351,6 +1359,8 @@ function SourceControlInner(): React.JSX.Element {
hostedReviewCreation,
isHostedReviewStateLoading,
hostedReview?.state,
+ branchSummary?.commitsAhead,
+ branchSummary?.status,
remoteStatus,
unresolvedConflicts.length
]
@@ -2395,6 +2405,8 @@ function SourceControlInner(): React.JSX.Element {
open={createPrDialogOpen}
repoId={activeRepo.id}
repoPath={activeRepo.path}
+ worktreeId={currentWorktreeId}
+ worktreePath={activeWorktree.path}
branch={branchName}
eligibility={hostedReviewCreation}
pushBeforeCreate={createPrPushFirst}
diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts
index 9cfcca3ed..33e361151 100644
--- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts
@@ -190,6 +190,21 @@ describe('resolveDropdownItems', () => {
expect(byKind.publish.disabled).toBe(false)
})
+ it('does not show Publish Branch when an unpublished branch has no commits ahead', () => {
+ const items = resolveDropdownItems(
+ inputs({
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
+ branchCommitsAhead: 0
+ })
+ )
+ const byKind = Object.fromEntries(
+ items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
+ )
+ expect(byKind.publish.label).toBe('No Branch Changes')
+ expect(byKind.publish.title).toBe('Nothing to publish')
+ expect(byKind.publish.disabled).toBe(true)
+ })
+
it('does not mention Publish Branch when the linked PR is already merged', () => {
const items = resolveDropdownItems(
inputs({
diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
index eeb116216..1231ca659 100644
--- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
@@ -65,7 +65,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
upstreamStatus,
prState,
isPRStateLoading,
- hostedReviewCreation
+ hostedReviewCreation,
+ branchCommitsAhead
} = inputs
const hasStaged = stagedCount > 0
@@ -80,6 +81,7 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
const hasUpstream = upstreamStatus?.hasUpstream ?? false
const publishBlockedByMergedPR = !hasUpstream && prState === 'merged'
const publishBlockedByPRLoading = !hasUpstream && !!isPRStateLoading
+ const publishBlockedByNoBranchCommits = !hasUpstream && branchCommitsAhead === 0
const ahead = upstreamStatus?.ahead ?? 0
const behind = upstreamStatus?.behind ?? 0
@@ -229,22 +231,30 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
const publishItem: DropdownItem = {
kind: 'publish',
- label: publishBlockedByMergedPR || publishBlockedByPRLoading ? 'PR Status' : 'Publish Branch',
+ label:
+ publishBlockedByMergedPR || publishBlockedByPRLoading
+ ? 'PR Status'
+ : publishBlockedByNoBranchCommits
+ ? 'No Branch Changes'
+ : 'Publish Branch',
title: upstreamLoading
? 'Checking branch status…'
: publishBlockedByPRLoading
? 'Checking PR status…'
: publishBlockedByMergedPR
? 'PR is already merged'
- : hasUpstream
- ? 'Branch is already published'
- : 'Publish this branch to origin',
+ : publishBlockedByNoBranchCommits
+ ? 'Nothing to publish'
+ : hasUpstream
+ ? 'Branch is already published'
+ : 'Publish this branch to origin',
disabled:
globalBusy ||
upstreamLoading ||
hasUpstream ||
publishBlockedByPRLoading ||
- publishBlockedByMergedPR
+ publishBlockedByMergedPR ||
+ publishBlockedByNoBranchCommits
}
const createBlockedHint = (() => {
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts
index dbec18d24..6fec64683 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts
@@ -174,7 +174,7 @@ describe('resolvePrimaryAction', () => {
it('returns Publish Branch on a clean tree when no upstream exists', () => {
const result = resolvePrimaryAction(
- inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } })
+ inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 1 })
)
expect(result).toEqual({
kind: 'publish',
@@ -184,6 +184,18 @@ describe('resolvePrimaryAction', () => {
})
})
+ it('does not offer Publish Branch when an unpublished branch has no commits ahead', () => {
+ const result = resolvePrimaryAction(
+ inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 0 })
+ )
+ expect(result).toEqual({
+ kind: 'commit',
+ label: 'Commit',
+ title: 'Nothing to commit. Branch has no changes to publish.',
+ disabled: true
+ })
+ })
+
it.each([
[{ prState: 'merged' as const }, 'Nothing to commit. PR is already merged.'],
[{ isPRStateLoading: true }, 'Checking PR status…']
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
index b5266a5e1..e6bc470dc 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
@@ -56,6 +56,10 @@ export type PrimaryActionInputs = {
// stale label that no longer matches what the slice is doing.
inFlightRemoteOpKind?: RemoteOpKind | null
hostedReviewCreation?: HostedReviewCreationEligibility | null
+ // Why: an unpublished branch is only worth publishing when it actually
+ // carries commits beyond the compare base. Undefined preserves the old
+ // behavior while the branch compare request is still unavailable/loading.
+ branchCommitsAhead?: number
}
const PRIMARY_LABEL_BY_KIND: Record, string> = {
@@ -112,7 +116,8 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
prState,
isPRStateLoading,
inFlightRemoteOpKind,
- hostedReviewCreation
+ hostedReviewCreation,
+ branchCommitsAhead
} = inputs
// 1. Commit in flight — lock the primary no matter what else is true.
@@ -245,6 +250,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
if (!upstreamStatus.hasUpstream) {
+ if (branchCommitsAhead === 0) {
+ return {
+ kind: 'commit',
+ label: 'Commit',
+ title: 'Nothing to commit. Branch has no changes to publish.',
+ disabled: true
+ }
+ }
+
if (isPRStateLoading) {
return {
kind: 'commit',
diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
new file mode 100644
index 000000000..37bee7d33
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
@@ -0,0 +1,299 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { getConnectionId } from '@/lib/connection-context'
+import { useAppStore, type AppState } from '@/store'
+import {
+ cancelRuntimeGeneratePullRequestFields,
+ generateRuntimePullRequestFields
+} from '@/runtime/runtime-git-client'
+import {
+ getRuntimeRepoBaseRefDefault,
+ searchRuntimeRepoBaseRefs
+} from '@/runtime/runtime-repo-client'
+import {
+ isCustomAgentId,
+ resolveCommitMessageAgentChoice
+} from '../../../../shared/commit-message-agent-spec'
+import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
+import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
+
+type UseCreatePullRequestDialogFieldsOptions = {
+ open: boolean
+ repoId: string
+ worktreeId: string | null
+ worktreePath: string
+ branch: string
+ eligibility: HostedReviewCreationEligibility | null
+ settings: AppState['settings']
+ submitting: boolean
+}
+
+type GenerationSeed = {
+ requestId: number
+ base: string
+ title: string
+ body: string
+ draft: boolean
+}
+
+export function stripBaseRef(ref: string): string {
+ return normalizeHostedReviewBaseRef(ref)
+}
+
+export function useCreatePullRequestDialogFields({
+ open,
+ repoId,
+ worktreeId,
+ worktreePath,
+ branch,
+ eligibility,
+ settings,
+ submitting
+}: UseCreatePullRequestDialogFieldsOptions) {
+ const commitMessageAi = settings?.commitMessageAi
+ const effectiveCommitMessageAgentId = resolveCommitMessageAgentChoice(
+ commitMessageAi?.agentId,
+ settings?.defaultTuiAgent
+ )
+ const initializedFromEligibilityRef = useRef(null)
+ const generateInFlightRef = useRef(false)
+ const generationRequestIdRef = useRef(0)
+ const generationSeedRef = useRef(null)
+ const latestFieldsRef = useRef({
+ base: '',
+ title: '',
+ body: '',
+ draft: false
+ })
+ const [base, setBase] = useState('')
+ const [title, setTitle] = useState('')
+ const [body, setBody] = useState('')
+ const [draft, setDraft] = useState(false)
+ const [baseQuery, setBaseQuery] = useState('')
+ const [baseResults, setBaseResults] = useState([])
+ const [baseSearchError, setBaseSearchError] = useState(null)
+ const [generating, setGenerating] = useState(false)
+ const [generateError, setGenerateError] = useState(null)
+
+ useEffect(() => {
+ latestFieldsRef.current = { base, title, body, draft }
+ }, [base, body, draft, title])
+
+ useEffect(() => {
+ if (!open) {
+ generationRequestIdRef.current += 1
+ if (generateInFlightRef.current && worktreePath) {
+ const connectionId = getConnectionId(worktreeId) ?? undefined
+ void cancelRuntimeGeneratePullRequestFields({
+ settings,
+ worktreeId,
+ worktreePath,
+ connectionId
+ })
+ }
+ generateInFlightRef.current = false
+ generationSeedRef.current = null
+ initializedFromEligibilityRef.current = null
+ setGenerating(false)
+ setGenerateError(null)
+ return
+ }
+ if (!eligibility) {
+ return
+ }
+ const initializationKey = `${repoId}:${branch}`
+ if (initializedFromEligibilityRef.current === initializationKey) {
+ return
+ }
+ // Why: eligibility refreshes while the dialog is open; only seed fields
+ // once per branch so late refreshes do not overwrite user edits.
+ initializedFromEligibilityRef.current = initializationKey
+ const initialBase = eligibility.defaultBaseRef ?? ''
+ setBase(stripBaseRef(initialBase))
+ setTitle(eligibility.title ?? '')
+ setBody(eligibility.body ?? '')
+ setDraft(false)
+ setBaseQuery('')
+ setBaseResults([])
+ setBaseSearchError(null)
+ setGenerateError(null)
+ }, [branch, eligibility, open, repoId, settings, worktreeId, worktreePath])
+
+ useEffect(() => {
+ if (!open || base) {
+ return
+ }
+ let stale = false
+ void getRuntimeRepoBaseRefDefault(settings, repoId)
+ .then((result) => {
+ if (!stale && result.defaultBaseRef) {
+ setBase(stripBaseRef(result.defaultBaseRef))
+ }
+ })
+ .catch(() => undefined)
+ return () => {
+ stale = true
+ }
+ }, [base, open, repoId, settings])
+
+ useEffect(() => {
+ if (!open || baseQuery.trim().length < 2) {
+ setBaseResults([])
+ setBaseSearchError(null)
+ return
+ }
+ let stale = false
+ const timer = window.setTimeout(() => {
+ void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20)
+ .then((results) => {
+ if (!stale) {
+ setBaseResults(results.map(stripBaseRef))
+ setBaseSearchError(null)
+ }
+ })
+ .catch(() => {
+ if (!stale) {
+ setBaseResults([])
+ setBaseSearchError('Branch discovery failed.')
+ }
+ })
+ }, 200)
+ return () => {
+ stale = true
+ window.clearTimeout(timer)
+ }
+ }, [baseQuery, open, repoId, settings])
+
+ let generateDisabledReason: string | undefined
+ if (submitting) {
+ generateDisabledReason = 'Create PR in progress...'
+ } else if (!commitMessageAi?.enabled) {
+ generateDisabledReason = 'Enable AI commit messages in Settings -> Git.'
+ } else if (!effectiveCommitMessageAgentId) {
+ generateDisabledReason = 'Pick an agent in Settings -> Git -> AI Commit Messages.'
+ } else if (isCustomAgentId(effectiveCommitMessageAgentId)) {
+ const command = commitMessageAi.customAgentCommand?.trim() ?? ''
+ if (!command) {
+ generateDisabledReason =
+ 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.'
+ }
+ } else if (!base.trim()) {
+ generateDisabledReason = 'Choose a base branch before generating.'
+ }
+ const generateDisabled = !generating && Boolean(generateDisabledReason)
+
+ const handleGenerate = useCallback(async (): Promise => {
+ if (!worktreePath || !base.trim() || generateInFlightRef.current || generateDisabled) {
+ return
+ }
+ const requestId = generationRequestIdRef.current + 1
+ generationRequestIdRef.current = requestId
+ const seed = { requestId, base, title, body, draft }
+ generationSeedRef.current = seed
+ generateInFlightRef.current = true
+ setGenerating(true)
+ setGenerateError(null)
+ try {
+ const connectionId = getConnectionId(worktreeId) ?? undefined
+ const result = await generateRuntimePullRequestFields(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId,
+ worktreePath,
+ connectionId
+ },
+ {
+ base: stripBaseRef(base.trim()),
+ title,
+ body,
+ draft
+ }
+ )
+ if (generationRequestIdRef.current !== requestId) {
+ return
+ }
+ if (!result.success) {
+ if (result.canceled) {
+ setGenerateError(null)
+ return
+ }
+ setGenerateError(result.error)
+ return
+ }
+
+ const currentSeed = generationSeedRef.current
+ const latestFields = latestFieldsRef.current
+ if (
+ !currentSeed ||
+ currentSeed.requestId !== requestId ||
+ currentSeed.base !== latestFields.base ||
+ currentSeed.title !== latestFields.title ||
+ currentSeed.body !== latestFields.body ||
+ currentSeed.draft !== latestFields.draft
+ ) {
+ setGenerateError('Fields changed while generating. Run generate again for a fresh draft.')
+ return
+ }
+ setBase(stripBaseRef(result.fields.base))
+ setBaseQuery('')
+ setBaseResults([])
+ setTitle(result.fields.title)
+ setBody(result.fields.body)
+ setDraft(result.fields.draft)
+ setGenerateError(null)
+ } catch (error) {
+ if (generationRequestIdRef.current !== requestId) {
+ return
+ }
+ setGenerateError(
+ error instanceof Error ? error.message : 'Failed to generate pull request details'
+ )
+ } finally {
+ if (generationRequestIdRef.current === requestId) {
+ generateInFlightRef.current = false
+ generationSeedRef.current = null
+ setGenerating(false)
+ }
+ }
+ }, [base, body, draft, generateDisabled, title, worktreeId, worktreePath])
+
+ const handleCancelGenerate = useCallback((): void => {
+ if (!worktreePath || !generateInFlightRef.current) {
+ return
+ }
+ generationRequestIdRef.current += 1
+ generateInFlightRef.current = false
+ generationSeedRef.current = null
+ setGenerating(false)
+ setGenerateError(null)
+ const connectionId = getConnectionId(worktreeId) ?? undefined
+ void cancelRuntimeGeneratePullRequestFields({
+ settings: useAppStore.getState().settings,
+ worktreeId,
+ worktreePath,
+ connectionId
+ })
+ }, [worktreeId, worktreePath])
+
+ return {
+ aiGenerationEnabled: commitMessageAi?.enabled === true,
+ base,
+ setBase,
+ title,
+ setTitle,
+ body,
+ setBody,
+ draft,
+ setDraft,
+ baseQuery,
+ setBaseQuery,
+ baseResults,
+ setBaseResults,
+ baseSearchError,
+ generating,
+ generateError,
+ generateDisabled,
+ generateDisabledReason,
+ handleGenerate,
+ handleCancelGenerate
+ }
+}
diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts
index d11f78191..ebd64b5e0 100644
--- a/src/renderer/src/runtime/runtime-git-client.ts
+++ b/src/renderer/src/runtime/runtime-git-client.ts
@@ -18,6 +18,14 @@ export type RuntimeGenerateCommitMessageResult =
| { success: true; message: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
+export type RuntimeGeneratePullRequestFieldsResult =
+ | {
+ success: true
+ fields: { base: string; title: string; body: string; draft: boolean }
+ agentLabel?: string
+ }
+ | { success: false; error: string; canceled?: boolean }
+
type RuntimeGitSettings = Pick &
Partial>
@@ -356,6 +364,49 @@ export async function cancelRuntimeGenerateCommitMessage(
)
}
+export async function generateRuntimePullRequestFields(
+ context: RuntimeGitContext,
+ input: { base: string; title: string; body: string; draft: boolean }
+): Promise {
+ const target = getActiveRuntimeTarget(context.settings)
+ if (target.kind === 'local' || !context.worktreeId) {
+ return window.api.git.generatePullRequestFields({
+ worktreePath: context.worktreePath,
+ connectionId: context.connectionId,
+ ...input
+ }) as Promise
+ }
+ return callRuntimeRpc(
+ target,
+ 'git.generatePullRequestFields',
+ {
+ worktree: context.worktreeId,
+ ...input,
+ ...getRuntimeCommitMessageSettings(context.settings)
+ },
+ { timeoutMs: 75_000 }
+ )
+}
+
+export async function cancelRuntimeGeneratePullRequestFields(
+ context: RuntimeGitContext
+): Promise {
+ const target = getActiveRuntimeTarget(context.settings)
+ if (target.kind === 'local' || !context.worktreeId) {
+ await window.api.git.cancelGeneratePullRequestFields({
+ worktreePath: context.worktreePath,
+ connectionId: context.connectionId
+ })
+ return
+ }
+ await callRuntimeRpc(
+ target,
+ 'git.cancelGeneratePullRequestFields',
+ { worktree: context.worktreeId },
+ { timeoutMs: 5_000 }
+ )
+}
+
export async function stageRuntimeGitPath(
context: RuntimeGitContext,
filePath: string
diff --git a/src/renderer/src/store/slices/hosted-review-cache.test.ts b/src/renderer/src/store/slices/hosted-review-cache.test.ts
new file mode 100644
index 000000000..46b64cdf9
--- /dev/null
+++ b/src/renderer/src/store/slices/hosted-review-cache.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { create } from 'zustand'
+import type { AppState } from '../types'
+import { createHostedReviewSlice, getHostedReviewCacheKey } from './hosted-review'
+import type { HostedReviewInfo } from '../../../../shared/hosted-review'
+
+const runtimeRpc = vi.hoisted(() => ({
+ callRuntimeRpc: vi.fn()
+}))
+
+vi.mock('@/runtime/runtime-rpc-client', () => ({
+ callRuntimeRpc: runtimeRpc.callRuntimeRpc,
+ getActiveRuntimeTarget: (
+ settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined
+ ) => {
+ const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
+ return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }
+ }
+}))
+
+const mockApi = {
+ hostedReview: {
+ forBranch: vi.fn(),
+ getCreationEligibility: vi.fn(),
+ create: vi.fn()
+ }
+}
+
+globalThis.window = { api: mockApi } as never
+
+function makeStore(settings: AppState['settings'] = null) {
+ return create<
+ Pick<
+ AppState,
+ | 'hostedReviewCache'
+ | 'fetchHostedReviewForBranch'
+ | 'getHostedReviewCreationEligibility'
+ | 'createHostedReview'
+ | 'settings'
+ | 'repos'
+ >
+ >()((...args) => ({
+ settings,
+ repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]],
+ ...createHostedReviewSlice(...(args as Parameters))
+ }))
+}
+
+const review: HostedReviewInfo = {
+ provider: 'gitlab',
+ number: 5,
+ title: 'Shared MR status',
+ state: 'open',
+ url: 'https://gitlab.com/g/p/-/merge_requests/5',
+ status: 'success',
+ updatedAt: '2026-05-10T00:00:00.000Z',
+ mergeable: 'MERGEABLE'
+}
+
+describe('hosted review cache revalidation', () => {
+ beforeEach(() => {
+ mockApi.hostedReview.forBranch.mockReset()
+ mockApi.hostedReview.getCreationEligibility.mockReset()
+ mockApi.hostedReview.create.mockReset()
+ runtimeRpc.callRuntimeRpc.mockReset()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => {
+ let resolveLinkedLookup: (value: typeof review) => void = () => {}
+ const linkedLookup = new Promise((resolve) => {
+ resolveLinkedLookup = resolve
+ })
+ mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup)
+ const store = makeStore()
+
+ await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
+ null
+ )
+
+ const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
+ linkedGitHubPR: 42
+ })
+ const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
+ linkedGitHubPR: 42
+ })
+
+ expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
+ resolveLinkedLookup(review)
+ await expect(firstLinkedFetch).resolves.toEqual(review)
+ await expect(secondLinkedFetch).resolves.toEqual(review)
+ })
+
+ it('serves stale hosted review metadata while revalidating in the background', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const updatedReview: HostedReviewInfo = {
+ ...review,
+ title: 'Updated linked PR status',
+ status: 'failure',
+ updatedAt: '2026-05-10T00:01:01.000Z'
+ }
+ let resolveRefresh: (value: typeof updatedReview) => void = () => {}
+ const refresh = new Promise((resolve) => {
+ resolveRefresh = resolve
+ })
+ mockApi.hostedReview.forBranch
+ .mockResolvedValueOnce(review)
+ .mockReturnValueOnce(refresh as Promise)
+ const store = makeStore()
+
+ await expect(
+ store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
+ linkedGitHubPR: 42
+ })
+ ).resolves.toEqual(review)
+ vi.setSystemTime(60_001)
+ await expect(
+ store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
+ linkedGitHubPR: 42,
+ staleWhileRevalidate: true
+ })
+ ).resolves.toEqual(review)
+ await expect(
+ store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
+ linkedGitHubPR: 42,
+ staleWhileRevalidate: true
+ })
+ ).resolves.toEqual(review)
+
+ expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
+ const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr')
+ expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review)
+
+ resolveRefresh(updatedReview)
+ await refresh
+ await Promise.resolve()
+
+ expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview)
+ })
+
+ it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const linkedReview: HostedReviewInfo = {
+ ...review,
+ provider: 'github',
+ number: 42,
+ title: 'Exact linked PR',
+ url: 'https://github.com/acme/orca/pull/42'
+ }
+ mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview)
+ const store = makeStore()
+
+ await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
+ review
+ )
+ vi.setSystemTime(60_001)
+ await expect(
+ store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
+ linkedGitHubPR: 42,
+ staleWhileRevalidate: true
+ })
+ ).resolves.toEqual(linkedReview)
+
+ expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts
index 1cfeedd47..0086cddaa 100644
--- a/src/renderer/src/store/slices/hosted-review.test.ts
+++ b/src/renderer/src/store/slices/hosted-review.test.ts
@@ -1,11 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
-import {
- createHostedReviewSlice,
- getHostedReviewCacheKey,
- refreshHostedReviewCard
-} from './hosted-review'
+import { createHostedReviewSlice, refreshHostedReviewCard } from './hosted-review'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
const runtimeRpc = vi.hoisted(() => ({
@@ -24,19 +20,30 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
const mockApi = {
hostedReview: {
- forBranch: vi.fn()
+ forBranch: vi.fn(),
+ getCreationEligibility: vi.fn(),
+ create: vi.fn()
}
}
globalThis.window = { api: mockApi } as never
function makeStore(settings: AppState['settings'] = null) {
- return create>()(
- (...args) => ({
- settings,
- ...createHostedReviewSlice(...(args as Parameters))
- })
- )
+ return create<
+ Pick<
+ AppState,
+ | 'hostedReviewCache'
+ | 'fetchHostedReviewForBranch'
+ | 'getHostedReviewCreationEligibility'
+ | 'createHostedReview'
+ | 'settings'
+ | 'repos'
+ >
+ >()((...args) => ({
+ settings,
+ repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]],
+ ...createHostedReviewSlice(...(args as Parameters))
+ }))
}
const review: HostedReviewInfo = {
@@ -53,6 +60,8 @@ const review: HostedReviewInfo = {
describe('hosted review slice', () => {
beforeEach(() => {
mockApi.hostedReview.forBranch.mockReset()
+ mockApi.hostedReview.getCreationEligibility.mockReset()
+ mockApi.hostedReview.create.mockReset()
runtimeRpc.callRuntimeRpc.mockReset()
})
@@ -115,6 +124,100 @@ describe('hosted review slice', () => {
)
})
+ it('forwards the selected worktree path when creating a local pull request', async () => {
+ mockApi.hostedReview.create.mockResolvedValueOnce({
+ ok: true,
+ number: 12,
+ url: 'https://github.com/acme/orca/pull/12'
+ })
+ const store = makeStore()
+
+ await expect(
+ store.getState().createHostedReview('/repo', {
+ provider: 'github',
+ base: 'main',
+ head: 'feature/create-pr',
+ title: 'Create PR',
+ worktreePath: '/worktrees/feature'
+ })
+ ).resolves.toMatchObject({ ok: true, number: 12 })
+
+ expect(mockApi.hostedReview.create).toHaveBeenCalledWith({
+ repoPath: '/repo',
+ connectionId: null,
+ provider: 'github',
+ base: 'main',
+ head: 'feature/create-pr',
+ title: 'Create PR',
+ worktreePath: '/worktrees/feature'
+ })
+ })
+
+ it('uses the selected worktree selector for runtime pull request creation', async () => {
+ runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({
+ ok: true,
+ number: 12,
+ url: 'https://github.com/acme/orca/pull/12'
+ })
+ const store = makeStore({
+ activeRuntimeEnvironmentId: 'env-win'
+ } as AppState['settings'])
+
+ await store.getState().createHostedReview('/repo', {
+ provider: 'github',
+ base: 'main',
+ head: 'feature/create-pr',
+ title: 'Create PR',
+ worktreePath: 'C:\\worktrees\\feature'
+ })
+
+ expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
+ { kind: 'environment', environmentId: 'env-win' },
+ 'hostedReview.create',
+ {
+ repo: 'repo-1',
+ worktree: 'path:C:\\worktrees\\feature',
+ provider: 'github',
+ base: 'main',
+ head: 'feature/create-pr',
+ title: 'Create PR'
+ },
+ { timeoutMs: 60_000 }
+ )
+ })
+
+ it('uses the selected worktree selector for runtime pull request creation eligibility', async () => {
+ runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({
+ provider: 'github',
+ review: null,
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null
+ })
+ const store = makeStore({
+ activeRuntimeEnvironmentId: 'env-win'
+ } as AppState['settings'])
+
+ await store.getState().getHostedReviewCreationEligibility({
+ repoPath: '/repo',
+ worktreePath: 'C:\\worktrees\\feature',
+ branch: 'feature/create-pr',
+ base: 'main'
+ })
+
+ expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
+ { kind: 'environment', environmentId: 'env-win' },
+ 'hostedReview.getCreationEligibility',
+ {
+ repo: 'repo-1',
+ worktree: 'path:C:\\worktrees\\feature',
+ branch: 'feature/create-pr',
+ base: 'main'
+ },
+ { timeoutMs: 30_000 }
+ )
+ })
+
it('forces card refresh with repo-scoped identity and linked review ids', async () => {
const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null)
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
@@ -187,104 +290,4 @@ describe('hosted review slice', () => {
await expect(plainFetch).resolves.toBeNull()
await expect(linkedFetch).resolves.toEqual(review)
})
-
- it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => {
- let resolveLinkedLookup: (value: typeof review) => void = () => {}
- const linkedLookup = new Promise((resolve) => {
- resolveLinkedLookup = resolve
- })
- mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup)
- const store = makeStore()
-
- await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
- null
- )
-
- const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
- linkedGitHubPR: 42
- })
- const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
- linkedGitHubPR: 42
- })
-
- expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
- resolveLinkedLookup(review)
- await expect(firstLinkedFetch).resolves.toEqual(review)
- await expect(secondLinkedFetch).resolves.toEqual(review)
- })
-
- it('serves stale hosted review metadata while revalidating in the background', async () => {
- vi.useFakeTimers()
- vi.setSystemTime(0)
- const updatedReview: HostedReviewInfo = {
- ...review,
- title: 'Updated linked PR status',
- status: 'failure',
- updatedAt: '2026-05-10T00:01:01.000Z'
- }
- let resolveRefresh: (value: typeof updatedReview) => void = () => {}
- const refresh = new Promise((resolve) => {
- resolveRefresh = resolve
- })
- mockApi.hostedReview.forBranch
- .mockResolvedValueOnce(review)
- .mockReturnValueOnce(refresh as Promise)
- const store = makeStore()
-
- await expect(
- store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
- linkedGitHubPR: 42
- })
- ).resolves.toEqual(review)
- vi.setSystemTime(60_001)
- await expect(
- store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
- linkedGitHubPR: 42,
- staleWhileRevalidate: true
- })
- ).resolves.toEqual(review)
- await expect(
- store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
- linkedGitHubPR: 42,
- staleWhileRevalidate: true
- })
- ).resolves.toEqual(review)
-
- expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
- const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr')
- expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review)
-
- resolveRefresh(updatedReview)
- await refresh
- await Promise.resolve()
-
- expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview)
- })
-
- it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => {
- vi.useFakeTimers()
- vi.setSystemTime(0)
- const linkedReview: HostedReviewInfo = {
- ...review,
- provider: 'github',
- number: 42,
- title: 'Exact linked PR',
- url: 'https://github.com/acme/orca/pull/42'
- }
- mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview)
- const store = makeStore()
-
- await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
- review
- )
- vi.setSystemTime(60_001)
- await expect(
- store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
- linkedGitHubPR: 42,
- staleWhileRevalidate: true
- })
- ).resolves.toEqual(linkedReview)
-
- expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
- })
})
diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts
index 085b8ebc2..1a5dd5f84 100644
--- a/src/renderer/src/store/slices/hosted-review.ts
+++ b/src/renderer/src/store/slices/hosted-review.ts
@@ -134,12 +134,16 @@ export const createHostedReviewSlice: StateCreator candidate.path === args.repoPath)
- const { repoPath: _repoPath, ...runtimeArgs } = args
+ const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args
void _repoPath
return callRuntimeRpc(
target,
'hostedReview.getCreationEligibility',
- { repo: repo?.id ?? args.repoPath, ...runtimeArgs },
+ {
+ repo: repo?.id ?? args.repoPath,
+ ...(worktreePath ? { worktree: `path:${worktreePath}` } : {}),
+ ...runtimeArgs
+ },
{ timeoutMs: 30_000 }
)
}
@@ -155,10 +159,15 @@ export const createHostedReviewSlice: StateCreator candidate.path === repoPath)
+ const { worktreePath, ...runtimeInput } = input
return callRuntimeRpc(
target,
'hostedReview.create',
- { repo: repo?.id ?? repoPath, ...input },
+ {
+ repo: repo?.id ?? repoPath,
+ ...(worktreePath ? { worktree: `path:${worktreePath}` } : {}),
+ ...runtimeInput
+ },
{ timeoutMs: 60_000 }
)
}
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index f51f9a024..27bee11fa 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -553,6 +553,11 @@ function createGitApi(): NonNullable['git']> {
error: 'Commit message generation is unavailable in the web client.'
}),
cancelGenerateCommitMessage: () => Promise.resolve(),
+ generatePullRequestFields: async () => ({
+ success: false,
+ error: 'Pull request detail generation is unavailable in the web client.'
+ }),
+ cancelGeneratePullRequestFields: () => Promise.resolve(),
stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath),
bulkStage: async ({ worktreePath, filePaths }) =>
mutateGitPaths('git.bulkStage', worktreePath, filePaths),
diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts
index 7c86785b4..3424b405d 100644
--- a/src/shared/hosted-review.ts
+++ b/src/shared/hosted-review.ts
@@ -46,6 +46,7 @@ export type CreateHostedReviewInput = {
title: string
body?: string
draft?: boolean
+ worktreePath?: string
}
export type CreateHostedReviewArgs = CreateHostedReviewInput & {
@@ -108,6 +109,7 @@ export type HostedReviewCreationEligibility = {
export type HostedReviewCreationEligibilityArgs = {
repoPath: string
+ worktreePath?: string
connectionId?: string | null
branch: string
base?: string | null
diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts
new file mode 100644
index 000000000..934be1376
--- /dev/null
+++ b/src/shared/pull-request-generation.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildPullRequestFieldsPrompt,
+ parseGeneratedPullRequestFields,
+ type PullRequestDraftContext
+} from './pull-request-generation'
+
+const context: PullRequestDraftContext = {
+ branch: 'feature/pr-details',
+ base: 'main',
+ currentTitle: 'Feature pr details',
+ currentBody: '- Add form',
+ currentDraft: false,
+ commitSummary: '- feat: add generated PR details',
+ changeSummary: 'M\tsrc/file.ts',
+ patch: 'diff --git a/src/file.ts b/src/file.ts\n+export const value = true'
+}
+
+describe('buildPullRequestFieldsPrompt', () => {
+ it('asks for compact JSON and includes PR context', () => {
+ const prompt = buildPullRequestFieldsPrompt(context, 'Use conventional PR titles.')
+
+ expect(prompt).toContain('Return ONLY compact JSON')
+ expect(prompt).toContain('Head branch: feature/pr-details')
+ expect(prompt).toContain('Current base: main')
+ expect(prompt).toContain('Additional instructions from user:')
+ expect(prompt).toContain('Use conventional PR titles.')
+ })
+})
+
+describe('parseGeneratedPullRequestFields', () => {
+ it('parses fenced JSON output', () => {
+ const fields = parseGeneratedPullRequestFields(
+ '```json\n{"base":"main","title":"fix: add details.","body":"Summary","draft":true}\n```',
+ context
+ )
+
+ expect(fields).toEqual({
+ base: 'main',
+ title: 'fix: add details',
+ body: 'Summary',
+ draft: true
+ })
+ })
+
+ it('falls back for missing optional values', () => {
+ const fields = parseGeneratedPullRequestFields('{"title":""}', context)
+
+ expect(fields).toEqual({
+ base: 'main',
+ title: 'Feature pr details',
+ body: '- Add form',
+ draft: false
+ })
+ })
+})
diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts
new file mode 100644
index 000000000..96ed52be8
--- /dev/null
+++ b/src/shared/pull-request-generation.ts
@@ -0,0 +1,114 @@
+import { truncateDiffForPrompt } from './commit-message-prompt'
+
+export type PullRequestDraftContext = {
+ branch: string | null
+ base: string
+ currentTitle: string
+ currentBody: string
+ currentDraft: boolean
+ commitSummary: string
+ changeSummary: string
+ patch: string
+}
+
+export type GeneratedPullRequestFields = {
+ base: string
+ title: string
+ body: string
+ draft: boolean
+}
+
+function limitSection(value: string, maxChars: number): string {
+ if (value.length <= maxChars) {
+ return value
+ }
+ const omitted = value.length - maxChars
+ return `${value.slice(0, maxChars)}\n\n[truncated: ${omitted} characters omitted]`
+}
+
+export function buildPullRequestFieldsPrompt(
+ context: PullRequestDraftContext,
+ customInstructions: string
+): string {
+ const base = [
+ 'You are generating pull request details.',
+ 'Return ONLY compact JSON with this exact shape:',
+ '{"base":"branch-name","title":"short title","body":"markdown description","draft":false}',
+ '',
+ 'Rules:',
+ '- Use the branch diff and commits below as source of truth.',
+ '- Keep the base branch as the current base unless the diff clearly targets a different branch.',
+ '- Title: concise, specific, no trailing period.',
+ '- Body: useful Markdown summary for reviewers. Include testing notes only when evidence exists.',
+ '- draft: true only when the changes clearly look unfinished, WIP, or unsafe to review.',
+ '- Do not include labels, reviewers, code fences, prose, or any keys beyond base/title/body/draft.',
+ '',
+ `Head branch: ${context.branch ?? '(detached)'}`,
+ `Current base: ${context.base}`,
+ `Current title: ${context.currentTitle || '(empty)'}`,
+ `Current description: ${context.currentBody || '(empty)'}`,
+ `Current draft: ${context.currentDraft ? 'true' : 'false'}`,
+ '',
+ 'Commits:',
+ limitSection(context.commitSummary || '(none)', 8_000),
+ '',
+ 'Changed files:',
+ limitSection(context.changeSummary || '(none)', 8_000),
+ '',
+ 'Patch:',
+ '```diff',
+ truncateDiffForPrompt(context.patch),
+ '```'
+ ].join('\n')
+
+ const trimmedInstructions = customInstructions.trim()
+ if (!trimmedInstructions) {
+ return base
+ }
+ return [
+ base,
+ '',
+ 'Additional instructions from user:',
+ limitSection(trimmedInstructions, 4_000)
+ ].join('\n')
+}
+
+function stripJsonFence(raw: string): string {
+ let text = raw.replace(/\r\n/g, '\n').trim()
+ const fenced = text.match(/^```(?:json)?\n([\s\S]*?)\n```$/i)
+ if (fenced) {
+ text = fenced[1].trim()
+ }
+ const start = text.indexOf('{')
+ const end = text.lastIndexOf('}')
+ if (start !== -1 && end > start) {
+ return text.slice(start, end + 1)
+ }
+ return text
+}
+
+export function parseGeneratedPullRequestFields(
+ raw: string,
+ fallback: Pick
+): GeneratedPullRequestFields {
+ const parsed = JSON.parse(stripJsonFence(raw)) as unknown
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error('Expected a JSON object.')
+ }
+ const record = parsed as Record
+ const base = typeof record.base === 'string' ? record.base.trim() : fallback.base
+ const title =
+ typeof record.title === 'string' && record.title.trim()
+ ? record.title.trim().replace(/[.]+$/g, '')
+ : fallback.currentTitle.trim()
+ const body =
+ typeof record.body === 'string' ? record.body.replace(/\s+$/g, '') : fallback.currentBody
+ const draft = typeof record.draft === 'boolean' ? record.draft : fallback.currentDraft
+
+ return {
+ base: base || fallback.base,
+ title: title || 'Update project files',
+ body,
+ draft
+ }
+}