Merge branch 'nwparker/term-speed-2-view-attribute-bridge' into nwparker/term-speed-2-query-authority-alignment

This commit is contained in:
Neil
2026-06-16 16:32:25 -07:00
144 changed files with 7979 additions and 1094 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.73-rc.2",
"version": "1.4.73-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -103,6 +103,17 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(renameWorktreeFolder).toHaveBeenCalledWith(WORKTREE_ID, 'fix-auth')
})
it('keeps branch and display rename working when folder rename is disabled', async () => {
const { deps, onRenamed, setDisplayName } = makeDeps({ renameWorktreeFolder: undefined })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.objectContaining({ cwd: '/repo/wt' })
)
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth')
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('skips the redundant branch-rename notify when the folder rename succeeded', async () => {
// The folder rename already pushed a worktrees:changed carrying the id mapping,
// so onRenamed would only trigger a second, redundant renderer re-list.
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createAzureDevOpsPullRequest,
isAzureDevOpsReviewCreationAuthenticated
} from './pull-request-creation'
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
describe('Azure DevOps pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n',
stderr: ''
})
_resetAzureDevOpsRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetAzureDevOpsRepoRefCache()
})
it('treats token-only auth as sufficient for repo-scoped creation', () => {
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
expect(isAzureDevOpsReviewCreationAuthenticated()).toBe(true)
})
it('posts a pull request create body to the repository REST endpoint', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
expect(url.pathname).toBe('/acme/Project/_apis/git/repositories/repo/pullRequests')
expect(url.searchParams.get('api-version')).toBe('7.1')
expect(init).toBeDefined()
const requestInit = init!
expect(requestInit.method).toBe('POST')
expect((requestInit.headers as Record<string, string>).Authorization).toMatch(/^Basic /)
expect(JSON.parse(String(requestInit.body))).toEqual({
sourceRefName: 'refs/heads/feature/azure',
targetRefName: 'refs/heads/main',
title: 'Add Azure create',
description: 'Body',
isDraft: true
})
return Response.json({
pullRequestId: 37,
title: 'Add Azure create',
status: 'active',
isDraft: true,
creationDate: '2026-06-01T00:00:00Z',
_links: {
web: {
href: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
}
}
})
})
globalThis.fetch = fetchMock as never
await expect(
createAzureDevOpsPullRequest('/repo', {
provider: 'azure-devops',
base: 'origin/main',
head: 'refs/heads/feature/azure',
title: 'Add Azure create',
body: 'Body',
draft: true
})
).resolves.toEqual({
ok: true,
number: 37,
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
})
expect(fetchMock).toHaveBeenCalledOnce()
})
it('resolves Azure DevOps remotes through the SSH git provider', async () => {
const remoteGit = {
exec: vi.fn(async () => ({
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n',
stderr: ''
}))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
globalThis.fetch = vi.fn(async () =>
Response.json({
pullRequestId: 38,
title: 'Remote Azure create',
status: 'active',
creationDate: '2026-06-01T00:00:00Z'
})
) as never
await expect(
createAzureDevOpsPullRequest(
'/remote/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/azure',
title: 'Remote Azure create'
},
'ssh-1'
)
).resolves.toMatchObject({
ok: true,
number: 38
})
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('classifies auth failures without retrying shell commands', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ message: 'Unauthorized' }, { status: 401 })
) as never
await expect(
createAzureDevOpsPullRequest('/repo', {
provider: 'azure-devops',
base: 'main',
head: 'feature/azure',
title: 'Add Azure create'
})
).resolves.toMatchObject({
ok: false,
code: 'auth_required'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
})
})
@@ -0,0 +1,238 @@
import { Buffer } from 'buffer'
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
HostedReviewApiRequestError,
requestHostedReviewJson
} from '../source-control/hosted-review-api-request'
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
import { getAzureDevOpsPullRequestForBranch } from './client'
import { mapAzureDevOpsPullRequest, type RawAzureDevOpsPullRequest } from './pull-request-mappers'
import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref'
const CREATE_REQUEST_TIMEOUT_MS = 60_000
type AzureDevOpsCreateAuthConfig = {
apiBaseUrl: string | null
pat: string | null
accessToken: string | null
username: string | null
}
function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
function normalizeApiBaseUrl(value: string): string {
return value
.trim()
.replace(/\/+$/, '')
.replace(/\/_apis$/i, '')
}
function getAuthConfig(): AzureDevOpsCreateAuthConfig {
return {
apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'),
pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'),
accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'),
username: envValue('ORCA_AZURE_DEVOPS_USERNAME')
}
}
export function isAzureDevOpsReviewCreationAuthenticated(): boolean {
const config = getAuthConfig()
return Boolean(config.pat || config.accessToken)
}
function authHeaders(config: AzureDevOpsCreateAuthConfig): Record<string, string> {
if (config.accessToken) {
return { Authorization: `Bearer ${config.accessToken}` }
}
if (config.pat) {
const encoded = Buffer.from(`${config.username ?? ''}:${config.pat}`).toString('base64')
return { Authorization: `Basic ${encoded}` }
}
return {}
}
function apiUrl(repo: AzureDevOpsRepoRef, path: string): URL {
const config = getAuthConfig()
const baseUrl = config.apiBaseUrl ? normalizeApiBaseUrl(config.apiBaseUrl) : repo.apiBaseUrl
const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`)
url.searchParams.set('api-version', '7.1')
return url
}
function encodePathSegment(value: string): string {
return encodeURIComponent(value)
}
function azureBranchRef(branch: string): string {
return `refs/heads/${branch.replace(/^refs\/heads\//, '')}`
}
function apiErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function classifyCreateError(error: unknown): CreateHostedReviewResult {
const message = apiErrorMessage(error)
if (message) {
console.warn('createAzureDevOpsPullRequest failed:', message)
}
const lower = message.toLowerCase()
const status = error instanceof HostedReviewApiRequestError ? error.status : null
if (
status === 401 ||
status === 403 ||
lower.includes('unauthorized') ||
lower.includes('forbidden') ||
lower.includes('authentication')
) {
return {
ok: false,
code: 'auth_required',
error:
'Create PR failed: Azure DevOps is not authenticated. Next step: set ORCA_AZURE_DEVOPS_TOKEN in this environment.'
}
}
if (status === 409 || lower.includes('already exists') || lower.includes('active pull request')) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.'
}
}
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
return {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
}
if (status === 400 || status === 422 || lower.includes('validation')) {
return {
ok: false,
code: 'validation',
error:
'Create PR failed: Azure DevOps rejected the pull request. Check the base branch and branch state, then try again.'
}
}
return {
ok: false,
code: 'unknown',
error:
'Create PR failed: Azure DevOps could not create the pull request. Try again in a moment.'
}
}
async function findExistingPullRequest(
repoPath: string,
head: string,
connectionId?: string | null
): Promise<{ number: number; url: string } | null> {
const existing = await getAzureDevOpsPullRequestForBranch(repoPath, head, null, connectionId)
return existing ? { number: existing.number, url: existing.url } : null
}
export async function createAzureDevOpsPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'azure-devops') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
const repo = await getAzureDevOpsRepoRef(repoPath, connectionId)
if (!repo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires an Azure DevOps remote.'
}
}
const base = normalizeHostedReviewBaseRef(input.base)
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
const title = input.title.trim()
if (!base || !head || !title) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: base branch, head branch, and title are required.'
}
}
if (head.toLowerCase() === base.toLowerCase()) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: choose a different base branch before creating a pull request.'
}
}
const body =
input.useTemplate && !input.body?.trim()
? await readHostedPullRequestTemplate(repoPath, connectionId)
: (input.body ?? '')
const requestBody = {
sourceRefName: azureBranchRef(head),
targetRefName: azureBranchRef(base),
title,
description: body,
...(input.draft ? { isDraft: true } : {})
}
try {
const raw = await requestHostedReviewJson<RawAzureDevOpsPullRequest>(
apiUrl(repo, `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`),
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders(getAuthConfig())
},
body: JSON.stringify(requestBody)
},
CREATE_REQUEST_TIMEOUT_MS
)
const created = mapAzureDevOpsPullRequest(raw, 'neutral', repo.webBaseUrl)
if (created) {
return { ok: true, number: created.number, url: created.url }
}
const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
return found
? { ok: true, ...found }
: {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
} catch (error) {
const classified = classifyCreateError(error)
if (
!classified.ok &&
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
) {
const existing = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
if (existing) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.',
existingReview: existing
}
}
}
return classified
}
}
+217 -9
View File
@@ -54,6 +54,7 @@ const ORCA_SHELL_WRAPPER_ENV = [
] as const
const POWERSHELL_OSC133_COMMAND_ARGS = ['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]
const ZSH_SHELL_READY_DIR = /shell-ready[\\/]zsh/
const itOnMacHost = process.platform === 'darwin' ? it : it.skip
function mockPtyProcess(pid = 12345) {
const onDataListeners: ((data: string) => void)[] = []
@@ -148,7 +149,7 @@ describe('createPtySubprocess', () => {
)
})
it('repairs a deleted macOS daemon cwd before spawning node-pty', () => {
itOnMacHost('repairs a deleted macOS daemon cwd before spawning node-pty', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -231,7 +232,11 @@ describe('createPtySubprocess', () => {
})
expect(handle.getForegroundProcess()).toBe('node')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'node',
expect.any(Object)
)
resolveForeground('codex')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
@@ -263,7 +268,11 @@ describe('createPtySubprocess', () => {
})
expect(handle.getForegroundProcess()).toBe('node.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'node.exe',
expect.any(Object)
)
resolveForeground('codex')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
@@ -274,6 +283,197 @@ describe('createPtySubprocess', () => {
}
})
it('serves daemon shell-rooted agent foreground from an async cache', async () => {
const proc = mockPtyProcess()
proc.process = 'powershell.exe'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
let resolveForeground!: (processName: string) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise<string>((resolve) => {
resolveForeground = resolve
})
)
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'powershell.exe',
expect.any(Object)
)
resolveForeground('codex')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('does not let stale shell enrichment clear a newer direct agent foreground cache', async () => {
const proc = mockPtyProcess()
proc.process = 'powershell.exe'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
let resolveForeground!: (processName: string) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise<string>((resolve) => {
resolveForeground = resolve
})
)
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBe('powershell.exe')
proc.process = 'codex'
expect(handle.getForegroundProcess()).toBe('codex')
resolveForeground('powershell.exe')
await Promise.resolve()
await Promise.resolve()
proc.process = 'powershell.exe'
expect(handle.getForegroundProcess()).toBe('codex')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('keeps menu startup agent foreground through early negative shell enrichment', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
const proc = mockPtyProcess()
proc.process = 'powershell.exe'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
resolveAgentForegroundProcessMock.mockResolvedValue('powershell.exe')
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
command: 'codex'
})
expect(handle.getForegroundProcess()).toBe('codex')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'powershell.exe',
expect.any(Object)
)
await Promise.resolve()
expect(handle.getForegroundProcess()).toBe('codex')
vi.advanceTimersByTime(4_999)
expect(handle.getForegroundProcess()).toBe('codex')
vi.advanceTimersByTime(2)
expect(handle.getForegroundProcess()).toBe('powershell.exe')
} finally {
vi.useRealTimers()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('keeps menu startup agent foreground during a slow Windows shell enrichment window', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
const proc = mockPtyProcess()
proc.process = 'powershell.exe'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
let resolveForeground!: (processName: string) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise<string>((resolve) => {
resolveForeground = resolve
})
)
try {
const handle = createPtySubprocess({
sessionId: 'repo::C:\\repo\\orca@@deadbeef',
cols: 80,
rows: 24,
cwd: 'C:\\repo\\orca',
command: 'codex'
})
expect(handle.getForegroundProcess()).toBe('codex')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'powershell.exe',
expect.objectContaining({
contextPaths: expect.arrayContaining(['C:\\repo\\orca'])
})
)
vi.advanceTimersByTime(2_500)
expect(handle.getForegroundProcess()).toBe('codex')
resolveForeground('powershell.exe')
await vi.runAllTimersAsync()
} finally {
vi.useRealTimers()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('uses the spawned Windows shell when node-pty reports only the terminal name', async () => {
const proc = mockPtyProcess()
proc.process = 'xterm-256color'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
resolveAgentForegroundProcessMock.mockResolvedValue('codex')
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
expect(handle.getForegroundProcess()).toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'powershell.exe',
expect.any(Object)
)
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('does not schedule foreground enrichment for arbitrary Windows TUIs', () => {
const proc = mockPtyProcess()
proc.process = 'vim.exe'
@@ -301,14 +501,22 @@ describe('createPtySubprocess', () => {
const proc = mockPtyProcess()
proc.process = 'xterm-256color'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBeNull()
expect(handle.getForegroundProcess()).toBeNull()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('does not inherit parent Orca pane identity when caller omits pane env', () => {
+83 -18
View File
@@ -30,12 +30,17 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
} from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
import { parsePtySessionId } from './pty-session-id'
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
export type PtySubprocessOptions = {
@@ -316,6 +321,19 @@ function normalizeForegroundProcessName(processName: string | null | undefined):
return trimmed.split(/[\\/]/).pop() || null
}
function resolveFallbackForegroundProcess(
processName: string | null | undefined,
shellPath: string
): string | null {
const normalized = normalizeForegroundProcessName(processName)
if (normalized || process.platform !== 'win32') {
return normalized
}
// Why: Windows node-pty can report the terminal name instead of the shell.
// Use the spawned shell so shell-rooted foreground enrichment still runs.
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
}
export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle {
const size = normalizePtySize(opts.cols, opts.rows)
const env: Record<string, string> = {
@@ -540,40 +558,84 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let disposed = false
let nodePtyKillIssued = false
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
const agentForegroundContextPaths = getAgentForegroundContextPaths({
cwd: opts.cwd,
worktreeId: parsePtySessionId(opts.sessionId).worktreeId
})
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
let startupAgentForeground: { processName: string; expiresAt: number } | null =
startupAgentRecognition
? {
processName: startupAgentRecognition.processName,
expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS
}
: null
let foregroundRefreshInFlight = false
let lastForegroundRefreshStartedAt = 0
const getFallbackForegroundProcess = (): string | null =>
normalizeForegroundProcessName(proc.process)
resolveFallbackForegroundProcess(proc.process, shellPath)
const getActiveStartupAgentForeground = (
now = Date.now()
): { processName: string; expiresAt: number } | null => {
if (!startupAgentForeground) {
return null
}
if (now > startupAgentForeground.expiresAt) {
startupAgentForeground = null
return null
}
return startupAgentForeground
}
const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean =>
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) || isAgentForegroundWrapperProcess(fallbackProcess))
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
if (dead || !proc.pid) {
return
}
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
if (
!fallbackProcess ||
isShellProcess(fallbackProcess) ||
recognizeAgentProcess(fallbackProcess) ||
!isAgentForegroundWrapperProcess(fallbackProcess)
!shouldInspectFallbackForegroundProcess(fallbackProcess)
) {
return
}
const now = Date.now()
if (
foregroundRefreshInFlight ||
now - lastForegroundRefreshStartedAt < FOREGROUND_AGENT_CACHE_TTL_MS
) {
const retryMs =
fallbackIsShell && !getActiveStartupAgentForeground(now) && !cachedAgentForeground
? SHELL_FOREGROUND_REFRESH_RETRY_MS
: FOREGROUND_AGENT_CACHE_TTL_MS
if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) {
return
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
// Why: daemon `getForegroundProcess()` is sync and runs on the IPC hot path.
// Refresh wrapper-derived identities (node/python → codex/gemini/etc.) in
// the background and serve them from a short cache on later reads.
void resolveAgentForegroundProcess(proc.pid, fallbackProcess)
// Why: daemon foreground reads are sync and run on the IPC hot path.
// Refresh shell/wrapper-derived identities (powershell/node -> codex/etc.)
// in the background and serve them from a short cache on later reads.
void resolveAgentForegroundProcess(proc.pid, fallbackProcess, {
contextPaths: agentForegroundContextPaths
})
.then((processName) => {
if (dead || !processName || !recognizeAgentProcess(processName)) {
if (dead) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
const currentFallbackProcess = getFallbackForegroundProcess()
if (
fallbackIsShell &&
!getActiveStartupAgentForeground() &&
currentFallbackProcess !== null &&
isShellProcess(currentFallbackProcess)
) {
cachedAgentForeground = null
startupAgentForeground = null
}
return
}
cachedAgentForeground = { processName, refreshedAt: Date.now() }
startupAgentForeground = null
})
.catch(() => {
// Best-effort only: foreground enrichment must never affect PTY health.
@@ -585,6 +647,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
proc.onExit(() => {
dead = true
cachedAgentForeground = null
startupAgentForeground = null
// Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
// (unixTerminal.js:219-229). After the child exits, the master socket's
// 'close' event can fire before our dispose() path gets to neutralize
@@ -611,21 +674,23 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
try {
const fallbackProcess = getFallbackForegroundProcess()
if (fallbackProcess && isShellProcess(fallbackProcess)) {
cachedAgentForeground = null
return fallbackProcess
}
if (fallbackProcess && recognizeAgentProcess(fallbackProcess)) {
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
startupAgentForeground = null
return fallbackProcess
}
scheduleAgentForegroundRefresh(fallbackProcess)
const now = Date.now()
if (
cachedAgentForeground &&
Date.now() - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
) {
return cachedAgentForeground.processName
}
const activeStartupAgentForeground = getActiveStartupAgentForeground(now)
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
return activeStartupAgentForeground.processName
}
return fallbackProcess
} catch {
return null
+31 -1
View File
@@ -1,7 +1,10 @@
/* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'path'
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../shared/large-diff-render-limit'
import {
MAX_RENDERED_DIFF_COMBINED_CHARACTERS,
MAX_RENDERED_DIFF_LINES_PER_SIDE
} from '../../shared/large-diff-render-limit'
const {
gitExecFileAsyncMock,
@@ -384,6 +387,33 @@ describe('getDiff', () => {
)
})
it('omits over-limit text bodies when line-count exceeds the cap', async () => {
const oversizedByLines = 'x\n'.repeat(MAX_RENDERED_DIFF_LINES_PER_SIDE)
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
statMock.mockResolvedValueOnce({
isFile: () => true,
size: oversizedByLines.length
})
readFileMock.mockResolvedValue(Buffer.from(oversizedByLines))
const result = await getDiff('/repo', 'dist/large-lines.log', false)
expect(result.kind).toBe('text')
if (result.kind !== 'text') {
throw new Error('expected text diff result')
}
expect(result.originalContent).toBe('')
expect(result.modifiedContent).toBe('')
expect(result.largeDiffRenderLimit?.limited).toBe(true)
if (result.largeDiffRenderLimit?.limited !== true) {
throw new Error('expected large diff render limit')
}
expect(result.largeDiffRenderLimit.reason).toBe('line-count')
expect(result.largeDiffRenderLimit.lineCounts?.modified).toBeGreaterThan(
MAX_RENDERED_DIFF_LINES_PER_SIDE
)
})
it('marks git blobs that overflow maxBuffer as binary instead of pretending they are missing', async () => {
gitExecFileAsyncBufferMock.mockRejectedValueOnce(
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })
+2
View File
@@ -1072,6 +1072,8 @@ function buildDiffResult(
} as GitDiffResult
}
// Why: if the diff exceeds safe render limits, avoid sending large text
// payloads and return metadata so the renderer can show fallback UI.
const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent })
if (largeDiffRenderLimit.limited) {
return {
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createGiteaPullRequest, isGiteaReviewCreationAuthenticated } from './pull-request-creation'
import { _resetGiteaRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
describe('Gitea pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'gitea-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://git.example.com/code/team/repo.git\n',
stderr: ''
})
_resetGiteaRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetGiteaRepoRefCache()
})
it('requires a token for repo-scoped creation', () => {
expect(isGiteaReviewCreationAuthenticated()).toBe(true)
delete process.env.ORCA_GITEA_TOKEN
expect(isGiteaReviewCreationAuthenticated()).toBe(false)
})
it('posts a pull request create body to the repository REST endpoint', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
expect(url.origin).toBe('https://git.example.com')
expect(url.pathname).toBe('/code/api/v1/repos/team/repo/pulls')
expect(init).toBeDefined()
const requestInit = init!
expect(requestInit.method).toBe('POST')
expect((requestInit.headers as Record<string, string>).Authorization).toBe(
'token gitea-token'
)
expect(JSON.parse(String(requestInit.body))).toEqual({
base: 'main',
head: 'feature/gitea',
title: 'Add Gitea create',
body: 'Body',
draft: true
})
return Response.json({
number: 13,
title: 'Add Gitea create',
state: 'open',
draft: true,
html_url: 'https://git.example.com/code/team/repo/pulls/13',
updated_at: '2026-06-01T00:00:00Z',
mergeable: true,
head: {
ref: 'feature/gitea',
sha: 'abc123'
}
})
})
globalThis.fetch = fetchMock as never
await expect(
createGiteaPullRequest('/repo', {
provider: 'gitea',
base: 'origin/main',
head: 'refs/heads/feature/gitea',
title: 'Add Gitea create',
body: 'Body',
draft: true
})
).resolves.toEqual({
ok: true,
number: 13,
url: 'https://git.example.com/code/team/repo/pulls/13'
})
expect(fetchMock).toHaveBeenCalledOnce()
})
it('resolves Gitea remotes through the SSH git provider', async () => {
const remoteGit = {
exec: vi.fn(async () => ({
stdout: 'git@git.example.com:code/team/repo.git\n',
stderr: ''
}))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
globalThis.fetch = vi.fn(async () =>
Response.json({
number: 14,
title: 'Remote Gitea create',
state: 'open',
html_url: 'https://git.example.com/code/team/repo/pulls/14',
updated_at: '2026-06-01T00:00:00Z',
mergeable: true
})
) as never
await expect(
createGiteaPullRequest(
'/remote/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/gitea',
title: 'Remote Gitea create'
},
'ssh-1'
)
).resolves.toMatchObject({
ok: true,
number: 14
})
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('classifies validation failures from the REST API', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ message: 'Validation failed' }, { status: 422 })
) as never
await expect(
createGiteaPullRequest('/repo', {
provider: 'gitea',
base: 'main',
head: 'feature/gitea',
title: 'Add Gitea create'
})
).resolves.toMatchObject({
ok: false,
code: 'validation'
})
})
})
+208
View File
@@ -0,0 +1,208 @@
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
HostedReviewApiRequestError,
requestHostedReviewJson
} from '../source-control/hosted-review-api-request'
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
import { getGiteaPullRequestForBranch } from './client'
import { mapGiteaPullRequest, type RawGiteaPullRequest } from './pull-request-mappers'
import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref'
const CREATE_REQUEST_TIMEOUT_MS = 60_000
function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
function normalizeApiBaseUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, '')
return /\/api\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/api/v1`
}
function configuredApiBaseUrl(repo: GiteaRepoRef): string {
const configured = envValue('ORCA_GITEA_API_BASE_URL')
return configured ? normalizeApiBaseUrl(configured) : repo.apiBaseUrl
}
export function isGiteaReviewCreationAuthenticated(): boolean {
return envValue('ORCA_GITEA_TOKEN') !== null
}
function authHeaders(): Record<string, string> {
const token = envValue('ORCA_GITEA_TOKEN')
return token ? { Authorization: `token ${token}` } : {}
}
function apiUrl(repo: GiteaRepoRef, path: string): URL {
return new URL(`${configuredApiBaseUrl(repo).replace(/\/+$/, '')}${path}`)
}
function encodedRepoPath(repo: GiteaRepoRef): string {
return `${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`
}
function apiErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function classifyCreateError(error: unknown): CreateHostedReviewResult {
const message = apiErrorMessage(error)
if (message) {
console.warn('createGiteaPullRequest failed:', message)
}
const lower = message.toLowerCase()
const status = error instanceof HostedReviewApiRequestError ? error.status : null
if (
status === 401 ||
status === 403 ||
lower.includes('unauthorized') ||
lower.includes('forbidden') ||
lower.includes('authentication')
) {
return {
ok: false,
code: 'auth_required',
error:
'Create PR failed: Gitea is not authenticated. Next step: set ORCA_GITEA_TOKEN in this environment.'
}
}
if (status === 409 || lower.includes('already exists') || lower.includes('already open')) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.'
}
}
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
return {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
}
if (status === 400 || status === 422 || lower.includes('validation')) {
return {
ok: false,
code: 'validation',
error:
'Create PR failed: Gitea rejected the pull request. Check the base branch and branch state, then try again.'
}
}
return {
ok: false,
code: 'unknown',
error: 'Create PR failed: Gitea could not create the pull request. Try again in a moment.'
}
}
async function findExistingPullRequest(
repoPath: string,
head: string,
connectionId?: string | null
): Promise<{ number: number; url: string } | null> {
const existing = await getGiteaPullRequestForBranch(repoPath, head, null, connectionId)
return existing ? { number: existing.number, url: existing.url } : null
}
export async function createGiteaPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'gitea') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
const repo = await getGiteaRepoRef(repoPath, connectionId)
if (!repo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires a Gitea remote.'
}
}
const base = normalizeHostedReviewBaseRef(input.base)
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
const title = input.title.trim()
if (!base || !head || !title) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: base branch, head branch, and title are required.'
}
}
if (head.toLowerCase() === base.toLowerCase()) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: choose a different base branch before creating a pull request.'
}
}
const body =
input.useTemplate && !input.body?.trim()
? await readHostedPullRequestTemplate(repoPath, connectionId)
: (input.body ?? '')
const requestBody = {
base,
head,
title,
body,
...(input.draft ? { draft: true } : {})
}
try {
const raw = await requestHostedReviewJson<RawGiteaPullRequest>(
apiUrl(repo, `/repos/${encodedRepoPath(repo)}/pulls`),
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(requestBody)
},
CREATE_REQUEST_TIMEOUT_MS
)
const created = mapGiteaPullRequest(raw, 'neutral')
if (created) {
return { ok: true, number: created.number, url: created.url }
}
const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
return found
? { ok: true, ...found }
: {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
} catch (error) {
const classified = classifyCreateError(error)
if (
!classified.ok &&
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
) {
const existing = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
if (existing) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.',
existingReview: existing
}
}
}
return classified
}
}
+200
View File
@@ -589,6 +589,47 @@ describe('getPRForBranch', () => {
})
})
it('ignores merged PRs discovered only by branch lookup', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 5511,
title: 'Merged branch PR',
state: 'closed',
merged_at: '2026-06-16T17:15:33Z',
html_url: 'https://github.com/stablyai/orca/pull/5511',
updated_at: '2026-06-16T17:15:33Z',
draft: false,
mergeable_state: 'clean',
head: { ref: 'add-guide-for-mobile-emulator-use', sha: 'head-oid' },
base: { ref: 'main', sha: 'base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5511,
title: 'Merged branch PR',
state: 'MERGED',
url: 'https://github.com/stablyai/orca/pull/5511',
statusCheckRollup: [],
updatedAt: '2026-06-16T17:15:33Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'add-guide-for-mobile-emulator-use',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'add-guide-for-mobile-emulator-use')
expect(pr).toBeNull()
})
it('prefers branch lookup over a fallback PR number', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
@@ -679,6 +720,165 @@ describe('getPRForBranch', () => {
expect(pr).toMatchObject({ number: 42, title: 'Fallback PR lookup' })
})
it('treats a merged branch lookup as a miss before using a fallback PR number', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 5511,
title: 'Merged branch PR',
state: 'closed',
merged_at: '2026-06-16T17:15:33Z',
html_url: 'https://github.com/stablyai/orca/pull/5511',
updated_at: '2026-06-16T17:15:33Z',
draft: false,
mergeable_state: 'clean',
head: { ref: 'feature/test', sha: 'merged-head-oid' },
base: { ref: 'main', sha: 'base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5511,
title: 'Merged branch PR',
state: 'MERGED',
url: 'https://github.com/stablyai/orca/pull/5511',
statusCheckRollup: [],
updatedAt: '2026-06-16T17:15:33Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'merged-head-oid'
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 42,
title: 'Open fallback PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-06-17T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'contributor/original',
baseRefOid: 'base-oid',
headRefOid: 'fallback-head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42)
expect(pr).toMatchObject({ number: 42, title: 'Open fallback PR' })
})
it('does not carry a merged upstream branch head repo into a fallback PR number', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
candidates: [{ owner: 'stablyai', repo: 'orca' }],
headRepo: { owner: 'origin-owner', repo: 'orca' }
})
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' })
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'fork/contributor/original\n',
stderr: ''
})
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 5511,
title: 'Merged upstream branch PR',
state: 'closed',
merged_at: '2026-06-16T17:15:33Z',
html_url: 'https://github.com/stablyai/orca/pull/5511',
updated_at: '2026-06-16T17:15:33Z',
draft: false,
mergeable_state: 'clean',
head: { ref: 'contributor/original', sha: 'merged-head-oid' },
base: { ref: 'main', sha: 'base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5511,
title: 'Merged upstream branch PR',
state: 'MERGED',
url: 'https://github.com/stablyai/orca/pull/5511',
statusCheckRollup: [],
updatedAt: '2026-06-16T17:15:33Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'contributor/original',
baseRefOid: 'base-oid',
headRefOid: 'merged-head-oid'
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 42,
title: 'Open fallback PR',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/42',
statusCheckRollup: [],
updatedAt: '2026-06-17T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'fresh/fallback',
baseRefOid: 'base-oid',
headRefOid: 'fallback-head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'local-created-from-pr', null, null, 42)
expect(pr).toMatchObject({
number: 42,
title: 'Open fallback PR',
headRepo: { owner: 'origin-owner', repo: 'orca' }
})
})
it('ignores merged PRs discovered only from a fallback PR number', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5511,
title: 'Merged fallback PR',
state: 'MERGED',
url: 'https://github.com/stablyai/orca/pull/5511',
statusCheckRollup: [],
updatedAt: '2026-06-16T17:15:33Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'add-guide-for-mobile-emulator-use',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
const pr = await getPRForBranch(
'/repo-root',
'add-guide-for-mobile-emulator-use',
null,
null,
5511
)
expect(pr).toBeNull()
})
it('falls back to the tracked upstream branch when the local branch name differs', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
+16
View File
@@ -1928,6 +1928,13 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
}
}
function isMergedImplicitPR(data: PullRequestLookupData, linkedPRNumber?: number | null): boolean {
// Why: merged PRs are historical branch matches unless the worktree has an
// explicit PR link. Showing them as implicit review context leaves nothing
// meaningful to unlink after the branch has been rebased or merged.
return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged'
}
function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData {
return {
...data,
@@ -2423,6 +2430,11 @@ export async function getPRForBranchOutcome(
}
}
}
if (data && isMergedImplicitPR(data, linkedPRNumber)) {
data = null
dataRepo = null
dataHeadRepo = headRepo
}
if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') {
const fallbackLookup = await lookupPRByNumber({
candidates,
@@ -2435,6 +2447,9 @@ export async function getPRForBranchOutcome(
if (!data) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
if (isMergedImplicitPR(data, linkedPRNumber)) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
const mergeable = derivePullRequestMergeable(data)
const conflictSummary =
@@ -2468,6 +2483,7 @@ export async function getPRForBranchOutcome(
: {}),
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
headSha: data.headRefOid,
...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
prRepo: dataRepo ?? undefined,
headRepo: dataHeadRepo ?? undefined,
conflictSummary
+12 -2
View File
@@ -26,8 +26,10 @@ describe('resolveGitHubPrStartPoint', () => {
remoteUrl: 'git@github.com:contributor/orca.git'
}
})
const fetchRemoteTrackingRef = vi.fn(async () => {
throw new Error('fatal: could not find remote ref')
const fetchRemoteTrackingRef = vi.fn(async (_remote: string, branch: string) => {
if (branch === 'feat/onboarding-model-choice-782') {
throw new Error('fatal: could not find remote ref')
}
})
const gitExec = vi.fn(async (args: string[]) => {
if (args[0] === 'rev-parse') {
@@ -40,6 +42,7 @@ describe('resolveGitHubPrStartPoint', () => {
repoPath: '/repo-root',
prNumber: 1849,
headRefName: 'feat/onboarding-model-choice-782',
baseRefName: 'main',
gitExec,
fetchRemoteTrackingRef,
resolveRemote: async () => 'origin'
@@ -49,9 +52,11 @@ describe('resolveGitHubPrStartPoint', () => {
'origin',
'feat/onboarding-model-choice-782'
)
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'main')
expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'])
expect(result).toEqual({
baseBranch: 'def456',
compareBaseRef: 'refs/remotes/origin/main',
headSha: 'def456',
branchNameOverride: 'feat/onboarding-model-choice-782',
pushTarget: {
@@ -124,6 +129,7 @@ describe('resolveGitHubPrStartPoint', () => {
getWorkItemMock.mockResolvedValue({
type: 'pr',
branchName: 'contributor/fix',
baseRefName: 'main',
isCrossRepository: true
})
getPullRequestPushTargetMock.mockResolvedValue({
@@ -152,6 +158,7 @@ describe('resolveGitHubPrStartPoint', () => {
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null)
expect(result).toEqual({
baseBranch: 'abc123',
compareBaseRef: 'refs/remotes/origin/main',
headSha: 'abc123',
branchNameOverride: 'contributor/fix',
pushTarget: {
@@ -215,15 +222,18 @@ describe('resolveGitHubPrStartPoint', () => {
repoPath: '/repo-root',
prNumber: 42,
headRefName: 'feature/add-feature',
baseRefName: 'develop',
gitExec,
fetchRemoteTrackingRef,
resolveRemote: async () => 'origin'
})
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'feature/add-feature')
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'develop')
expect(gitExec).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature/add-feature'])
expect(result).toEqual({
baseBranch: 'abc123',
compareBaseRef: 'refs/remotes/origin/develop',
headSha: 'abc123',
branchNameOverride: 'feature/add-feature',
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
+33
View File
@@ -8,6 +8,7 @@ type ResolveGitHubPrStartPointArgs = {
repoPath: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
connectionId?: string | null
gitExec: GitExec
@@ -21,6 +22,7 @@ export async function resolveGitHubPrStartPoint(
args: ResolveGitHubPrStartPointArgs
): Promise<ResolveGitHubPrStartPointResult> {
let headRefName = args.headRefName?.trim() ?? ''
let baseRefName = args.baseRefName?.trim() ?? ''
let isCrossRepository = args.isCrossRepository === true
let pushTarget: GitPushTarget | undefined
let maintainerCanModify: boolean | undefined
@@ -50,6 +52,7 @@ export async function resolveGitHubPrStartPoint(
return { error: `PR #${args.prNumber} not found.` }
}
headRefName = (item.branchName ?? '').trim()
baseRefName = (item.baseRefName ?? '').trim()
if (!headRefName) {
return { error: `PR #${args.prNumber} has no head branch.` }
}
@@ -69,6 +72,21 @@ export async function resolveGitHubPrStartPoint(
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
}
const compareBaseRef = baseRefName ? `refs/remotes/${remote}/${baseRefName}` : undefined
const fetchCompareBaseRef = async (): Promise<{ error: string } | null> => {
if (!baseRefName) {
return null
}
try {
await args.fetchRemoteTrackingRef(remote, baseRefName)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { error: `Failed to fetch ${remote}/${baseRefName}: ${message.split('\n')[0]}` }
}
return null
}
const fetchPullRequestHeadSha = async (): Promise<{ baseBranch: string } | { error: string }> => {
const pullRef = `refs/pull/${args.prNumber}/head`
try {
@@ -100,11 +118,16 @@ export async function resolveGitHubPrStartPoint(
if ('error' in result) {
return result
}
const compareBaseFetchError = await fetchCompareBaseRef()
if (compareBaseFetchError) {
return compareBaseFetchError
}
// Why: adopt the contributor's branch name locally (mirroring the same-repo
// return below) so fork-PR worktrees aren't renamed with the maintainer's
// branch prefix (e.g. `me/866`). The push refspec still targets the fork.
return {
...result,
...(compareBaseRef ? { compareBaseRef } : {}),
headSha: result.baseBranch,
branchNameOverride: headRefName,
...(pushTarget ? { pushTarget } : {}),
@@ -122,8 +145,13 @@ export async function resolveGitHubPrStartPoint(
const result = await fetchPullRequestHeadSha()
if (!('error' in result)) {
await resolvePushTarget()
const compareBaseFetchError = await fetchCompareBaseRef()
if (compareBaseFetchError) {
return compareBaseFetchError
}
return {
...result,
...(compareBaseRef ? { compareBaseRef } : {}),
headSha: result.baseBranch,
branchNameOverride: headRefName,
...(pushTarget ? { pushTarget } : {}),
@@ -147,9 +175,14 @@ export async function resolveGitHubPrStartPoint(
if (!headSha) {
return { error: `Empty SHA resolving PR #${args.prNumber} head.` }
}
const compareBaseFetchError = await fetchCompareBaseRef()
if (compareBaseFetchError) {
return compareBaseFetchError
}
return {
baseBranch: headSha,
...(compareBaseRef ? { compareBaseRef } : {}),
headSha,
branchNameOverride: headRefName,
pushTarget: { remoteName: remote, branchName: headRefName }
+2
View File
@@ -97,6 +97,7 @@ type GitLabMRRaw = {
has_conflicts?: boolean
detailed_merge_status?: string
description?: string | null
target_branch?: string
author?: { username?: string | null; avatar_url?: string | null } | null
}
@@ -110,6 +111,7 @@ export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInf
updatedAt: data.updated_at ?? data.updatedAt ?? '',
mergeable: deriveMergeable(data),
headSha: data.sha,
baseRefName: data.target_branch,
// Why: detail-endpoint payloads include `description`; list endpoints
// strip it. Pass through what's present rather than coercing missing
// values to '' so downstream UIs can distinguish "no body authored"
+1 -1
View File
@@ -199,7 +199,7 @@ if (appImageCliRedirect.redirected) {
// worktree id change via migrateWorktreeIdentity + a rename-aware worktrees:changed
// handler, so an old->new id change is no longer mistaken for a deletion. Flip off
// to disable the on-disk move (branch + display rename still happen) if needed.
const ENABLE_FIRST_WORK_FOLDER_RENAME = true
const ENABLE_FIRST_WORK_FOLDER_RENAME = false
// Why: the store/runtime singletons live here in index.ts; injecting them keeps
// the rename orchestrator free of module-level state and unit-testable.
+20 -2
View File
@@ -78,6 +78,7 @@ import {
} from '../git/huge-folder-ignore'
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import type { HostedReviewProvider } from '../../shared/hosted-review'
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
import { validateGitPushTarget } from '../git/push-target-validation'
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
@@ -101,6 +102,7 @@ import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
import {
prepareLocalCommitMessageAgentEnv,
type CommitMessageAgentEnvironmentResolvers
@@ -1130,6 +1132,8 @@ export function registerFilesystemHandlers(
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
connectionId?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: GlobalSettings['sourceControlAi']
@@ -1166,12 +1170,19 @@ export function registerFilesystemHandlers(
}
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: args.body,
repoPath: args.worktreePath,
connectionId: args.connectionId,
provider: args.provider,
useTemplate: args.useTemplate
})
context = await getPullRequestDraftContext(
(argv) => provider.exec(argv, args.worktreePath),
{
base: args.base,
currentTitle: args.title,
currentBody: args.body,
currentBody,
currentDraft: args.draft
}
)
@@ -1197,12 +1208,19 @@ export function registerFilesystemHandlers(
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: args.body,
repoPath: worktreePath,
connectionId: args.connectionId,
provider: args.provider,
useTemplate: args.useTemplate
})
context = await getPullRequestDraftContext(
(argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }),
{
base: args.base,
currentTitle: args.title,
currentBody: args.body,
currentBody,
currentDraft: args.draft
}
)
+6 -6
View File
@@ -1572,9 +1572,9 @@ export async function createRemoteWorktree(
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
// Why: persisted compare refs must survive local branches whose names look
// like remote labels, e.g. a local branch literally named "origin/main".
const metadataBaseRef = remoteTrackingBase?.ref ?? baseBranch
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
// Control must compare against the review target branch.
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
let configuredPushTarget: GitPushTarget | undefined
if (preparedPushTarget) {
configuredPushTarget = await configureCreatedWorktreePushTargetSsh(
@@ -2120,9 +2120,9 @@ export async function createLocalWorktree(
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
// Why: persisted compare refs must survive local branches whose names look
// like remote labels, e.g. a local branch literally named "origin/main".
const metadataBaseRef = remoteTrackingBase?.ref ?? baseBranch
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
// Control must compare against the review target branch.
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
const metaUpdates: Partial<WorktreeMeta> = {
// Why: path-derived worktree IDs can be reused after external deletion.
// Fresh creations must rotate instance identity so stale lineage cannot
+46
View File
@@ -1060,6 +1060,7 @@ describe('registerWorktreeHandlers', () => {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
compareBaseRef: 'refs/remotes/origin/main',
branchNameOverride: 'feature/fix',
linkedPR: 42,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
@@ -1076,9 +1077,54 @@ describe('registerWorktreeHandlers', () => {
['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'],
{ cwd: '/workspace/fix-title' }
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/fix-title',
expect.objectContaining({
baseRef: 'refs/remotes/origin/main',
linkedPR: 42
})
)
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
})
it('persists an explicit compare base ahead of the checkout remote-tracking base', async () => {
runtimeStub.resolveRemoteTrackingBase.mockResolvedValueOnce({
base: 'origin/source-branch',
remote: 'origin',
branch: 'source-branch',
ref: 'refs/remotes/origin/source-branch'
})
runtimeStub.hasRemoteTrackingRef.mockResolvedValueOnce(true)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/fix-title',
head: 'abc123',
branch: 'refs/heads/feature/fix',
isBare: false,
isMainWorktree: false
}
])
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'origin/source-branch',
compareBaseRef: 'refs/remotes/origin/main',
branchNameOverride: 'feature/fix',
linkedGitLabMR: 7,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/fix-title',
expect.objectContaining({
baseRef: 'refs/remotes/origin/main',
linkedGitLabMR: 7
})
)
})
it('allows a selected Bitbucket PR branch override to match its remote push target', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/bitbucket' ? 'remote' : null
+8 -1
View File
@@ -1054,6 +1054,7 @@ export function registerWorktreeHandlers(
repoId: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}
): Promise<GitHubPrStartPoint | { error: string }> => {
@@ -1090,6 +1091,7 @@ export function registerWorktreeHandlers(
repoPath: repo.path,
prNumber: args.prNumber,
headRefName: args.headRefName,
baseRefName: args.baseRefName,
isCrossRepository: args.isCrossRepository,
connectionId: repo.connectionId ?? null,
gitExec,
@@ -1120,13 +1122,18 @@ export function registerWorktreeHandlers(
repoId: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}
): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => {
): Promise<
| { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget }
| { error: string }
> => {
return runtime.resolveManagedMrBase({
repoSelector: `id:${args.repoId}`,
mrIid: args.mrIid,
sourceBranch: args.sourceBranch,
targetBranch: args.targetBranch,
isCrossRepository: args.isCrossRepository
})
}
@@ -0,0 +1,11 @@
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
export function getAgentForegroundContextPaths(options: {
cwd?: string
worktreeId?: string | null
}): string[] {
const worktreePath = options.worktreeId
? splitWorktreeIdForFilesystem(options.worktreeId)?.worktreePath
: undefined
return [...new Set([options.cwd, worktreePath].filter((path): path is string => Boolean(path)))]
}
@@ -19,14 +19,46 @@ function mockPs(stdout: string): void {
})
}
function windowsProcessRows(): string {
function windowsProcessJsonRows(
rows: {
CommandLine: string | null
Name: string
ParentProcessId: number
ProcessId: number
ExecutablePath?: string | null
}[] = [
{
CommandLine: 'powershell.exe',
Name: 'powershell.exe',
ParentProcessId: 99,
ProcessId: 100
},
{
CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
Name: 'node.exe',
ParentProcessId: 100,
ProcessId: 101
}
]
): string {
return JSON.stringify(
rows.map((row) => ({
ExecutablePath: row.ExecutablePath ?? null,
...row
}))
)
}
function windowsProcessValueRows(): string {
return [
'CommandLine=powershell.exe',
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'Name=powershell.exe',
'ParentProcessId=99',
'ProcessId=100',
'',
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
'ExecutablePath=C:\\Program Files\\nodejs\\node.exe',
'Name=node.exe',
'ParentProcessId=100',
'ProcessId=101',
@@ -95,7 +127,7 @@ describe('resolveAgentForegroundProcess', () => {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, { stdout: windowsProcessRows(), stderr: '' })
callback(null, { stdout: windowsProcessJsonRows(), stderr: '' })
}
)
@@ -108,6 +140,79 @@ describe('resolveAgentForegroundProcess', () => {
)
})
it('recognizes Windows shell-rooted agent launches from descendant command lines', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, { stdout: windowsProcessJsonRows(), stderr: '' })
}
)
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
})
it('recognizes Windows Git Bash shell-rooted agent launches', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout: windowsProcessJsonRows([
{
CommandLine: 'C:\\Program Files\\Git\\bin\\bash.exe --login -i',
Name: 'bash.exe',
ParentProcessId: 99,
ProcessId: 100
},
{
CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
Name: 'node.exe',
ParentProcessId: 100,
ProcessId: 101
}
]),
stderr: ''
})
}
)
await expect(resolveAgentForegroundProcess(100, 'bash.exe')).resolves.toBe('codex')
})
it('keeps multiline Windows command lines inside the parsed process row', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout: windowsProcessJsonRows([
{
CommandLine: 'powershell.exe',
Name: 'powershell.exe',
ParentProcessId: 99,
ProcessId: 100
},
{
CommandLine: [
'node',
'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
'--prompt',
'"line one\r\nName=gemini.exe\r\nProcessId=999"'
].join(' '),
Name: 'node.exe',
ParentProcessId: 100,
ProcessId: 101
}
]),
stderr: ''
})
}
)
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
})
it('falls back to WMIC when Windows PowerShell process enumeration fails', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
@@ -116,7 +221,27 @@ describe('resolveAgentForegroundProcess', () => {
callback(new Error('powershell unavailable'), { stdout: '', stderr: '' })
return
}
callback(null, { stdout: windowsProcessRows(), stderr: '' })
callback(null, { stdout: windowsProcessValueRows(), stderr: '' })
})
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex')
expect(execFileMock).toHaveBeenCalledWith(
'wmic',
expect.any(Array),
expect.objectContaining({ timeout: 3000 }),
expect.any(Function)
)
})
it('falls back to WMIC when Windows PowerShell returns no process rows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
if (cmd === 'powershell.exe') {
callback(null, { stdout: ' \r\n', stderr: '' })
return
}
callback(null, { stdout: windowsProcessValueRows(), stderr: '' })
})
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex')
@@ -159,6 +284,162 @@ describe('resolveAgentForegroundProcess', () => {
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe')
})
it('fails closed for ambiguous Windows shell-rooted agent descendants', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout: [
'CommandLine=powershell.exe',
'Name=powershell.exe',
'ParentProcessId=99',
'ProcessId=100',
'',
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
'Name=node.exe',
'ParentProcessId=100',
'ProcessId=101',
'',
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs',
'Name=node.exe',
'ParentProcessId=100',
'ProcessId=102',
''
].join('\r\n'),
stderr: ''
})
}
)
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
'powershell.exe'
)
})
it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout: [
'CommandLine=powershell.exe',
'CreationDate=20260616110000.000000-000',
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'Name=powershell.exe',
'ParentProcessId=99',
'ProcessId=100',
'',
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js --cwd C:\\repo\\orca',
'CreationDate=20260616110100.000000-000',
'ExecutablePath=C:\\Program Files\\nodejs\\node.exe',
'Name=node.exe',
'ParentProcessId=100',
'ProcessId=101',
'',
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs --cwd C:\\repo\\other',
'CreationDate=20260616110200.000000-000',
'ExecutablePath=C:\\Program Files\\nodejs\\node.exe',
'Name=node.exe',
'ParentProcessId=100',
'ProcessId=102',
''
].join('\r\n'),
stderr: ''
})
}
)
await expect(
resolveAgentForegroundProcess(100, 'powershell.exe', {
contextPaths: ['C:\\repo\\orca']
})
).resolves.toBe('codex')
})
it('recognizes the deepest Windows shell-rooted agent when candidates share one lineage', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout: [
'CommandLine=powershell.exe',
'CreationDate=20260616110000.000000-000',
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'Name=powershell.exe',
'ParentProcessId=99',
'ProcessId=100',
'',
'CommandLine=codex --cwd C:\\repo\\orca',
'CreationDate=20260616110100.000000-000',
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
'Name=codex.exe',
'ParentProcessId=100',
'ProcessId=101',
'',
'CommandLine=gemini --cwd C:\\repo\\orca',
'CreationDate=20260616110200.000000-000',
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\gemini.cmd',
'Name=gemini.exe',
'ParentProcessId=101',
'ProcessId=102',
''
].join('\r\n'),
stderr: ''
})
}
)
await expect(
resolveAgentForegroundProcess(100, 'powershell.exe', {
contextPaths: ['C:\\repo\\orca']
})
).resolves.toBe('gemini')
})
it('fails closed for sibling Windows agents that both match the same worktree path', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout: [
'CommandLine=powershell.exe',
'CreationDate=20260616110000.000000-000',
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'Name=powershell.exe',
'ParentProcessId=99',
'ProcessId=100',
'',
'CommandLine=codex --cwd C:\\repo\\orca',
'CreationDate=20260616110100.000000-000',
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
'Name=codex.exe',
'ParentProcessId=100',
'ProcessId=101',
'',
'CommandLine=gemini --cwd C:\\repo\\orca',
'CreationDate=20260616110200.000000-000',
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\gemini.cmd',
'Name=gemini.exe',
'ParentProcessId=100',
'ProcessId=102',
''
].join('\r\n'),
stderr: ''
})
}
)
await expect(
resolveAgentForegroundProcess(100, 'powershell.exe', {
contextPaths: ['C:\\repo\\orca']
})
).resolves.toBe('powershell.exe')
})
it('fails closed when Windows has multiple matching wrapper descendants', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
execFileMock.mockImplementation(
+12 -144
View File
@@ -1,10 +1,13 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcessFromCommandLine
} from '../../shared/agent-process-recognition'
resolveWindowsAgentForegroundProcess,
shouldInspectWindowsAgentForeground,
type AgentForegroundResolutionOptions
} from './windows-agent-foreground-process'
export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process'
const execFileAsync = promisify(execFile)
@@ -15,13 +18,6 @@ type ProcessRow = {
command: string
}
type WindowsProcessRow = {
pid: number
ppid: number
name: string
command: string
}
function parsePsRows(stdout: string): ProcessRow[] {
const rows: ProcessRow[] = []
for (const line of stdout.split('\n')) {
@@ -39,49 +35,6 @@ function parsePsRows(stdout: string): ProcessRow[] {
return rows
}
function parseWindowsProcessRows(stdout: string): WindowsProcessRow[] {
const rows: WindowsProcessRow[] = []
let command = ''
let name = ''
let pid = Number.NaN
let ppid = Number.NaN
const flush = (): void => {
if (Number.isFinite(pid) && Number.isFinite(ppid)) {
rows.push({ pid, ppid, name, command: command || name })
}
command = ''
name = ''
pid = Number.NaN
ppid = Number.NaN
}
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim()
if (!line) {
flush()
continue
}
const eq = line.indexOf('=')
if (eq < 0) {
continue
}
const key = line.slice(0, eq)
const value = line.slice(eq + 1)
if (key === 'CommandLine') {
command = value
} else if (key === 'Name') {
name = value
} else if (key === 'ParentProcessId') {
ppid = Number.parseInt(value, 10)
} else if (key === 'ProcessId') {
pid = Number.parseInt(value, 10)
}
}
flush()
return rows
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
rows: Row[],
rootPid: number
@@ -114,18 +67,20 @@ function candidateScore(row: ProcessRow & { depth: number }): number {
export async function resolveAgentForegroundProcess(
shellPid: number | null | undefined,
fallbackProcess: string | null
fallbackProcess: string | null,
options: AgentForegroundResolutionOptions = {}
): Promise<string | null> {
if (!shellPid) {
return fallbackProcess
}
if (process.platform === 'win32') {
if (!fallbackProcess || !isAgentForegroundWrapperProcess(fallbackProcess)) {
if (!fallbackProcess || !shouldInspectWindowsAgentForeground(fallbackProcess)) {
return fallbackProcess
}
return (
(await resolveAgentForegroundProcessFromWindows(shellPid, fallbackProcess)) ?? fallbackProcess
(await resolveWindowsAgentForegroundProcess(shellPid, fallbackProcess, options)) ??
fallbackProcess
)
}
@@ -143,93 +98,6 @@ export async function resolveAgentForegroundProcess(
return fallbackProcess
}
async function resolveAgentForegroundProcessFromWindows(
shellPid: number,
fallbackProcess: string
): Promise<string | null> {
const stdout =
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
return stdout
? resolveAgentForegroundProcessFromWindowsRows(stdout, shellPid, fallbackProcess)
: null
}
async function queryWindowsProcessesWithPowerShell(): Promise<string | null> {
try {
const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'Get-CimInstance Win32_Process | ForEach-Object { "CommandLine=$($_.CommandLine)"; "Name=$($_.Name)"; "ParentProcessId=$($_.ParentProcessId)"; "ProcessId=$($_.ProcessId)"; "" }'
],
{
encoding: 'utf8',
timeout: 3000,
maxBuffer: 8 * 1024 * 1024
}
)
return stdout
} catch {
return null
}
}
async function queryWindowsProcessesWithWmic(): Promise<string | null> {
try {
const { stdout } = await execFileAsync(
'wmic',
['process', 'get', 'CommandLine,Name,ParentProcessId,ProcessId', '/format:value'],
{
encoding: 'utf8',
timeout: 3000,
maxBuffer: 8 * 1024 * 1024
}
)
return stdout
} catch {
// Best-effort: Windows process enumeration may be disabled, so callers
// still fall back to node-pty's process name when both probes fail.
return null
}
}
function resolveAgentForegroundProcessFromWindowsRows(
stdout: string,
shellPid: number,
fallbackProcess: string
): string | null {
const candidates = collectDescendants(parseWindowsProcessRows(stdout), shellPid).sort(
(a, b) => b.depth - a.depth
)
const wrapperCandidates = candidates.filter((candidate) =>
windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess)
)
if (wrapperCandidates.length !== 1) {
return null
}
const [candidate] = wrapperCandidates
const recognized =
recognizeAgentProcessFromCommandLine(candidate.command) ??
recognizeAgentProcessFromCommandLine(candidate.name)
if (recognized) {
return recognized.processName
}
return null
}
function windowsCandidateMatchesFallbackWrapper(
candidate: WindowsProcessRow,
fallbackProcess: string
): boolean {
const commandToken = candidate.command.trim().split(/\s+/, 1)[0] ?? ''
return (
isExpectedAgentProcess(candidate.name, fallbackProcess) ||
isExpectedAgentProcess(commandToken, fallbackProcess)
)
}
function resolveAgentForegroundProcessFromPs(stdout: string, shellPid: number): string | null {
const rows = parsePsRows(stdout)
const shellRow = rows.find((row) => row.pid === shellPid)
+30 -2
View File
@@ -7,14 +7,16 @@ const {
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
spawnMock
spawnMock,
resolveAgentForegroundProcessMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
spawnMock: vi.fn()
spawnMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn()
}))
vi.mock('fs', () => ({
@@ -37,6 +39,10 @@ vi.mock('node-pty', () => ({
spawn: spawnMock
}))
vi.mock('./agent-foreground-process', () => ({
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
}))
vi.mock('../wsl', () => ({
parseWslPath: (path: string) => {
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
@@ -82,6 +88,10 @@ describe('LocalPtyProvider', () => {
accessSyncMock.mockReturnValue(undefined)
mkdirSyncMock.mockReset()
writeFileSyncMock.mockReset()
resolveAgentForegroundProcessMock.mockReset()
resolveAgentForegroundProcessMock.mockImplementation(
async (_pid: number, fallbackProcess: string | null) => fallbackProcess
)
exitCb = undefined
mockProc = {
@@ -650,6 +660,24 @@ describe('LocalPtyProvider', () => {
expect(await provider.getForegroundProcess(id)).toBe('zsh')
})
it('uses the spawned Windows shell when node-pty reports only the terminal name', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'xterm-256color'
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
expect(await provider.getForegroundProcess(id)).toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
mockProc.pid,
'powershell.exe',
expect.any(Object)
)
})
it('returns null for unknown PTY ids', async () => {
expect(await provider.getForegroundProcess('nonexistent')).toBeNull()
})
+35 -1
View File
@@ -40,12 +40,14 @@ import {
} from '../git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcess } from './agent-foreground-process'
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
const ptyShellName = new Map<string, string>()
const ptyAgentForegroundContextPaths = new Map<string, string[]>()
// Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction
// callbacks. If the PTY is killed without disposing these listeners, the
// stale callbacks survive into node::FreeEnvironment() where NAPI attempts
@@ -133,6 +135,7 @@ function clearPtyState(id: string): void {
disposePtyListeners(id)
ptyProcesses.delete(id)
ptyShellName.delete(id)
ptyAgentForegroundContextPaths.delete(id)
ptyLoadGeneration.delete(id)
}
@@ -156,6 +159,26 @@ function normalizeLocalCallerSessionId(sessionId: string | undefined): string |
return requested
}
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
return null
}
return trimmed.split(/[\\/]/).pop() || null
}
function resolveForegroundFallbackProcess(
processName: string | null | undefined,
shellName: string | undefined
): string | null {
if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) {
return processName || null
}
// Why: Windows node-pty can expose only the terminal name (`xterm-256color`).
// The spawned shell is the best fallback for agent foreground enrichment.
return shellName ?? processName ?? null
}
function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void {
// Why: node-pty's UnixTerminal.destroy() closes the master socket, which
// releases the ptmx fd to the OS — without this call the fd leaks until GC
@@ -507,6 +530,10 @@ export class LocalPtyProvider implements IPtyProvider {
const proc = spawnResult.process
ptyProcesses.set(id, proc)
ptyShellName.set(id, basename(shellPath))
ptyAgentForegroundContextPaths.set(
id,
getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId })
)
ptyLoadGeneration.set(id, loadGeneration)
this.opts.onSpawned?.(id)
@@ -644,6 +671,7 @@ export class LocalPtyProvider implements IPtyProvider {
destroyPtyProcess(proc, { alreadyKilled: true })
ptyProcesses.delete(id)
ptyShellName.delete(id)
ptyAgentForegroundContextPaths.delete(id)
ptyLoadGeneration.delete(id)
this.opts.onExit?.(id, -1)
for (const cb of exitListeners) {
@@ -711,7 +739,13 @@ export class LocalPtyProvider implements IPtyProvider {
return null
}
try {
return await resolveAgentForegroundProcess(proc.pid, proc.process || null)
return await resolveAgentForegroundProcess(
proc.pid,
resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)),
{
contextPaths: ptyAgentForegroundContextPaths.get(id)
}
)
} catch {
return null
}
@@ -0,0 +1,217 @@
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcessFromCommandLine
} from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
import {
queryWindowsProcessDescendants,
type WindowsProcessCandidate,
type WindowsProcessRow
} from './windows-foreground-process-rows'
export type AgentForegroundResolutionOptions = {
contextPaths?: readonly string[]
}
export function shouldInspectWindowsAgentForeground(fallbackProcess: string): boolean {
return isAgentForegroundWrapperProcess(fallbackProcess) || isShellProcess(fallbackProcess)
}
export async function resolveWindowsAgentForegroundProcess(
shellPid: number,
fallbackProcess: string,
options: AgentForegroundResolutionOptions
): Promise<string | null> {
const candidates = await queryWindowsProcessDescendants(shellPid)
if (!candidates) {
return null
}
if (isShellProcess(fallbackProcess)) {
return resolveShellForegroundProcessFromWindowsCandidates(candidates, options.contextPaths)
}
const wrapperCandidates = candidates.filter((candidate) =>
windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess)
)
if (wrapperCandidates.length !== 1) {
return resolveWrapperForegroundProcessFromWindowsCandidates(
wrapperCandidates,
options.contextPaths
)
}
const [candidate] = wrapperCandidates
const recognized =
recognizeAgentProcessFromCommandLine(candidate.command) ??
recognizeAgentProcessFromCommandLine(candidate.name)
if (recognized) {
return recognized.processName
}
return null
}
function resolveShellForegroundProcessFromWindowsCandidates(
candidates: readonly WindowsProcessCandidate[],
contextPaths: readonly string[] | undefined
): string | null {
const recognizedCandidates = createRecognizedWindowsProcessCandidates(candidates, contextPaths)
const contextCandidates = recognizedCandidates.filter((candidate) => candidate.contextMatch)
if (contextCandidates.length > 0) {
return resolveRecognizedWindowsProcessCandidates(contextCandidates, candidates)
}
return resolveRecognizedWindowsProcessCandidates(recognizedCandidates, candidates)
}
function resolveWrapperForegroundProcessFromWindowsCandidates(
candidates: readonly WindowsProcessCandidate[],
contextPaths: readonly string[] | undefined
): string | null {
const contextCandidates = createRecognizedWindowsProcessCandidates(
candidates,
contextPaths
).filter((candidate) => candidate.contextMatch)
return contextCandidates.length > 0
? resolveRecognizedWindowsProcessCandidates(contextCandidates, candidates)
: null
}
type RecognizedWindowsProcessCandidate = WindowsProcessRow & {
contextMatch: boolean
depth: number
processName: string
}
function createRecognizedWindowsProcessCandidates(
candidates: readonly WindowsProcessCandidate[],
contextPaths: readonly string[] | undefined
): RecognizedWindowsProcessCandidate[] {
const normalizedContextPaths = normalizeContextPaths(contextPaths)
return candidates.flatMap((candidate) => {
const recognized = recognizeWindowsProcessCandidate(candidate)
if (!recognized) {
return []
}
return [
{
...candidate,
contextMatch: candidateMatchesContextPath(candidate, normalizedContextPaths),
processName: recognized
}
]
})
}
function resolveRecognizedWindowsProcessCandidates(
recognizedCandidates: readonly RecognizedWindowsProcessCandidate[],
allCandidates: readonly WindowsProcessCandidate[]
): string | null {
if (recognizedCandidates.length === 0) {
return null
}
const recognizedProcessNames = new Set(
recognizedCandidates.map((candidate) => candidate.processName)
)
if (recognizedProcessNames.size === 1) {
return [...recognizedProcessNames][0]
}
const candidatesByPid = new Map(allCandidates.map((candidate) => [candidate.pid, candidate]))
const leafCandidates = recognizedCandidates.filter(
(candidate) =>
!recognizedCandidates.some(
(other) =>
other.pid !== candidate.pid &&
windowsCandidateIsAncestor(candidate, other, candidatesByPid)
)
)
const leafProcessNames = new Set(leafCandidates.map((candidate) => candidate.processName))
// Why: Windows lacks a cheap PTY foreground marker like POSIX '+'. A single
// recognized lineage leaf is strong enough; sibling agent leaves are not.
return leafProcessNames.size === 1 ? [...leafProcessNames][0] : null
}
function windowsCandidateIsAncestor(
candidate: WindowsProcessRow,
other: WindowsProcessRow,
candidatesByPid: ReadonlyMap<number, WindowsProcessRow>
): boolean {
let current = candidatesByPid.get(other.ppid)
while (current) {
if (current.pid === candidate.pid) {
return true
}
current = candidatesByPid.get(current.ppid)
}
return false
}
function normalizeContextPaths(contextPaths: readonly string[] | undefined): string[] {
const normalized = new Set<string>()
for (const contextPath of contextPaths ?? []) {
const candidate = normalizePathForCommandMatch(contextPath)
if (isSafeContextPath(candidate)) {
normalized.add(candidate)
}
}
return [...normalized].sort((a, b) => b.length - a.length)
}
function isSafeContextPath(contextPath: string): boolean {
return contextPath.length >= 4 && (/^[a-z]:\//.test(contextPath) || contextPath.startsWith('//'))
}
function candidateMatchesContextPath(
candidate: WindowsProcessRow,
normalizedContextPaths: readonly string[]
): boolean {
if (normalizedContextPaths.length === 0) {
return false
}
const haystack = normalizePathForCommandMatch(
[candidate.command, candidate.executablePath].filter(Boolean).join('\n')
)
return normalizedContextPaths.some((contextPath) =>
commandLineContainsPath(haystack, contextPath)
)
}
function normalizePathForCommandMatch(value: string): string {
return value
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\\/g, '/')
.replace(/\/+$/g, '')
.toLowerCase()
}
function commandLineContainsPath(haystack: string, contextPath: string): boolean {
let index = haystack.indexOf(contextPath)
while (index !== -1) {
const before = index > 0 ? haystack[index - 1] : ''
const after = haystack[index + contextPath.length] ?? ''
const beforeOk = !before || /[\s"'(=]/.test(before)
const afterOk = !after || after === '/' || /[\s"'),;]/.test(after)
if (beforeOk && afterOk) {
return true
}
index = haystack.indexOf(contextPath, index + 1)
}
return false
}
function recognizeWindowsProcessCandidate(candidate: WindowsProcessRow): string | null {
const recognized =
recognizeAgentProcessFromCommandLine(candidate.command) ??
recognizeAgentProcessFromCommandLine(candidate.name)
return recognized?.processName ?? null
}
function windowsCandidateMatchesFallbackWrapper(
candidate: WindowsProcessRow,
fallbackProcess: string
): boolean {
const commandToken = candidate.command.trim().split(/\s+/, 1)[0] ?? ''
return (
isExpectedAgentProcess(candidate.name, fallbackProcess) ||
isExpectedAgentProcess(commandToken, fallbackProcess)
)
}
@@ -0,0 +1,206 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000
// Why: CommandLine can contain CR/LF text. JSON keeps process fields structured
// so an argument cannot masquerade as another `Name=` / `ProcessId=` row.
const POWERSHELL_PROCESS_QUERY =
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' +
'Get-CimInstance -ClassName Win32_Process ' +
'-Property CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
'Select-Object CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
'ConvertTo-Json -Compress'
export type WindowsProcessRow = {
pid: number
ppid: number
name: string
command: string
executablePath: string
}
export type WindowsProcessCandidate = WindowsProcessRow & { depth: number }
export async function queryWindowsProcessDescendants(
rootPid: number
): Promise<WindowsProcessCandidate[] | null> {
const rows =
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
return rows ? collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth) : null
}
function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] {
const rows: WindowsProcessRow[] = []
let command = ''
let executablePath = ''
let name = ''
let pid = Number.NaN
let ppid = Number.NaN
const flush = (): void => {
if (Number.isFinite(pid) && Number.isFinite(ppid)) {
rows.push({ pid, ppid, name, command: command || name, executablePath })
}
command = ''
executablePath = ''
name = ''
pid = Number.NaN
ppid = Number.NaN
}
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim()
if (!line) {
flush()
continue
}
const eq = line.indexOf('=')
if (eq < 0) {
continue
}
const key = line.slice(0, eq)
const value = line.slice(eq + 1)
if (key === 'CommandLine') {
command = value
} else if (key === 'ExecutablePath') {
executablePath = value
} else if (key === 'Name') {
name = value
} else if (key === 'ParentProcessId') {
ppid = Number.parseInt(value, 10)
} else if (key === 'ProcessId') {
pid = Number.parseInt(value, 10)
}
}
flush()
return rows
}
type WindowsProcessJsonRow = {
CommandLine?: unknown
ExecutablePath?: unknown
Name?: unknown
ParentProcessId?: unknown
ProcessId?: unknown
}
function parseWindowsProcessJsonRows(stdout: string): WindowsProcessRow[] | null {
const trimmed = stdout.trim()
if (!trimmed) {
return []
}
try {
const parsed = JSON.parse(trimmed) as unknown
const items = Array.isArray(parsed) ? parsed : [parsed]
return items.flatMap((item) => {
if (!item || typeof item !== 'object') {
return []
}
const row = item as WindowsProcessJsonRow
const pid = numberFromWindowsProcessField(row.ProcessId)
const ppid = numberFromWindowsProcessField(row.ParentProcessId)
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) {
return []
}
const name = stringFromWindowsProcessField(row.Name)
const command = stringFromWindowsProcessField(row.CommandLine) || name
return [
{
pid,
ppid,
name,
command,
executablePath: stringFromWindowsProcessField(row.ExecutablePath)
}
]
})
} catch {
return null
}
}
function stringFromWindowsProcessField(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
return String(value)
}
function numberFromWindowsProcessField(value: unknown): number {
if (typeof value === 'number') {
return value
}
if (typeof value === 'string') {
return Number.parseInt(value, 10)
}
return Number.NaN
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
rows: Row[],
rootPid: number
): (Row & { depth: number })[] {
const childrenByParent = new Map<number, Row[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (Row & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of childrenByParent.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
async function queryWindowsProcessesWithPowerShell(): Promise<WindowsProcessRow[] | null> {
try {
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', POWERSHELL_PROCESS_QUERY],
{
encoding: 'utf8',
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024
}
)
const rows = parseWindowsProcessJsonRows(stdout)
return rows && rows.length > 0 ? rows : null
} catch {
return null
}
}
async function queryWindowsProcessesWithWmic(): Promise<WindowsProcessRow[] | null> {
try {
const { stdout } = await execFileAsync(
'wmic',
[
'process',
'get',
'CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId',
'/format:value'
],
{
encoding: 'utf8',
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024
}
)
return parseWindowsProcessValueRows(stdout)
} catch {
// Best-effort: Windows process enumeration may be disabled, so callers
// still fall back to node-pty's process name when both probes fail.
return null
}
}
+70
View File
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
generateCommitMessageFromContext: vi.fn(),
generatePullRequestFieldsFromContext: vi.fn(),
resolveCommitMessageSettings: vi.fn(),
resolveHostedReviewBodyForGeneration: vi.fn(),
getSshGitProvider: vi.fn()
}))
@@ -53,6 +54,10 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: mocks.getSshGitProvider
}))
vi.mock('../source-control/pull-request-template', () => ({
resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration
}))
const tempDirs: string[] = []
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
@@ -86,6 +91,8 @@ describe('RuntimeGitCommands', () => {
mocks.generateCommitMessageFromContext.mockReset()
mocks.generatePullRequestFieldsFromContext.mockReset()
mocks.resolveCommitMessageSettings.mockReset()
mocks.resolveHostedReviewBodyForGeneration.mockReset()
mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body)
mocks.getSshGitProvider.mockReset()
mocks.checkoutBranch.mockReset()
mocks.listLocalBranches.mockReset()
@@ -409,6 +416,69 @@ describe('RuntimeGitCommands', () => {
)
})
it('loads the hosted review template before generating pull-request fields', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const templateBody = '## Summary\n\n## Testing\n\n- [ ] Required checks'
const context = {
base: 'main',
branch: 'feature/template-aware-pr',
branchChangedByPreparation: false,
commitSummary: 'abc123 feat: test',
changeSummary: 'M README.md',
patch: '+hello',
currentTitle: '',
currentBody: templateBody,
currentDraft: false
}
const sourceControlAiResolvedParams = {
agentId: 'codex' as const,
model: 'gpt-5.5'
}
mocks.resolveHostedReviewBodyForGeneration.mockResolvedValue(templateBody)
mocks.getPullRequestDraftContext.mockResolvedValue(context)
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({
success: true,
fields: {
base: 'main',
title: 'Use existing template',
body: templateBody,
draft: false
}
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await commands.generateRuntimePullRequestFields(
'id:wt-1',
{
base: 'main',
title: '',
body: '',
draft: false,
provider: 'gitlab',
useTemplate: true
},
{ sourceControlAiResolvedParams }
)
expect(mocks.resolveHostedReviewBodyForGeneration).toHaveBeenCalledWith({
body: '',
repoPath: worktreePath,
connectionId: undefined,
provider: 'gitlab',
useTemplate: true
})
expect(mocks.getPullRequestDraftContext).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
currentBody: templateBody
})
)
})
it('resolves remote commit-message settings against the SSH host cache', async () => {
const worktreePath = '/remote/repo'
const context = {
+19 -3
View File
@@ -71,6 +71,8 @@ import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-mes
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
import { gitExecFileAsync } from '../git/runner'
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
import type { HostedReviewProvider } from '../../shared/hosted-review'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
type RuntimeCommitMessageSettingsOverride = Partial<
@@ -580,7 +582,14 @@ export class RuntimeGitCommands {
async generateRuntimePullRequestFields(
worktreeSelector: string,
input: { base: string; title: string; body: string; draft: boolean },
input: {
base: string
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
},
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GeneratePullRequestFieldsResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
@@ -612,11 +621,18 @@ export class RuntimeGitCommands {
}
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: input.body,
repoPath: target.worktree.path,
connectionId: target.connectionId,
provider: input.provider,
useTemplate: input.useTemplate
})
context = target.connectionId
? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), {
base: input.base,
currentTitle: input.title,
currentBody: input.body,
currentBody,
currentDraft: input.draft
})
: await getPullRequestDraftContext(
@@ -624,7 +640,7 @@ export class RuntimeGitCommands {
{
base: input.base,
currentTitle: input.title,
currentBody: input.body,
currentBody,
currentDraft: input.draft
}
)
+26 -3
View File
@@ -16034,6 +16034,10 @@ describe('OrcaRuntimeService', () => {
getRepos: () => [localRepo],
getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined)
}
getGitLabProjectRefForRemoteMock.mockResolvedValue({
host: 'gitlab.example',
path: 'group/repo'
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'fetch') {
@@ -16050,13 +16054,21 @@ describe('OrcaRuntimeService', () => {
repoSelector: 'id:repo-1',
mrIid: 42,
sourceBranch: 'contrib/fix',
targetBranch: 'main',
isCrossRepository: true
})
expect(result).toEqual({ baseBranch: 'fork-mr-sha' })
expect(result).toEqual({
baseBranch: 'fork-mr-sha',
compareBaseRef: 'refs/remotes/origin/main'
})
expect(gitSpy).toHaveBeenCalledWith(['fetch', 'origin', 'refs/merge-requests/42/head'], {
cwd: TEST_REPO_PATH
})
expect(gitSpy).toHaveBeenCalledWith(
['fetch', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: TEST_REPO_PATH }
)
expect(gitSpy).toHaveBeenCalledWith(['rev-parse', '--verify', 'FETCH_HEAD'], {
cwd: TEST_REPO_PATH
})
@@ -16089,7 +16101,8 @@ describe('OrcaRuntimeService', () => {
return { stdout: 'remote-fork-mr-sha\n', stderr: '' }
}
throw new Error(`unexpected git call: ${args.join(' ')}`)
})
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined)
}
registerSshGitProvider('ssh-1', provider as never)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal'])
@@ -16099,14 +16112,24 @@ describe('OrcaRuntimeService', () => {
repoSelector: 'id:repo-1',
mrIid: 77,
sourceBranch: 'contrib/remote-fix',
targetBranch: 'main',
isCrossRepository: true
})
expect(result).toEqual({ baseBranch: 'remote-fork-mr-sha' })
expect(result).toEqual({
baseBranch: 'remote-fork-mr-sha',
compareBaseRef: 'refs/remotes/origin/main'
})
expect(provider.exec).toHaveBeenCalledWith(
['fetch', 'origin', 'refs/merge-requests/77/head'],
'/remote/repo'
)
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
expect(provider.exec).toHaveBeenCalledWith(
['rev-parse', '--verify', 'FETCH_HEAD'],
'/remote/repo'
+43 -6
View File
@@ -10490,6 +10490,7 @@ export class OrcaRuntimeService {
repoSelector: string
name: string
baseBranch?: string
compareBaseRef?: string
branchNameOverride?: string
linkedIssue?: number | null
linkedPR?: number | null
@@ -10959,9 +10960,9 @@ export class OrcaRuntimeService {
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
// Why: persisted compare refs must survive local branches whose names look
// like remote labels, e.g. a local branch literally named "origin/main".
const metadataBaseRef = remoteTrackingBase?.ref ?? baseBranch
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
// Control must compare against the review target branch.
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
const displayNameMeta = requestedDisplayName
? { displayName: requestedDisplayName }
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
@@ -11263,6 +11264,7 @@ export class OrcaRuntimeService {
args: {
name: string
baseBranch?: string
compareBaseRef?: string
branchNameOverride?: string
linkedIssue?: number | null
linkedPR?: number | null
@@ -11308,6 +11310,7 @@ export class OrcaRuntimeService {
name: args.name,
...(args.displayName ? { displayName: args.displayName } : {}),
...(args.baseBranch ? { baseBranch: args.baseBranch } : {}),
...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}),
...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}),
...(args.runHooks ? { setupDecision: 'run' as const } : {}),
...(!args.runHooks && args.setupDecision ? { setupDecision: args.setupDecision } : {}),
@@ -12000,6 +12003,7 @@ export class OrcaRuntimeService {
repoSelector: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}): Promise<GitHubPrStartPoint | { error: string }> {
if (!this.store) {
@@ -12049,6 +12053,7 @@ export class OrcaRuntimeService {
repoPath: repo.path,
prNumber: args.prNumber,
headRefName: args.headRefName,
baseRefName: args.baseRefName,
isCrossRepository: args.isCrossRepository,
connectionId: repo.connectionId ?? null,
gitExec,
@@ -12061,8 +12066,11 @@ export class OrcaRuntimeService {
repoSelector: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> {
}): Promise<
{ baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string }
> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
@@ -12081,6 +12089,7 @@ export class OrcaRuntimeService {
: (gitArgs: string[]) => gitExecFileAsync(gitArgs, { cwd: repo.path })
let sourceBranch = args.sourceBranch?.trim() ?? ''
let targetBranch = args.targetBranch?.trim() ?? ''
let isCrossRepository = args.isCrossRepository === true
if (!sourceBranch) {
@@ -12115,6 +12124,7 @@ export class OrcaRuntimeService {
return { error: `MR !${args.mrIid} not found.` }
}
sourceBranch = (item.branchName ?? '').trim()
targetBranch = (item.baseRefName ?? '').trim()
if (!sourceBranch) {
return { error: `MR !${args.mrIid} has no source branch.` }
}
@@ -12133,6 +12143,21 @@ export class OrcaRuntimeService {
} catch (error) {
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
}
const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined
const fetchTargetBranch = async (): Promise<{ error: string } | null> => {
if (!targetBranch || !compareBaseRef) {
return null
}
try {
await (sshGitProvider
? sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, targetBranch, compareBaseRef)
: gitExec(['fetch', remote, `+refs/heads/${targetBranch}:${compareBaseRef}`]))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { error: `Failed to fetch ${remote}/${targetBranch}: ${message.split('\n')[0]}` }
}
return null
}
if (isCrossRepository) {
const mrRef = `refs/merge-requests/${args.mrIid}/head`
@@ -12154,7 +12179,11 @@ export class OrcaRuntimeService {
if (!sha) {
return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` }
}
return { baseBranch: sha }
const targetFetchError = await fetchTargetBranch()
if (targetFetchError) {
return targetFetchError
}
return { baseBranch: sha, ...(compareBaseRef ? { compareBaseRef } : {}) }
}
try {
@@ -12174,7 +12203,15 @@ export class OrcaRuntimeService {
} catch {
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
}
return { baseBranch: remoteRef, pushTarget: { remoteName: remote, branchName: sourceBranch } }
const targetFetchError = await fetchTargetBranch()
if (targetFetchError) {
return targetFetchError
}
return {
baseBranch: remoteRef,
...(compareBaseRef ? { compareBaseRef } : {}),
pushTarget: { remoteName: remote, branchName: sourceBranch }
}
}
private async resolveGitLabIssueSourceRemote(
+5 -1
View File
@@ -172,7 +172,11 @@ export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
base: z.string().min(1, 'Missing base branch'),
title: z.string(),
body: z.string(),
draft: z.boolean()
draft: z.boolean(),
provider: z
.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported'])
.optional(),
useTemplate: z.boolean().optional()
})
export const GitBulkPaths = WorktreeSelector.extend({
+10 -1
View File
@@ -512,13 +512,22 @@ describe('git RPC methods', () => {
title: '',
body: '',
draft: false,
provider: 'github',
useTemplate: true,
sourceControlAiResolvedParams
})
)
expect(runtime.generateRuntimePullRequestFields).toHaveBeenCalledWith(
'id:wt-1',
{ base: 'main', title: '', body: '', draft: false },
{
base: 'main',
title: '',
body: '',
draft: false,
provider: 'github',
useTemplate: true
},
{ sourceControlAiResolvedParams }
)
})
+3 -1
View File
@@ -278,7 +278,9 @@ export const GIT_METHODS: RpcMethod[] = [
base: params.base,
title: params.title,
body: params.body,
draft: params.draft
draft: params.draft,
provider: params.provider,
useTemplate: params.useTemplate
}
const override = buildCommitMessageGenerationOverride(params)
if (override === undefined) {
@@ -55,6 +55,7 @@ export const WorktreeCreate = z
.pipe(z.string().min(1, 'Missing repo selector')),
name: OptionalString,
baseBranch: OptionalString,
compareBaseRef: OptionalString,
branchNameOverride: OptionalString,
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
@@ -231,6 +232,7 @@ export const WorktreeResolvePrBase = z.object({
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
.pipe(z.number().int().positive('Missing PR number')),
headRefName: OptionalString,
baseRefName: OptionalString,
isCrossRepository: OptionalBoolean
})
@@ -244,5 +246,6 @@ export const WorktreeResolveMrBase = z.object({
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
.pipe(z.number().int().positive('Missing MR number')),
sourceBranch: OptionalString,
targetBranch: OptionalString,
isCrossRepository: OptionalBoolean
})
+3
View File
@@ -63,6 +63,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
repoSelector: params.repo,
name: params.name ?? '',
baseBranch: params.baseBranch,
compareBaseRef: params.compareBaseRef,
branchNameOverride: params.branchNameOverride,
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
@@ -172,6 +173,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
repoSelector: params.repo,
prNumber: params.prNumber,
headRefName: params.headRefName,
baseRefName: params.baseRefName,
isCrossRepository: params.isCrossRepository
})
}),
@@ -183,6 +185,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
repoSelector: params.repo,
mrIid: params.mrIid,
sourceBranch: params.sourceBranch,
targetBranch: params.targetBranch,
isCrossRepository: params.isCrossRepository
})
}),
+88 -2
View File
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
getAzureDevOpsRepoSlugMock,
getBitbucketRepoSlugMock,
getGiteaRepoSlugMock,
@@ -13,6 +15,8 @@ const {
} = vi.hoisted(() => ({
createGitHubPullRequestMock: vi.fn(),
createGitLabMergeRequestMock: vi.fn(),
createAzureDevOpsPullRequestMock: vi.fn(),
createGiteaPullRequestMock: vi.fn(),
getAzureDevOpsRepoSlugMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
getGiteaRepoSlugMock: vi.fn(),
@@ -50,12 +54,20 @@ vi.mock('../azure-devops/client', () => ({
getAzureDevOpsPullRequest: vi.fn()
}))
vi.mock('../azure-devops/pull-request-creation', () => ({
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock
}))
vi.mock('../gitea/client', () => ({
getGiteaRepoSlug: getGiteaRepoSlugMock,
getGiteaPullRequestForBranch: vi.fn(),
getGiteaPullRequest: vi.fn()
}))
vi.mock('../gitea/pull-request-creation', () => ({
createGiteaPullRequest: createGiteaPullRequestMock
}))
import {
FORGE_PROVIDERS,
detectHostedReviewProvider,
@@ -67,6 +79,8 @@ describe('forge provider interface', () => {
beforeEach(() => {
createGitHubPullRequestMock.mockReset()
createGitLabMergeRequestMock.mockReset()
createAzureDevOpsPullRequestMock.mockReset()
createGiteaPullRequestMock.mockReset()
getAzureDevOpsRepoSlugMock.mockReset()
getBitbucketRepoSlugMock.mockReset()
getGiteaRepoSlugMock.mockReset()
@@ -94,8 +108,8 @@ describe('forge provider interface', () => {
['gitlab', true],
['github', true],
['bitbucket', false],
['azure-devops', false],
['gitea', false]
['azure-devops', true],
['gitea', true]
])
createGitHubPullRequestMock.mockResolvedValue({
ok: true,
@@ -160,6 +174,78 @@ describe('forge provider interface', () => {
)
})
it('routes Azure DevOps review creation through the shared provider contract', async () => {
createAzureDevOpsPullRequestMock.mockResolvedValue({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
const provider = getForgeProviderById('azure-devops')
await expect(
provider.createReview?.(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
expect(createAzureDevOpsPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
})
it('routes Gitea review creation through the shared provider contract', async () => {
createGiteaPullRequestMock.mockResolvedValue({
ok: true,
number: 19,
url: 'https://git.example.com/team/orca/pulls/19'
})
const provider = getForgeProviderById('gitea')
await expect(
provider.createReview?.(
'/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 19,
url: 'https://git.example.com/team/orca/pulls/19'
})
expect(createGiteaPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
})
it('adapts GitHub branch lookup through the shared provider contract', async () => {
getPRForBranchMock.mockResolvedValue({
number: 7,
+9 -4
View File
@@ -11,6 +11,7 @@ import {
getAzureDevOpsPullRequestForBranch,
getAzureDevOpsRepoSlug
} from '../azure-devops/client'
import { createAzureDevOpsPullRequest } from '../azure-devops/pull-request-creation'
import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers'
import {
getBitbucketPullRequest,
@@ -23,6 +24,7 @@ import {
getGiteaPullRequestForBranch,
getGiteaRepoSlug
} from '../gitea/client'
import { createGiteaPullRequest } from '../gitea/pull-request-creation'
import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers'
import { createGitHubPullRequest, getPRForBranch, getRepoSlug } from '../github/client'
import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client'
@@ -80,6 +82,7 @@ function mapGitLabReview(mr: MRInfo): HostedReviewInfo {
updatedAt: mr.updatedAt,
mergeable: mr.mergeable,
...(mr.headSha ? { headSha: mr.headSha } : {}),
...(mr.baseRefName ? { baseRefName: mr.baseRefName } : {}),
...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {})
}
}
@@ -198,7 +201,7 @@ const bitbucketForgeProvider = {
const azureDevOpsForgeProvider = {
id: 'azure-devops',
supportsReviewCreation: false,
supportsReviewCreation: true,
resolveRepository: ({ repoPath, connectionId }) => getAzureDevOpsRepoSlug(repoPath, connectionId),
async getReviewForBranch(input) {
const pr = await getAzureDevOpsPullRequestForBranch(
@@ -212,12 +215,13 @@ const azureDevOpsForgeProvider = {
async getReviewByNumber(input) {
const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number, input.connectionId)
return pr ? mapAzureDevOpsReview(pr) : null
}
},
createReview: createAzureDevOpsPullRequest
} satisfies ForgeProvider
const giteaForgeProvider = {
id: 'gitea',
supportsReviewCreation: false,
supportsReviewCreation: true,
resolveRepository: ({ repoPath, connectionId }) => getGiteaRepoSlug(repoPath, connectionId),
async getReviewForBranch(input) {
const pr = await getGiteaPullRequestForBranch(
@@ -231,7 +235,8 @@ const giteaForgeProvider = {
async getReviewByNumber(input) {
const pr = await getGiteaPullRequest(input.repoPath, input.number, input.connectionId)
return pr ? mapGiteaReview(pr) : null
}
},
createReview: createGiteaPullRequest
} satisfies ForgeProvider
// Why: provider order preserves existing branch-status behavior when remotes
@@ -0,0 +1,48 @@
export class HostedReviewApiRequestError extends Error {
readonly status: number | null
readonly timedOut: boolean
constructor(message: string, options: { status?: number | null; timedOut?: boolean } = {}) {
super(message)
this.name = 'HostedReviewApiRequestError'
this.status = options.status ?? null
this.timedOut = options.timedOut ?? false
}
}
async function readResponseText(response: Response): Promise<string> {
try {
return await response.text()
} catch {
return ''
}
}
export async function requestHostedReviewJson<T>(
url: URL,
init: Omit<RequestInit, 'signal'>,
timeoutMs: number
): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, { ...init, signal: controller.signal })
if (!response.ok) {
const body = await readResponseText(response)
throw new HostedReviewApiRequestError(body || response.statusText, {
status: response.status
})
}
return (await response.json()) as T
} catch (error) {
if (error instanceof HostedReviewApiRequestError) {
throw error
}
if (error instanceof Error && error.name === 'AbortError') {
throw new HostedReviewApiRequestError('Request timed out', { timedOut: true })
}
throw error
} finally {
clearTimeout(timeout)
}
}
@@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
isAzureDevOpsReviewCreationAuthenticatedMock,
isGiteaReviewCreationAuthenticatedMock,
getRepoSlugMock,
getProjectSlugMock,
getBitbucketRepoSlugMock,
@@ -18,6 +22,10 @@ const {
} = vi.hoisted(() => ({
createGitHubPullRequestMock: vi.fn(),
createGitLabMergeRequestMock: vi.fn(),
createAzureDevOpsPullRequestMock: vi.fn(),
createGiteaPullRequestMock: vi.fn(),
isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(),
isGiteaReviewCreationAuthenticatedMock: vi.fn(),
getRepoSlugMock: vi.fn(),
getProjectSlugMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
@@ -59,12 +67,22 @@ vi.mock('../azure-devops/client', () => ({
getAzureDevOpsPullRequest: vi.fn()
}))
vi.mock('../azure-devops/pull-request-creation', () => ({
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock,
isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock
}))
vi.mock('../gitea/client', () => ({
getGiteaRepoSlug: getGiteaRepoSlugMock,
getGiteaPullRequestForBranch: vi.fn(),
getGiteaPullRequest: vi.fn()
}))
vi.mock('../gitea/pull-request-creation', () => ({
createGiteaPullRequest: createGiteaPullRequestMock,
isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock
}))
vi.mock('../github/gh-utils', () => ({
acquire: vi.fn(),
release: vi.fn(),
@@ -98,6 +116,10 @@ function resetMocks(): void {
for (const mock of [
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
isAzureDevOpsReviewCreationAuthenticatedMock,
isGiteaReviewCreationAuthenticatedMock,
getRepoSlugMock,
getProjectSlugMock,
getBitbucketRepoSlugMock,
@@ -130,6 +152,34 @@ function mockGitLabProvider(): void {
getGiteaRepoSlugMock.mockResolvedValue(null)
}
function mockAzureDevOpsProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue({
host: 'dev.azure.com',
project: 'Project',
repository: 'orca',
apiBaseUrl: 'https://dev.azure.com/acme/Project',
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca'
})
getGiteaRepoSlugMock.mockResolvedValue(null)
}
function mockGiteaProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
getGiteaRepoSlugMock.mockResolvedValue({
host: 'git.example.com',
owner: 'acme',
repo: 'orca',
apiBaseUrl: 'https://git.example.com/api/v1',
webBaseUrl: 'https://git.example.com'
})
}
describe('createHostedReview', () => {
beforeEach(() => {
resetMocks()
@@ -169,6 +219,18 @@ describe('createHostedReview', () => {
number: 44,
url: 'https://gitlab.com/acme/orca/-/merge_requests/44'
})
createAzureDevOpsPullRequestMock.mockResolvedValue({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
createGiteaPullRequestMock.mockResolvedValue({
ok: true,
number: 19,
url: 'https://git.example.com/acme/orca/pulls/19'
})
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
})
it('revalidates ahead commits before creating a GitHub pull request', async () => {
@@ -266,6 +328,66 @@ describe('createHostedReview', () => {
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
})
it('creates an Azure DevOps pull request after fresh main-process validation passes', async () => {
mockAzureDevOpsProvider()
await expect(
createHostedReview('/repo', {
provider: 'azure-devops',
base: 'main',
head: 'feature',
title: 'Feature'
})
).resolves.toEqual({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
expect(createAzureDevOpsPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature',
title: 'Feature'
},
undefined
)
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
})
it('creates a Gitea pull request after fresh main-process validation passes', async () => {
mockGiteaProvider()
await expect(
createHostedReview('/repo', {
provider: 'gitea',
base: 'main',
head: 'feature',
title: 'Feature'
})
).resolves.toEqual({
ok: true,
number: 19,
url: 'https://git.example.com/acme/orca/pulls/19'
})
expect(createGiteaPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature',
title: 'Feature'
},
undefined
)
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
})
it('uses the SSH git provider for remote hosted-review preflight', async () => {
const remoteGit = {
getStatus: vi.fn(async () => ({ entries: [], conflictOperation: 'unknown' })),
@@ -375,6 +497,8 @@ describe('getHostedReviewCreationEligibility', () => {
getHostedReviewForBranchMock.mockResolvedValue(null)
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' })
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
})
it('treats short remote base refs as the default branch name', async () => {
@@ -511,4 +635,54 @@ describe('getHostedReviewCreationEligibility', () => {
{ cwd: '/repo' }
)
})
it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => {
mockAzureDevOpsProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/azure',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'azure-devops',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/azure'
})
expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => {
mockGiteaProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/gitea',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'gitea',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/gitea'
})
expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
})
@@ -12,6 +12,12 @@ import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
supportsHostedReviewCreation,
type HostedReviewCreationProvider
} from '../../shared/hosted-review-creation-providers'
import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation'
import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation'
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
import type { GitUpstreamStatus } from '../../shared/types'
@@ -160,22 +166,56 @@ async function getHostedReviewUpstreamStatus(
function reviewCopy(provider: HostedReviewProvider): {
shortLabel: 'PR' | 'MR'
reviewLabel: 'pull request' | 'merge request'
providerName: 'GitHub' | 'GitLab'
authCommand: 'gh auth login' | 'glab auth login'
providerName: string
authInstruction: string
} {
return provider === 'gitlab'
? {
shortLabel: 'MR',
reviewLabel: 'merge request',
providerName: 'GitLab',
authCommand: 'glab auth login'
}
: {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'GitHub',
authCommand: 'gh auth login'
}
if (provider === 'gitlab') {
return {
shortLabel: 'MR',
reviewLabel: 'merge request',
providerName: 'GitLab',
authInstruction: 'Run glab auth login'
}
}
if (provider === 'azure-devops') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Azure DevOps',
authInstruction: 'Set ORCA_AZURE_DEVOPS_TOKEN'
}
}
if (provider === 'gitea') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Gitea',
authInstruction: 'Set ORCA_GITEA_TOKEN'
}
}
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'GitHub',
authInstruction: 'Run gh auth login'
}
}
async function isProviderAuthenticated(
provider: HostedReviewCreationProvider,
repoPath: string,
connectionId?: string | null
): Promise<boolean> {
if (provider === 'gitlab') {
return isGitLabAuthenticated(repoPath, connectionId)
}
if (provider === 'azure-devops') {
return isAzureDevOpsReviewCreationAuthenticated()
}
if (provider === 'gitea') {
return isGiteaReviewCreationAuthenticated()
}
return isGitHubAuthenticated(repoPath, connectionId)
}
function blockedCreateResultForReason(
@@ -187,7 +227,7 @@ function blockedCreateResultForReason(
auth_required: {
ok: false,
code: 'auth_required',
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: run ${copy.authCommand} in this environment.`
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: ${copy.authInstruction} in this environment.`
},
unsupported_provider: {
ok: false,
@@ -346,7 +386,7 @@ export async function getHostedReviewCreationEligibility(
nextAction: 'open_existing_review'
}
}
if (provider !== 'github' && provider !== 'gitlab') {
if (!supportsHostedReviewCreation(provider)) {
return {
...baseResult,
canCreate: false,
@@ -369,10 +409,7 @@ export async function getHostedReviewCreationEligibility(
if ((args.behind ?? 0) > 0) {
return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' }
}
const authenticated =
provider === 'gitlab'
? await isGitLabAuthenticated(args.repoPath, args.connectionId)
: await isGitHubAuthenticated(args.repoPath, args.connectionId)
const authenticated = await isProviderAuthenticated(provider, args.repoPath, args.connectionId)
if (!authenticated) {
return {
...baseResult,
@@ -392,7 +429,7 @@ export async function createHostedReview(
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'github' && input.provider !== 'gitlab') {
if (!supportsHostedReviewCreation(input.provider)) {
return {
ok: false,
code: 'unsupported_provider',
@@ -0,0 +1,82 @@
import { readFile } from 'fs/promises'
import { join } from 'path'
import type { HostedReviewProvider } from '../../shared/hosted-review'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
const PULL_REQUEST_TEMPLATE_CANDIDATES = [
'.github/pull_request_template.md',
'.github/PULL_REQUEST_TEMPLATE.md',
'.azuredevops/pull_request_template.md',
'.azuredevops/PULL_REQUEST_TEMPLATE.md',
'.gitea/pull_request_template.md',
'.gitea/PULL_REQUEST_TEMPLATE.md',
'pull_request_template.md',
'PULL_REQUEST_TEMPLATE.md',
'docs/pull_request_template.md',
'docs/PULL_REQUEST_TEMPLATE.md'
]
const MERGE_REQUEST_TEMPLATE_CANDIDATES = [
'.gitlab/merge_request_templates/Default.md',
'.gitlab/merge_request_templates/default.md',
'.gitlab/merge_request_template.md',
'.gitlab/MERGE_REQUEST_TEMPLATE.md'
]
function getTemplateCandidates(provider?: HostedReviewProvider | null): string[] {
if (provider === 'gitlab') {
return [...MERGE_REQUEST_TEMPLATE_CANDIDATES, ...PULL_REQUEST_TEMPLATE_CANDIDATES]
}
return PULL_REQUEST_TEMPLATE_CANDIDATES
}
export async function readHostedPullRequestTemplate(
repoPath: string,
connectionId?: string | null
): Promise<string> {
return readHostedReviewTemplate(repoPath, connectionId)
}
export async function readHostedReviewTemplate(
repoPath: string,
connectionId?: string | null,
provider?: HostedReviewProvider | null
): Promise<string> {
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
if (connectionId && !remoteProvider) {
return ''
}
for (const relativeCandidate of getTemplateCandidates(provider)) {
try {
if (remoteProvider) {
const result = await remoteProvider.readFile(
joinWorktreeRelativePath(repoPath, relativeCandidate)
)
if (result.isBinary) {
continue
}
return result.content
}
return await readFile(join(repoPath, relativeCandidate), 'utf8')
} catch {
// Try the next conventional hosted-review template path.
}
}
return ''
}
export async function resolveHostedReviewBodyForGeneration(args: {
body: string
repoPath: string
connectionId?: string | null
provider?: HostedReviewProvider | null
useTemplate?: boolean
}): Promise<string> {
if (!args.useTemplate || args.body.trim()) {
return args.body
}
// Why: generated non-empty bodies bypass provider-side template fallback, so
// preload the template into the AI context when the user asked to use it.
return readHostedReviewTemplate(args.repoPath, args.connectionId, args.provider)
}
+10 -2
View File
@@ -5,7 +5,8 @@ import type {
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewForBranchArgs,
HostedReviewInfo
HostedReviewInfo,
HostedReviewProvider
} from '../shared/hosted-review'
import type { NativeFileDropPayload } from '../shared/native-file-drop'
import type { AppIdentity } from '../shared/app-identity'
@@ -939,6 +940,7 @@ export type PreloadApi = {
repoId: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}) => Promise<GitHubPrStartPoint | { error: string }>
/** GitLab parallel of resolvePrBase. For same-project MRs returns
@@ -948,8 +950,12 @@ export type PreloadApi = {
repoId: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>
}) => Promise<
| { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget }
| { error: string }
>
remove: (args: {
worktreeId: string
force?: boolean
@@ -2248,6 +2254,8 @@ export type PreloadApi = {
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
connectionId?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: SourceControlAiSettings
+2
View File
@@ -2671,6 +2671,8 @@ const api = {
title: string
body: string
draft: boolean
provider?: unknown
useTemplate?: boolean
connectionId?: string
sourceControlAiResolvedParams?: unknown
sourceControlAi?: unknown
+98 -43
View File
@@ -30,6 +30,21 @@ function mockExecFile(
)
}
async function withProcessPlatform<T>(
platform: NodeJS.Platform,
run: () => T | Promise<T>
): Promise<T> {
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
if (descriptor) {
Object.defineProperty(process, 'platform', descriptor)
}
}
}
beforeEach(() => {
execFileMock.mockReset()
})
@@ -115,66 +130,106 @@ describe('getForegroundProcessName', () => {
})
it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join('\n')
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join(
'\n'
)
}
}
}
return new Error('unexpected command')
})
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex')
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex')
})
})
it('recognizes Windows SSH relay shell-rooted agent descendants', async () => {
await withProcessPlatform('win32', async () => {
mockExecFile((command) => {
if (command === 'powershell.exe') {
return {
stdout: JSON.stringify([
{
CommandLine: 'powershell.exe',
ExecutablePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
Name: 'powershell.exe',
ParentProcessId: 99,
ProcessId: 100
},
{
CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe',
Name: 'node.exe',
ParentProcessId: 100,
ProcessId: 101
}
])
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex')
})
})
it('recognizes SSH relay wrapped agents when no foreground marker is available', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs'
].join('\n')
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs'
].join('\n')
}
}
}
return new Error('unexpected command')
})
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini')
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini')
})
})
it('does not guess when SSH relay wrapper descendants are ambiguous', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/project/server.js',
'102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js'
].join('\n')
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/project/server.js',
'102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js'
].join('\n')
}
}
}
return new Error('unexpected command')
})
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
})
})
it('does not report a stopped SSH relay agent when another process has foreground', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 T node /home/dev/.local/bin/codex',
'102 100 S+ vim notes.txt'
].join('\n')
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 T node /home/dev/.local/bin/codex',
'102 100 S+ vim notes.txt'
].join('\n')
}
}
}
return new Error('unexpected command')
})
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
})
})
it('falls back to the root process command when descendant inspection fails', async () => {
+12
View File
@@ -10,6 +10,10 @@ import {
recognizeAgentProcessFromCommandLine
} from '../shared/agent-process-recognition'
import { isShellProcess } from '../shared/shell-process-detection'
import {
resolveWindowsAgentForegroundProcess,
shouldInspectWindowsAgentForeground
} from '../main/providers/windows-agent-foreground-process'
const execFile = promisify(execFileCb)
@@ -259,6 +263,14 @@ export async function getForegroundProcessName(
if (fallbackRecognition) {
return fallbackRecognition.processName
}
if (process.platform === 'win32') {
if (!shouldInspectWindowsAgentForeground(fallbackProcess)) {
return fallbackProcess
}
return (
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess
)
}
if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) {
return fallbackProcess
}
+6
View File
@@ -17,6 +17,12 @@
@custom-variant dark (&:is(.dark *));
/* Why: hover-reveal controls (sidebar/row action buttons) hide with `opacity-0`
and fade in via `group-hover`. Touch devices have no hover, so those controls
stay invisible. Gate the hide behind `can-hover:` so touch (hover: none) keeps
them visible while pointer devices preserve the reveal-on-hover behavior. */
@custom-variant can-hover (@media (hover: hover));
.theme-transition-disabled *,
.theme-transition-disabled *::before,
.theme-transition-disabled *::after {
@@ -1193,7 +1193,7 @@ function ThreadRow({
'Mark thread unread'
)}
>
<Bell className="size-3 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
<Bell className="size-3 text-muted-foreground/40 can-hover:opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
@@ -1216,14 +1216,13 @@ function ThreadRow({
{/* Why (Jump-to-workspace lives on the secondary row): the bell slot
on the title row already holds the unread/Mark-unread state, so
the navigation action gets its own slot down here aligned with
the worktree name. Reserved layout via `invisible` +
`pointer-events-none` keeps the worktree-name's flex-1 width
stable across hover. */}
the worktree name. On hover-capable pointers, the hidden state
keeps the worktree-name's flex-1 width stable across hover. */}
{canJump ? (
<span
className={cn(
'ml-auto inline-flex shrink-0 items-center transition-opacity',
'pointer-events-none invisible opacity-0',
'can-hover:pointer-events-none can-hover:invisible can-hover:opacity-0',
'group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100'
)}
>
@@ -5232,7 +5232,7 @@ function BrowserPagePane({
<Button
size="icon-xs"
variant="ghost"
className="opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 group-focus-within:opacity-100"
className="can-hover:opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 group-focus-within:opacity-100"
onClick={() => handleDeleteBrowserAnnotation(annotation.id)}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.f2d0c22d67',
@@ -1,15 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getPaletteHostBadge } from './palette-host-badge'
import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
// Why: a connected SSH state makes the target a live remote, which is what the
// palette badge now requires before disambiguating rows with a host label.
const connectedSshStates = (targetId: string) =>
new Map([
[targetId, { targetId, status: 'connected' as const, error: null, reconnectAttempt: 0 }]
])
const localHostLabel = getLocalExecutionHostLabel()
describe('getPaletteHostBadge', () => {
it('returns null for single-host (local-only) workspaces', () => {
@@ -44,7 +45,7 @@ describe('getPaletteHostBadge', () => {
expect(getPaletteHostBadge({ connectionId: null }, hosts)).toEqual({
hostId: 'local',
label: localHostLabel
label: LOCAL_HOST_LABEL
})
})
@@ -114,7 +115,7 @@ describe('getPaletteHostBadge', () => {
expect(getPaletteHostBadge({}, hosts)).toEqual({
hostId: 'local',
label: localHostLabel
label: LOCAL_HOST_LABEL
})
})
@@ -13,7 +13,7 @@ export type PaletteHostBadge = {
// Why: Cmd+J only needs a host label when there's a live remote to disambiguate
// from. A merely-configured-but-disconnected SSH/runtime host shouldn't tag every
// row with "Local Mac", so we require an actually-reachable non-local host —
// row with the local host label, so we require an actually-reachable non-local host —
// unlike the sidebar gate, which lists disconnected hosts so users can connect.
function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean {
return hostOptions.some(
@@ -1,5 +1,4 @@
import React, { useState, useCallback } from 'react'
import { X, ChevronDown, Send } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import { AgentIcon } from '@/lib/agent-catalog'
@@ -7,10 +6,10 @@ import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure'
import { DashboardAgentRowMessage } from './DashboardAgentRowMessage'
import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls'
import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
import { translate } from '@/i18n/i18n'
// Why: the dashboard tracks its own rollup states (incl. 'idle'); narrow to the
// shared dot states for rendering, falling back to 'idle' for any unknown
@@ -153,37 +152,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
childAgentCount > 0 &&
typeof onToggleChildAgents === 'function'
const [expanded, setExpanded] = useState(false)
// Why: stop propagation so clicking the X doesn't also fire the worktree
// card's click handler (which navigates away from the dashboard).
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onDismiss(agent.paneKey)
},
[onDismiss, agent.paneKey]
)
// Why: the chevron toggles expand-collapse and must not propagate — clicks
// on it would otherwise bubble to the row's activate handler and navigate
// away the instant the user tried to reveal the full text. Stop mousedown
// too so focus-based navigation on the parent can't fire first.
const handleToggleExpand = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
const handleToggleExpanded = useCallback(() => {
setExpanded((prev) => !prev)
}, [])
const stopMouseDown = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
}, [])
// Why: nested buttons (dismiss X, expand chevron) are real <button>s whose
// native Enter/Space handling fires their onClick. Stopping Enter/Space
// propagation (not preventDefault) preserves native button activation while
// defending against any ancestor key handlers that might otherwise react to
// the bubbled event.
const stopKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation()
}
}, [])
// Why: agent rows navigate directly to the agent's own tab, while the
// surrounding worktree card navigates to whatever tab the worktree last had
// focused. Stop propagation so the card click handler does not run second
@@ -215,16 +186,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
},
[agent.paneKey, onSendTargetClick, sendTargetStatus]
)
const handleInlineSendTargetClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
if (sendTargetStatus === 'eligible') {
onSendTargetClick?.(agent.paneKey)
}
},
[agent.paneKey, onSendTargetClick, sendTargetStatus]
)
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
const doneAt = lastEnteredDoneAt(agent)
const prompt = agent.entry.prompt.trim()
@@ -267,12 +228,15 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
// content already fits is a no-op; the cost of an occasionally inert
// toggle is much lower than layout jitter on every live row.
const startedTimeAgo = startedAt !== null ? formatTimeAgo(startedAt, now) : null
const doneTimeAgo = doneAt !== null ? formatTimeAgo(doneAt, now) : null
const relativeTimestamp = doneTimeAgo ?? startedTimeAgo
const tsParts: string[] = []
if (startedAt !== null) {
tsParts.push(`started ${formatTimeAgo(startedAt, now)}`)
if (startedTimeAgo !== null) {
tsParts.push(`started ${startedTimeAgo}`)
}
if (doneAt !== null) {
tsParts.push(`done ${formatTimeAgo(doneAt, now)}`)
if (doneTimeAgo !== null) {
tsParts.push(`done ${doneTimeAgo}`)
}
const titleParts = sendTargetDisabledReason ? [sendTargetDisabledReason, ...tsParts] : tsParts
@@ -415,120 +379,16 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
+{childAgentCount}
</span>
)}
{/* Why: right cluster keeps passive time and dismiss affordance in one
place. State belongs in the leading gutter; repeating it here as
text makes interrupted rows look like the old badge treatment. */}
<span className="relative ml-auto flex h-3.5 w-12 shrink-0 items-center justify-end">
{(sendTargetStatus === "eligible" || sendTargetStatus === "sending") && (
<button
type="button"
onClick={handleInlineSendTargetClick}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
disabled={sendTargetStatus === 'sending'}
className={cn(
'worktree-agent-send-target-button absolute right-0 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium leading-none transition-[background-color,border-color,color,opacity]',
sendTargetStatus === 'sending' && 'cursor-progress opacity-75'
)}
aria-label={translate("auto.components.dashboard.DashboardAgentRow.0272969e28", "Send to this agent")}
title={translate("auto.components.dashboard.DashboardAgentRow.0272969e28", "Send to this agent")}
>
<Send className="size-3" />
<span>{translate("auto.components.dashboard.DashboardAgentRow.912e136cd9", "Send")}</span>
</button>
)}
{/* Why: timestamp and dismiss-X share a single slot so passive
rows show "time ago" and hovered rows swap in the X — no
reserved-space gap, no competing columns. Grid stacks both
children in cell 1,1 so the slot width is the larger of the
two (usually the timestamp, e.g. "just now" / "12m ago"),
which keeps the chevron's column stable whether or not the
row is hovered. Using opacity (not display:none) lets us
fade the crossfade instead of snapping, and keyboard focus
on the hidden X still activates it because `opacity-0`
doesn't remove it from the tab order. */}
{!sendTargetStatus && (startedAt !== null || doneAt !== null) && (
<span className="relative grid grid-cols-1 grid-rows-1 shrink-0 items-center justify-items-end">
<span
className={cn(
'[grid-area:1/1] pointer-events-none text-[10px] leading-none text-muted-foreground/60',
'transition-opacity duration-150',
'group-hover/agent-row:opacity-0'
)}
aria-hidden
>
{doneAt !== null
? formatTimeAgo(doneAt, now)
: startedAt !== null
? formatTimeAgo(startedAt, now)
: null}
</span>
<button
type="button"
onClick={handleDismiss}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
className={cn(
'[grid-area:1/1] inline-flex items-center justify-center text-muted-foreground/70 hover:text-foreground',
'opacity-0 transition-opacity duration-150',
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
)}
aria-label={translate("auto.components.dashboard.DashboardAgentRow.b06e13fcf7", "Dismiss agent")}
title={translate("auto.components.dashboard.DashboardAgentRow.5ae84475cc", "Dismiss")}
>
<X className="size-3.5" />
</button>
</span>
)}
{/* Why: when there is no timestamp yet (fresh agent, never
reported), the grid slot above does not render — show the X
as a standalone hover-only control so dismiss is still
reachable. Rare path; most rows have a timestamp the moment
they start. */}
{!sendTargetStatus && startedAt === null && doneAt === null && (
<button
type="button"
onClick={handleDismiss}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
className={cn(
'inline-flex shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground',
'opacity-0 transition-opacity duration-150',
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
)}
aria-label={translate("auto.components.dashboard.DashboardAgentRow.b06e13fcf7", "Dismiss agent")}
title={translate("auto.components.dashboard.DashboardAgentRow.5ae84475cc", "Dismiss")}
>
<X className="size-3.5" />
</button>
)}
{/* Why: chevron points down when collapsed (content below is
available) and rotates 180° to point up when expanded
(content is showing above the fold line). Single glyph
with a transform animates smoothly; swapping between two
glyphs (ChevronDown / ChevronUp) would snap because the
old node unmounts. Invisible placeholder keeps vertical
alignment stable across rows when nothing is expandable
so the row-trailing edge stays stable. */}
{!hideExpand && (
<button
type="button"
onClick={handleToggleExpand}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
className="inline-flex shrink-0 items-center justify-center text-muted-foreground/60 hover:text-foreground"
aria-label={expanded ? translate("auto.components.dashboard.DashboardAgentRow.a41fb5376e", "Collapse details") : translate("auto.components.dashboard.DashboardAgentRow.a743da52ff", "Expand details")}
aria-expanded={expanded}
>
<ChevronDown
className={cn(
'size-3.5 transition-transform duration-150',
expanded && 'rotate-180'
)}
/>
</button>
)}
</span>
<DashboardAgentRowTrailingControls
paneKey={agent.paneKey}
relativeTimestamp={relativeTimestamp}
expanded={expanded}
hideExpand={hideExpand}
sendTargetStatus={sendTargetStatus}
onDismiss={onDismiss}
onToggleExpanded={handleToggleExpanded}
onSendTargetClick={onSendTargetClick}
/>
</div>
<DashboardAgentRowToolStep
expanded={expanded}
@@ -0,0 +1,170 @@
import React, { useCallback } from 'react'
import { ChevronDown, Send, X } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
type DashboardAgentRowTrailingControlsProps = {
paneKey: string
relativeTimestamp: string | null
expanded: boolean
hideExpand: boolean
sendTargetStatus?: 'eligible' | 'disabled' | 'sending'
onDismiss: (paneKey: string) => void
onToggleExpanded: () => void
onSendTargetClick?: (paneKey: string) => void
}
export function DashboardAgentRowTrailingControls({
paneKey,
relativeTimestamp,
expanded,
hideExpand,
sendTargetStatus,
onDismiss,
onToggleExpanded,
onSendTargetClick
}: DashboardAgentRowTrailingControlsProps): React.JSX.Element {
// Why: stop propagation so clicking nested row controls does not also
// activate the agent row or parent worktree card.
const stopMouseDown = useCallback((event: React.MouseEvent) => {
event.stopPropagation()
}, [])
const stopKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}, [])
const handleDismiss = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
onDismiss(paneKey)
},
[onDismiss, paneKey]
)
const handleToggleExpand = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
onToggleExpanded()
},
[onToggleExpanded]
)
const handleInlineSendTargetClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
if (sendTargetStatus === 'eligible') {
onSendTargetClick?.(paneKey)
}
},
[onSendTargetClick, paneKey, sendTargetStatus]
)
return (
<span className="relative ml-auto flex h-3.5 w-12 shrink-0 items-center justify-end">
{(sendTargetStatus === 'eligible' || sendTargetStatus === 'sending') && (
<button
type="button"
onClick={handleInlineSendTargetClick}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
disabled={sendTargetStatus === 'sending'}
className={cn(
'worktree-agent-send-target-button absolute right-0 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium leading-none transition-[background-color,border-color,color,opacity]',
sendTargetStatus === 'sending' && 'cursor-progress opacity-75'
)}
aria-label={translate(
'auto.components.dashboard.DashboardAgentRow.0272969e28',
'Send to this agent'
)}
title={translate(
'auto.components.dashboard.DashboardAgentRow.0272969e28',
'Send to this agent'
)}
>
<Send className="size-3" />
<span>{translate('auto.components.dashboard.DashboardAgentRow.912e136cd9', 'Send')}</span>
</button>
)}
{/* Why: timestamp and dismiss-X share one slot. On no-hover devices the X
is visible by default, so the timestamp must yield there too. */}
{!sendTargetStatus && relativeTimestamp !== null && (
<span className="relative grid grid-cols-1 grid-rows-1 shrink-0 items-center justify-items-end">
<span
className={cn(
'[grid-area:1/1] pointer-events-none text-[10px] leading-none text-muted-foreground/60',
'transition-opacity duration-150',
'group-hover/agent-row:opacity-0 [@media(hover:none)]:opacity-0'
)}
aria-hidden
>
{relativeTimestamp}
</span>
<button
type="button"
onClick={handleDismiss}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
className={cn(
'[grid-area:1/1] inline-flex items-center justify-center text-muted-foreground/70 hover:text-foreground',
'can-hover:opacity-0 transition-opacity duration-150',
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
)}
aria-label={translate(
'auto.components.dashboard.DashboardAgentRow.b06e13fcf7',
'Dismiss agent'
)}
title={translate('auto.components.dashboard.DashboardAgentRow.5ae84475cc', 'Dismiss')}
>
<X className="size-3.5" />
</button>
</span>
)}
{!sendTargetStatus && relativeTimestamp === null && (
<button
type="button"
onClick={handleDismiss}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
className={cn(
'inline-flex shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground',
'can-hover:opacity-0 transition-opacity duration-150',
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
)}
aria-label={translate(
'auto.components.dashboard.DashboardAgentRow.b06e13fcf7',
'Dismiss agent'
)}
title={translate('auto.components.dashboard.DashboardAgentRow.5ae84475cc', 'Dismiss')}
>
<X className="size-3.5" />
</button>
)}
{!hideExpand && (
<button
type="button"
onClick={handleToggleExpand}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
className="inline-flex shrink-0 items-center justify-center text-muted-foreground/60 hover:text-foreground"
aria-label={
expanded
? translate(
'auto.components.dashboard.DashboardAgentRow.a41fb5376e',
'Collapse details'
)
: translate(
'auto.components.dashboard.DashboardAgentRow.a743da52ff',
'Expand details'
)
}
aria-expanded={expanded}
>
<ChevronDown
className={cn('size-3.5 transition-transform duration-150', expanded && 'rotate-180')}
/>
</button>
)}
</span>
)
}
@@ -1,10 +1,13 @@
import React from 'react'
import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
import { CheckJobLogTail } from '@/components/right-sidebar/check-job-log-tail'
import { SourceControlFixSplitButton } from '@/components/right-sidebar/source-control-fix-split-button'
import { translate } from '@/i18n/i18n'
import { useCheckRunDetailsFixWithAI } from './check-run-details-fix-with-ai'
function formatCheckTimestamp(value: string | null | undefined): string | null {
if (!value) {
@@ -52,6 +55,7 @@ export function CheckRunDetailsPanel({
loading,
error,
openUrl,
worktreeId,
onRefresh
}: {
check: PRCheckDetail
@@ -59,8 +63,28 @@ export function CheckRunDetailsPanel({
loading: boolean
error: string | null
openUrl: string | null | undefined
worktreeId: string | null
onRefresh?: () => void
}): React.JSX.Element {
const {
canFixWithAI,
disabledReason,
isFixing,
fixPrompt,
repoId,
connectionId,
launchPlatform,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
saveLaunchActionDefault,
openSourceControlAiSettings,
fixWithAI
} = useCheckRunDetailsFixWithAI({
worktreeId,
check,
details
})
const startedAt = formatCheckTimestamp(details?.startedAt)
const completedAt = formatCheckTimestamp(details?.completedAt)
const detailsStatusCheck: PRCheckDetail = {
@@ -85,19 +109,86 @@ export function CheckRunDetailsPanel({
<h1 className="min-w-0 flex-1 truncate text-base font-medium text-foreground">
{check.name}
</h1>
{onRefresh && (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={loading}
onClick={onRefresh}
>
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
</Button>
)}
<div className="flex shrink-0 items-center gap-2">
{canFixWithAI && (
<SourceControlFixSplitButton
label={translate(
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
'Fix with AI'
)}
actionId="fixChecks"
dialogTitle={translate(
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
'Fix with AI'
)}
dialogDescription={translate(
'auto.components.editor.CheckRunDetailsPanel.c8f1a2d4e7',
'Choose the agent and edit the full command input before launch.'
)}
launchSource="task_page"
contextUnavailableLabel={translate(
'auto.components.editor.CheckRunDetailsPanel.b3e7f9a1c2',
'Check fix context unavailable'
)}
primaryTitle={translate(
'auto.components.editor.CheckRunDetailsPanel.d5a8c2f1b9',
'Start the default AI agent to fix this check'
)}
primaryAriaLabel={translate(
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
'Fix with AI'
)}
chevronTitle={translate(
'auto.components.editor.CheckRunDetailsPanel.e2b4d7c8a1',
'Choose an agent for this check'
)}
chevronAriaLabel={translate(
'auto.components.editor.CheckRunDetailsPanel.f1c9e3a6d4',
'Choose agent to fix check'
)}
worktreeId={worktreeId}
groupId={worktreeId}
connectionId={connectionId}
repoId={repoId}
launchPlatform={launchPlatform}
prompt={fixPrompt}
isLaunching={loading || isFixing}
disabledReason={disabledReason}
variant="default"
size="sm"
iconClassName="size-3.5"
primaryClassName="rounded-r-none font-medium"
chevronClassName="rounded-l-none border-l border-primary-foreground/20 px-2"
savedAgentId={savedAgentId}
savedCommandInputTemplate={savedCommandInputTemplate}
savedAgentArgs={savedAgentArgs}
onSaveAgentDefault={saveLaunchActionDefault}
onOpenSettings={openSourceControlAiSettings}
onFixWithDefaultAgent={fixWithAI}
onPromptDelivered={() =>
toast.success(
translate(
'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819',
'Started an AI agent for this check.'
)
)
}
/>
)}
{onRefresh && (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={loading}
onClick={onRefresh}
>
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
</Button>
)}
</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
@@ -1617,7 +1617,7 @@ export default function CombinedDiffViewer({
comments={diffCommentsForWorktree}
filePath={section.path}
showFileScope
triggerClassName="p-0.5 opacity-0 group-hover:opacity-100"
triggerClassName="p-0.5 can-hover:opacity-0 group-hover:opacity-100"
/>
) : null
}}
@@ -72,7 +72,7 @@ export function DiffSectionHeader({
<div className="flex items-center gap-1 shrink-0 ml-2">
{trailingContent}
<button
className="p-0.5 rounded text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
className="p-0.5 rounded text-muted-foreground hover:text-foreground can-hover:opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onOpenSection}
title={openSectionTitle}
>
@@ -637,6 +637,7 @@ export function EditorContent({
loading={checkRunDetails.loading}
error={checkRunDetails.error}
openUrl={openUrl}
worktreeId={activeFile.worktreeId}
onRefresh={() => {
void reloadOpenCheckRunDetailsTab(activeFile.id)
}}
@@ -0,0 +1,153 @@
import {
buildFixBrokenChecksPrompt,
getBrokenChecks,
getCheckDetailsPromptKey
} from '@/components/pr-checks-fix-prompt'
import { gitHubPRToChecksPanelReview } from '@/components/right-sidebar/checks-panel-review'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { useAppStore } from '@/store'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRCheckDetail, PRCheckRunDetails, Repo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export function resolveCheckRunDetailsFixCheck(
check: PRCheckDetail,
details: PRCheckRunDetails | null
): PRCheckDetail {
if (!details) {
return check
}
return {
...check,
status: (details.status as PRCheckDetail['status'] | undefined) ?? check.status,
conclusion: (details.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion
}
}
export function isCheckRunDetailsFixCandidate(
check: PRCheckDetail,
details: PRCheckRunDetails | null = null
): boolean {
return getBrokenChecks([resolveCheckRunDetailsFixCheck(check, details)]).length > 0
}
export function resolveHostedReviewForCheckRunDetailsFix(
worktreeId: string
): HostedReviewInfo | null {
const store = useAppStore.getState()
const worktree = findWorktreeById(store.worktreesByRepo, worktreeId)
if (!worktree) {
return null
}
const repo = store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null
if (!repo) {
return null
}
const identity = getWorktreeGitIdentityDisplay(worktree)
const branch = identity?.kind === 'branch' ? identity.branchName : null
if (!branch) {
return null
}
const settings = store.settings
const prCacheKey = getGitHubPRCacheKey(
repo.path,
repo.id,
branch,
settings,
repo.connectionId,
repo.executionHostId
)
const hostedReviewCacheKey = getHostedReviewCacheKey(
repo.path,
branch,
settings,
repo.id,
repo.connectionId,
repo.executionHostId
)
const pr = prCacheKey ? (store.prCache[prCacheKey]?.data ?? null) : null
const hostedReview = hostedReviewCacheKey
? (store.hostedReviewCache[hostedReviewCacheKey]?.data ?? null)
: null
const gitLabHostedReview = hostedReview?.provider === 'gitlab' ? hostedReview : null
const linkedGitLabMR = worktree.linkedGitLabMR ?? null
if (gitLabHostedReview) {
return gitLabHostedReview
}
if (linkedGitLabMR !== null) {
return null
}
return pr ? gitHubPRToChecksPanelReview(pr) : null
}
export function buildCheckRunDetailsFixBasePrompt(args: {
worktreeId: string
check: PRCheckDetail
details: PRCheckRunDetails | null
}): string | null {
const review = resolveHostedReviewForCheckRunDetailsFix(args.worktreeId)
if (!review) {
return null
}
const resolvedCheck = resolveCheckRunDetailsFixCheck(args.check, args.details)
if (!isCheckRunDetailsFixCandidate(resolvedCheck)) {
return null
}
const checkRunDetailsByCheckKey = args.details
? { [getCheckDetailsPromptKey(resolvedCheck, 0)]: args.details }
: undefined
return buildFixBrokenChecksPrompt({
reviewKind: review.provider === 'gitlab' ? 'MR' : 'PR',
reviewNumber: review.number,
reviewTitle: review.title,
reviewUrl: review.url,
checks: [resolvedCheck],
checkRunDetailsByCheckKey
})
}
export function getCheckRunDetailsFixDisabledReason(worktreeId: string | null): string | undefined {
if (!worktreeId) {
return translate(
'auto.components.editor.check.run.details.fix.with.ai.1a8c4e2b90',
'Select a workspace before launching an AI action.'
)
}
const store = useAppStore.getState()
const worktree = findWorktreeById(store.worktreesByRepo, worktreeId)
if (!worktree) {
return translate(
'auto.components.editor.check.run.details.fix.with.ai.1a8c4e2b90',
'Select a workspace before launching an AI action.'
)
}
const repo = store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null
if (!repo) {
return translate(
'auto.components.editor.check.run.details.fix.with.ai.4f2d9a8c17',
'Select a repository before launching an AI action.'
)
}
if (!resolveHostedReviewForCheckRunDetailsFix(worktreeId)) {
return translate(
'auto.components.editor.check.run.details.fix.with.ai.7c3e1b5d42',
'Open a PR or MR before launching an AI fix.'
)
}
return undefined
}
export function resolveCheckRunDetailsFixRepo(worktreeId: string | null): Repo | null {
if (!worktreeId) {
return null
}
const store = useAppStore.getState()
const worktree = findWorktreeById(store.worktreesByRepo, worktreeId)
if (!worktree) {
return null
}
return store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null
}
@@ -0,0 +1,203 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
PRCheckDetail,
PRCheckRunDetails,
PRInfo,
Repo,
Worktree
} from '../../../../shared/types'
import {
getCheckRunDetailsFixDisabledReason,
isCheckRunDetailsFixCandidate,
resolveCheckRunDetailsFixCheck,
resolveHostedReviewForCheckRunDetailsFix,
startCheckRunDetailsFixWithAI
} from './check-run-details-fix-with-ai'
const startFixChecksAgent = vi.fn()
const fixtures = vi.hoisted(() => {
const repo: Repo = {
id: 'repo-1',
path: '/tmp/repo',
displayName: 'repo',
badgeColor: '#2563eb',
addedAt: 1,
connectionId: null,
executionHostId: null
}
const worktree: Worktree = {
id: 'repo-1::/tmp/repo/feature',
repoId: 'repo-1',
path: '/tmp/repo/feature',
head: 'abc123',
branch: 'feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: 42,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
const pr: PRInfo = {
number: 42,
title: 'Fix CI',
state: 'open',
url: 'https://github.com/acme/widgets/pull/42',
checksStatus: 'failure',
updatedAt: '2026-06-16T00:00:00Z',
mergeable: 'MERGEABLE'
}
const failingCheck: PRCheckDetail = {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
checkRunId: 42
}
const checkDetails: PRCheckRunDetails = {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
detailsUrl: null,
startedAt: null,
completedAt: null,
title: null,
summary: null,
text: null,
annotations: [],
jobs: [
{
id: 1,
name: 'test',
status: 'completed',
conclusion: 'failure',
startedAt: null,
completedAt: null,
url: null,
steps: [],
logTail: 'assertion failed'
}
]
}
const prCacheKey = 'repo-1::feature'
return { repo, worktree, pr, failingCheck, checkDetails, prCacheKey }
})
const storeState = vi.hoisted(() => ({
worktreesByRepo: { 'repo-1': [fixtures.worktree] } as Record<string, Worktree[]>,
repos: [fixtures.repo] as Repo[],
settings: {},
prCache: {
[fixtures.prCacheKey]: { data: fixtures.pr, fetchedAt: 1 }
} as Record<string, { data: PRInfo; fetchedAt: number }>,
hostedReviewCache: {} as Record<string, { data: unknown }>
}))
vi.mock('@/lib/worktree-git-identity-display', () => ({
getWorktreeGitIdentityDisplay: () => ({ kind: 'branch', branchName: 'feature' })
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: typeof storeState) => unknown) => selector(storeState),
{
getState: () => storeState
}
)
}))
vi.mock('@/lib/fix-checks-agent-launch', () => ({
startFixChecksAgent: (...args: unknown[]) => startFixChecksAgent(...args)
}))
vi.mock('sonner', () => ({
toast: {
message: vi.fn(),
success: vi.fn()
}
}))
beforeEach(() => {
startFixChecksAgent.mockReset()
startFixChecksAgent.mockResolvedValue(true)
storeState.worktreesByRepo = { 'repo-1': [fixtures.worktree] }
storeState.repos = [fixtures.repo]
storeState.prCache = {
[fixtures.prCacheKey]: { data: fixtures.pr, fetchedAt: 1 }
}
storeState.hostedReviewCache = {}
})
describe('check-run-details-fix-with-ai', () => {
it('detects failing checks as fix candidates', () => {
expect(isCheckRunDetailsFixCandidate(fixtures.failingCheck)).toBe(true)
expect(
isCheckRunDetailsFixCandidate({
...fixtures.failingCheck,
conclusion: 'success'
})
).toBe(false)
})
it('prefers loaded details conclusion over the list-level check', () => {
const listFailure = fixtures.failingCheck
const passingDetails: PRCheckRunDetails = {
...fixtures.checkDetails,
conclusion: 'success',
status: 'completed'
}
expect(isCheckRunDetailsFixCandidate(listFailure, passingDetails)).toBe(false)
expect(
resolveCheckRunDetailsFixCheck(
{ ...fixtures.failingCheck, conclusion: 'success' },
fixtures.checkDetails
).conclusion
).toBe('failure')
expect(
isCheckRunDetailsFixCandidate(
{ ...fixtures.failingCheck, conclusion: 'success' },
fixtures.checkDetails
)
).toBe(true)
})
it('resolves hosted review metadata from the worktree PR cache', () => {
expect(resolveHostedReviewForCheckRunDetailsFix(fixtures.worktree.id)).toMatchObject({
number: 42,
title: 'Fix CI',
url: 'https://github.com/acme/widgets/pull/42'
})
})
it('requires a hosted review before launching an AI fix', () => {
storeState.prCache = {}
expect(getCheckRunDetailsFixDisabledReason(fixtures.worktree.id)).toContain('PR or MR')
})
it('starts a single-check AI fix prompt for the owning worktree', async () => {
await startCheckRunDetailsFixWithAI({
worktreeId: fixtures.worktree.id,
check: fixtures.failingCheck,
details: fixtures.checkDetails
})
expect(startFixChecksAgent).toHaveBeenCalledWith(
expect.objectContaining({
repoId: 'repo-1',
worktreeId: fixtures.worktree.id,
groupId: fixtures.worktree.id,
launchSource: 'task_page',
basePrompt: expect.stringContaining('"name": "verify"')
})
)
expect(startFixChecksAgent.mock.calls[0]?.[0]?.basePrompt).toContain('assertion failed')
})
})
@@ -0,0 +1,232 @@
import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react'
import {
buildCheckRunDetailsFixBasePrompt,
getCheckRunDetailsFixDisabledReason,
isCheckRunDetailsFixCandidate,
resolveCheckRunDetailsFixCheck,
resolveHostedReviewForCheckRunDetailsFix,
resolveCheckRunDetailsFixRepo
} from './check-run-details-fix-context'
import { openSourceControlAiSettingsTarget } from '@/components/right-sidebar/source-control-ai-settings-navigation'
import { getConnectionId } from '@/lib/connection-context'
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
import {
saveSourceControlActionRecipe,
type SourceControlAiWriteTarget
} from '../../../../shared/source-control-ai-recipe-save'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export {
buildCheckRunDetailsFixBasePrompt,
getCheckRunDetailsFixDisabledReason,
isCheckRunDetailsFixCandidate,
resolveCheckRunDetailsFixCheck,
resolveHostedReviewForCheckRunDetailsFix
} from './check-run-details-fix-context'
export async function startCheckRunDetailsFixWithAI(args: {
worktreeId: string
check: PRCheckDetail
details: PRCheckRunDetails | null
}): Promise<boolean> {
const disabledReason = getCheckRunDetailsFixDisabledReason(args.worktreeId)
if (disabledReason) {
toast.message(disabledReason)
return false
}
const resolvedCheck = resolveCheckRunDetailsFixCheck(args.check, args.details)
if (!isCheckRunDetailsFixCandidate(resolvedCheck)) {
toast.message(
translate(
'auto.components.editor.check.run.details.fix.with.ai.9b2f6d4a81',
'This check is not failing.'
)
)
return false
}
const review = resolveHostedReviewForCheckRunDetailsFix(args.worktreeId)
if (!review) {
toast.message(
translate(
'auto.components.editor.check.run.details.fix.with.ai.7c3e1b5d42',
'Open a PR or MR before launching an AI fix.'
)
)
return false
}
const repoId = resolveCheckRunDetailsFixRepo(args.worktreeId)?.id
if (!repoId) {
return false
}
const basePrompt =
buildCheckRunDetailsFixBasePrompt({
worktreeId: args.worktreeId,
check: args.check,
details: args.details
}) ?? ''
if (!basePrompt) {
return false
}
const started = await startFixChecksAgent({
repoId,
basePrompt,
worktreeId: args.worktreeId,
groupId: args.worktreeId,
launchSource: 'task_page'
})
if (started) {
toast.success(
translate(
'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819',
'Started an AI agent for this check.'
)
)
}
return started
}
export function useCheckRunDetailsFixWithAI(args: {
worktreeId: string | null
check: PRCheckDetail
details: PRCheckRunDetails | null
}): {
canFixWithAI: boolean
disabledReason: string | undefined
isFixing: boolean
fixPrompt: string | null
repoId: string | null
connectionId: string | null | undefined
launchPlatform: NodeJS.Platform | undefined
savedAgentId: ReturnType<typeof readSourceControlLaunchRecipeAgentId>
savedCommandInputTemplate: string | null
savedAgentArgs: string | null
saveLaunchActionDefault: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => Promise<void>
openSourceControlAiSettings: () => void
fixWithAI: () => Promise<boolean>
} {
const [isFixing, setIsFixing] = useState(false)
const settings = useAppStore((state) => state.settings)
const updateSettings = useAppStore((state) => state.updateSettings)
const updateRepo = useAppStore((state) => state.updateRepo)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const repo = useMemo(() => resolveCheckRunDetailsFixRepo(args.worktreeId), [args.worktreeId])
const worktree = useMemo(() => {
if (!args.worktreeId) {
return null
}
return findWorktreeById(useAppStore.getState().worktreesByRepo, args.worktreeId)
}, [args.worktreeId])
const canFixWithAI = isCheckRunDetailsFixCandidate(args.check, args.details)
const disabledReason = getCheckRunDetailsFixDisabledReason(args.worktreeId)
const fixPrompt = useMemo(() => {
if (!args.worktreeId || !canFixWithAI) {
return null
}
return buildCheckRunDetailsFixBasePrompt({
worktreeId: args.worktreeId,
check: args.check,
details: args.details
})
}, [args.check, args.details, args.worktreeId, canFixWithAI])
const connectionId = args.worktreeId
? (getConnectionId(args.worktreeId) ?? repo?.connectionId ?? null)
: null
const launchPlatform = resolveSourceControlLaunchPlatform({
connectionId,
worktreePath: worktree?.path ?? null
})
const fixChecksRecipe = useMemo(
() =>
resolveSourceControlActionRecipe({
settings,
repo,
actionId: 'fixChecks'
}),
[repo, settings]
)
const saveLaunchActionDefault = useCallback(
async (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
): Promise<void> => {
const state = useAppStore.getState()
const latestSettings = state.settings
if (!latestSettings) {
throw new Error('Settings are not loaded.')
}
const latestRepo =
target.type === 'repo'
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
: null
const result = saveSourceControlActionRecipe({
target,
settings: latestSettings,
repo: latestRepo,
actionId,
recipe
})
if ('sourceControlAi' in result) {
await updateSettings({ sourceControlAi: result.sourceControlAi })
return
}
await updateRepo(result.target.repoId, result.update)
},
[updateRepo, updateSettings]
)
const openSourceControlAiSettings = useCallback((): void => {
openSourceControlAiSettingsTarget({
activeRepo: repo,
openSettingsTarget,
openSettingsPage
})
}, [openSettingsPage, openSettingsTarget, repo])
const fixWithAI = useCallback(async (): Promise<boolean> => {
if (!args.worktreeId || isFixing || disabledReason) {
return false
}
setIsFixing(true)
try {
return await startCheckRunDetailsFixWithAI({
worktreeId: args.worktreeId,
check: args.check,
details: args.details
})
} finally {
setIsFixing(false)
}
}, [args.check, args.details, args.worktreeId, disabledReason, isFixing])
return {
canFixWithAI,
disabledReason,
isFixing,
fixPrompt,
repoId: repo?.id ?? null,
connectionId,
launchPlatform,
savedAgentId: readSourceControlLaunchRecipeAgentId(fixChecksRecipe),
savedCommandInputTemplate: fixChecksRecipe.commandInputTemplate ?? null,
savedAgentArgs: fixChecksRecipe.agentArgs ?? null,
saveLaunchActionDefault,
openSourceControlAiSettings,
fixWithAI
}
}
@@ -663,7 +663,7 @@ function PickerRow({
<button
type="button"
title={translate('auto.components.github.project.ProjectPicker.8ab5447c64', 'Pin')}
className="opacity-0 group-hover:opacity-100"
className="can-hover:opacity-0 group-hover:opacity-100"
onClick={onPin}
>
<Pin className="size-3.5" />
@@ -115,7 +115,7 @@ export default function ProjectRow({
</div>
)
})}
<div className="flex items-center justify-end gap-1 opacity-0 transition group-hover:opacity-100">
<div className="flex items-center justify-end gap-1 can-hover:opacity-0 transition group-hover:opacity-100">
{row.content.url ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -0,0 +1,87 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { describe, expect, it } from 'vitest'
// Why: hover-reveal action buttons hide with `opacity-0` and reveal on
// `group-hover`. Touch devices have no hover, so a bare hide leaves the icon
// permanently invisible while the control stays clickable.
const MAIN_CSS = resolve(__dirname, '../assets/main.css')
const HOVER_REVEAL_FILES = [
resolve(__dirname, 'activity/ActivityPrototypePage.tsx'),
resolve(__dirname, 'browser-pane/BrowserPane.tsx'),
resolve(__dirname, 'dashboard/DashboardAgentRow.tsx'),
resolve(__dirname, 'dashboard/DashboardAgentRowTrailingControls.tsx'),
resolve(__dirname, 'editor/CombinedDiffViewer.tsx'),
resolve(__dirname, 'editor/DiffSectionHeader.tsx'),
resolve(__dirname, 'github-project/ProjectPicker.tsx'),
resolve(__dirname, 'github-project/ProjectRow.tsx'),
resolve(__dirname, 'right-sidebar/AiVaultSessionRow.tsx'),
resolve(__dirname, 'right-sidebar/ChecksPanel.tsx'),
resolve(__dirname, 'right-sidebar/PortsPanel.tsx'),
resolve(__dirname, 'right-sidebar/SourceControl.tsx'),
resolve(__dirname, 'right-sidebar/checks-panel-content.tsx'),
resolve(__dirname, 'settings/MobilePairingQrSection.tsx'),
resolve(__dirname, 'settings/ShortcutBindingRow.tsx'),
resolve(__dirname, 'settings/VoiceSpeechModelSection.tsx'),
resolve(__dirname, 'sidebar/HostSectionHeaderMenu.tsx'),
resolve(__dirname, 'sidebar/PendingWorktreeRow.tsx'),
resolve(__dirname, 'sidebar/WorkspaceKanbanStatusLane.tsx'),
resolve(__dirname, 'sidebar/WorktreeCardPorts.tsx'),
resolve(__dirname, 'sidebar/WorktreeList.tsx'),
resolve(__dirname, 'status-bar/ResourceUsageStatusSegment.tsx'),
resolve(__dirname, 'status-bar/ports-status-popover-rows.tsx'),
resolve(__dirname, 'tab-bar/TabBarQuickCommandsMenu.tsx')
]
function lineHasBareOpacityZero(line: string): boolean {
for (const match of line.matchAll(/opacity-0\b/g)) {
const previousChar = match.index! > 0 ? line[match.index! - 1] : ''
if (previousChar !== ':') {
return true
}
}
return false
}
describe('hover-reveal action button touch visibility', () => {
it('declares the can-hover variant so touch devices skip the hover-only hide', () => {
const css = readFileSync(MAIN_CSS, 'utf8')
expect(css).toMatch(/@custom-variant\s+can-hover\s+\(@media\s*\(hover:\s*hover\)\)/)
})
it('keeps hover-reveal controls visible on touch devices', () => {
const offenders: string[] = []
HOVER_REVEAL_FILES.forEach((file) => {
const source = readFileSync(file, 'utf8')
const lines = source.split('\n')
lines.forEach((line, index) => {
if (!line.includes('opacity-0') || !lineHasBareOpacityZero(line)) {
return
}
const revealContext = [line, lines[index + 1] ?? '', lines[index + 2] ?? ''].join(' ')
const isHoverReveal =
/(group-hover|group-focus-within|focus-visible|focus:|data-\[state=open\]|data-\[selected=true\]):opacity-100/.test(
revealContext
)
const hasTouchOverride = revealContext.includes('[@media(hover:none)]:opacity-100')
const isPassiveSwap =
line.includes('[@media(hover:none)]:opacity-0') && line.includes('pointer-events-none')
const isPointerlessPlaceholder =
line.includes('pointer-events-none') &&
!/(group-hover|group-focus-within)/.test(revealContext)
if (isHoverReveal && !hasTouchOverride && !isPassiveSwap && !isPointerlessPlaceholder) {
offenders.push(`${file}:${index + 1}: ${line.trim()}`)
}
})
})
expect(offenders).toEqual([])
})
})
@@ -127,7 +127,12 @@ export function VaultSessionRow({
event.stopPropagation()
onResume()
}}
className="opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
// Why: the wrapper is `pointer-events-none`; this control only
// re-enables pointer events on hover/focus. On touch (no hover)
// it is visible via `can-hover:opacity-0`, so it must also be
// tappable — keep base `pointer-events-auto` and only disable it
// on hover-capable devices where the reveal gates interaction.
className="pointer-events-auto can-hover:pointer-events-none can-hover:opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
>
<Play className="size-3.5" />
</Button>
@@ -70,6 +70,7 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../../shared/hosted-review'
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { toast } from 'sonner'
@@ -694,8 +695,9 @@ export default function ChecksPanel(): React.JSX.Element {
hostedReviewCreationSnapshot?.requestKey === hostedReviewCreationRequestKey
? hostedReviewCreationSnapshot.data
: null
const hostedReviewCreateProvider: HostedReviewProvider =
hostedReviewCreation?.provider === 'gitlab' ? 'gitlab' : 'github'
const hostedReviewCreateProvider = resolveHostedReviewCreationProvider(
hostedReviewCreation?.provider
)
const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider)
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
if (!activeWorktreeId || !activeWorktree?.path) {
@@ -2669,17 +2671,27 @@ export default function ChecksPanel(): React.JSX.Element {
if (activeWorktreeId && result.provider === 'gitlab') {
await updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: result.number })
}
if (activeWorktreeId && result.provider === 'azure-devops') {
await updateWorktreeMeta(activeWorktreeId, { linkedAzureDevOpsPR: result.number })
}
if (activeWorktreeId && result.provider === 'gitea') {
await updateWorktreeMeta(activeWorktreeId, { linkedGiteaPR: result.number })
}
const linkedReviewNumbers = {
linkedGitHubPR: result.provider === 'github' ? result.number : linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR: result.provider === 'gitlab' ? result.number : linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR:
result.provider === 'azure-devops' ? result.number : linkedAzureDevOpsPR,
linkedGiteaPR: result.provider === 'gitea' ? result.number : linkedGiteaPR
}
if (result.provider === 'gitlab') {
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
linkedGitHubPR: linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR: result.number,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
...linkedReviewNumbers
})
const refreshedGitLabReview =
refreshedReview?.provider === 'gitlab' ? refreshedReview : null
@@ -2690,6 +2702,15 @@ export default function ChecksPanel(): React.JSX.Element {
})
return
}
if (result.provider !== 'github') {
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
...linkedReviewNumbers
})
return
}
await refreshLinkedGitHubPullRequest(result.number)
} catch {
// The success toast keeps the hosted URL available; Checks can be refreshed manually.
@@ -2703,6 +2724,7 @@ export default function ChecksPanel(): React.JSX.Element {
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
refreshLinkedGitHubPullRequest,
repo,
@@ -3103,7 +3125,7 @@ export default function ChecksPanel(): React.JSX.Element {
<span className="text-[12px] text-foreground leading-snug flex-1">
{activeReview.title}
</span>
<Pencil className="size-3 text-muted-foreground/40 opacity-0 group-hover/title:opacity-100 transition-opacity shrink-0 mt-0.5" />
<Pencil className="size-3 text-muted-foreground/40 can-hover:opacity-0 group-hover/title:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
)}
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { CommitArea, ConflictSummaryCard, OperationBanner } from './SourceControl'
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
import {
resolveCommitAreaPrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -39,7 +42,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolvePrimaryAction(inputs),
primaryAction: resolveCommitAreaPrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
onGenerate: vi.fn(),
@@ -50,7 +53,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
}
}
function renderCommitArea(props: ReturnType<typeof baseProps>): string {
function renderCommitArea(props: Parameters<typeof CommitArea>[0]): string {
return renderToStaticMarkup(
<TooltipProvider>
<CommitArea {...props} />
@@ -327,6 +330,96 @@ describe('CommitArea', () => {
expect(button).toContain('animate-spin')
expect(button).not.toContain('lucide-check')
})
it('keeps Stage All as the commit-area primary when review prep can stage changes', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
const markup = renderCommitArea(baseProps(input))
const stageAllButton = firstButton(markup)
expect(stageAllButton).toContain('Stage All')
expect(stageAllButton).not.toContain('disabled=""')
expect(stageAllButton).toContain('lucide-plus')
expect(stageAllButton).toContain('rounded-r-none')
expect(markup).toContain('aria-label="More commit and remote actions"')
expect(markup).toContain('Stage all changes')
expect(
(markup.match(/<button\b[\s\S]*?<\/button>/g) ?? []).some((button) =>
button.includes('Commit</button>')
)
).toBe(false)
})
it('keeps Push as the commit-area primary when review prep can create after pushing', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
const markup = renderCommitArea(baseProps(input))
const pushButton = firstButton(markup)
expect(pushButton).toContain('Push')
expect(pushButton).not.toContain('disabled=""')
expect(pushButton).toContain('lucide-arrow-up')
expect(pushButton).toContain('rounded-r-none')
expect(markup).toContain('aria-label="More commit and remote actions"')
})
it('hides the composer generate affordance while Create PR intent is in flight', () => {
const markup = renderCommitArea({
...baseProps(),
aiEnabled: true,
aiAgentConfigured: true,
isGenerating: true,
isCreatePrIntentInFlight: true,
createPrIntentNotice: {
tone: 'muted',
message: 'Generating commit message…'
}
})
expect(markup).not.toContain('lucide-sparkles')
expect(markup).not.toContain('animate-spin')
expect(markup).toContain('Generating commit message…')
})
it('renders Create PR failures in the visible inline notice', () => {
const markup = renderCommitArea({
...baseProps(),
createPrIntentNotice: {
tone: 'destructive',
message: 'Create PR failed: push this branch first.'
}
})
expect(markup).toContain('id="commit-area-create-pr-intent"')
expect(markup).toContain('role="alert"')
expect(markup).toContain('Create PR failed: push this branch first.')
})
})
describe('ConflictSummaryCard', () => {
@@ -15,6 +15,7 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../../shared/hosted-review'
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import {
@@ -68,7 +69,7 @@ export function CreatePullRequestDialog({
const submitInFlightRef = useRef(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const provider = eligibility?.provider === 'gitlab' ? 'gitlab' : 'github'
const provider = resolveHostedReviewCreationProvider(eligibility?.provider)
const copy = reviewCopy(provider)
const prCreationDefaults = React.useMemo(() => {
if (!settings) {
@@ -114,6 +114,8 @@ export function FolderWorkspacePrChecksRow({
checksLoading={row.isRefreshing}
checkDetailsContextKey={row.refreshIdentity}
onLoadCheckDetails={onLoadCheckDetails}
worktreeId={row.worktree.id}
detailsStickySurface="card"
/>
</div>
) : null}
@@ -594,7 +594,7 @@ function LocalPortRow({
</div>
</ContextMenuTrigger>
<TooltipProvider delayDuration={400}>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1107,7 +1107,7 @@ function ForwardedPortRow({
</div>
)}
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
@@ -1190,7 +1190,7 @@ function DetectedPortRow({
</div>
<button
type="button"
className="text-[11px] px-2 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity bg-accent hover:bg-accent/80 text-foreground"
className="text-[11px] px-2 py-0.5 rounded can-hover:opacity-0 group-hover:opacity-100 transition-opacity bg-accent hover:bg-accent/80 text-foreground"
onClick={onForward}
>
{translate('auto.components.right.sidebar.PortsPanel.c9d106547a', 'Forward')}
@@ -3,6 +3,7 @@ import {
CompareSummary,
CompareSummaryToolbarButton,
resolveSourceControlBaseRef,
resolveSourceControlPickerBaseRef,
shouldShowCompareSummary
} from './SourceControl'
import type { GitBranchCompareSummary } from '../../../../shared/types'
@@ -80,12 +81,113 @@ describe('SourceControl compare summary', () => {
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: 'refs/remotes/origin/main',
reviewBaseRefName: 'main',
repoBaseRef: 'main',
defaultBaseRef: 'origin/main'
})
).toBe('refs/remotes/origin/main')
})
it('repairs stale PR head SHA compare bases from linked review metadata', () => {
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
reviewBaseRefName: 'main',
repoBaseRef: null,
defaultBaseRef: 'origin/main'
})
).toBe('origin/main')
})
it('keeps non-SHA worktree base refs ahead of review metadata', () => {
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: 'refs/remotes/upstream/release',
reviewBaseRefName: 'main',
repoBaseRef: null,
defaultBaseRef: 'origin/main'
})
).toBe('refs/remotes/upstream/release')
})
it('rewrites stale SHA compare bases using the configured remote style', () => {
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
reviewBaseRefName: 'release/next',
repoBaseRef: null,
defaultBaseRef: 'refs/remotes/upstream/main'
})
).toBe('refs/remotes/upstream/release/next')
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
reviewBaseRefName: 'release',
repoBaseRef: null,
defaultBaseRef: 'upstream/main'
})
).toBe('upstream/release')
})
it('does not treat nested branch suffix matches as the review target', () => {
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
reviewBaseRefName: 'main',
repoBaseRef: null,
defaultBaseRef: 'origin/release/main'
})
).toBe('origin/main')
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
reviewBaseRefName: 'main',
repoBaseRef: null,
defaultBaseRef: 'refs/remotes/upstream/release/main'
})
).toBe('refs/remotes/upstream/main')
})
it('keeps exact slash-containing target branch matches', () => {
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
reviewBaseRefName: 'release/main',
repoBaseRef: null,
defaultBaseRef: 'origin/release/main'
})
).toBe('origin/release/main')
})
it('waits for a remote candidate before repairing stale SHA compare bases', () => {
expect(
resolveSourceControlBaseRef({
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
reviewBaseRefName: 'release',
repoBaseRef: null,
defaultBaseRef: null
})
).toBeNull()
})
it('shows the repaired pinned base ref in the base picker', () => {
expect(
resolveSourceControlPickerBaseRef({
pinnedBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
effectiveBaseRef: 'origin/main'
})
).toBe('origin/main')
expect(
resolveSourceControlPickerBaseRef({
pinnedBaseRef: null,
effectiveBaseRef: 'origin/main'
})
).toBeUndefined()
})
it('falls back to repo and default base refs when worktree metadata is absent', () => {
expect(
resolveSourceControlBaseRef({
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
import { ChecksList } from './checks-panel-content'
const openCheckRunDetails = vi.fn()
const patchOpenCheckRunDetails = vi.fn()
const activeWorktreeState = vi.hoisted(() => ({
current: null as { id: string } | null
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
openCheckRunDetails,
patchOpenCheckRunDetails
})
}))
vi.mock('@/store/selectors', () => ({
useActiveWorktree: () => activeWorktreeState.current
}))
let container: HTMLDivElement
let root: Root
const failingCheck: PRCheckDetail = {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
checkRunId: 42,
workflowRunId: 7
}
const checkDetails: PRCheckRunDetails = {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
detailsUrl: null,
startedAt: '2026-06-16T12:00:00Z',
completedAt: '2026-06-16T12:05:00Z',
title: 'Verify failed',
summary: null,
text: null,
annotations: [],
jobs: [
{
id: 1,
name: 'test',
status: 'completed',
conclusion: 'failure',
startedAt: null,
completedAt: null,
url: null,
steps: [],
logTail: 'Error: assertion failed'
}
]
}
beforeEach(() => {
activeWorktreeState.current = null
openCheckRunDetails.mockReset()
patchOpenCheckRunDetails.mockReset()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
function renderChecksList(
props: Partial<{
worktreeId: string
detailsStickySurface: 'sidebar' | 'card'
onLoadCheckDetails: (check: PRCheckDetail) => Promise<PRCheckRunDetails | null>
}> = {}
): void {
act(() => {
root.render(
<TooltipProvider>
<ChecksList
checks={[failingCheck]}
checksLoading={false}
checkDetailsContextKey="repo:42"
worktreeId={props.worktreeId}
detailsStickySurface={props.detailsStickySurface ?? 'sidebar'}
onLoadCheckDetails={
props.onLoadCheckDetails ??
(async () => {
await Promise.resolve()
return checkDetails
})
}
/>
</TooltipProvider>
)
})
}
describe('ChecksList expanded check details', () => {
it('pins a contextual full-details action with the correct sticky surface', async () => {
renderChecksList({ worktreeId: 'wt-child-1', detailsStickySurface: 'card' })
await act(async () => {
await Promise.resolve()
})
const stickyBar = container.querySelector('.sticky.top-0')
expect(stickyBar).not.toBeNull()
expect(stickyBar?.className).toContain('bg-card/95')
expect(stickyBar?.textContent).toContain('verify')
expect(stickyBar?.textContent).toContain('View full logs')
expect(container.innerHTML).toContain('lucide-panel-right')
expect(container.innerHTML).toContain('data-variant="outline"')
})
it('uses the sidebar sticky surface by default in the hosted checks panel', async () => {
activeWorktreeState.current = { id: 'wt-active-1' }
renderChecksList()
await act(async () => {
await Promise.resolve()
})
const stickyBar = container.querySelector('.sticky.top-0')
expect(stickyBar?.className).toContain('bg-sidebar/95')
})
it('falls back to the active worktree when no worktree override is provided', async () => {
activeWorktreeState.current = { id: 'wt-active-1' }
renderChecksList()
await act(async () => {
await Promise.resolve()
})
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes('View full logs')
)
expect(button).toBeDefined()
act(() => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(openCheckRunDetails).toHaveBeenCalledWith(
'wt-active-1',
'repo:42',
failingCheck,
expect.objectContaining({
details: checkDetails,
loading: false,
error: null
})
)
})
it('opens full details on the provided worktree instead of the active worktree', async () => {
activeWorktreeState.current = { id: 'wt-active-1' }
renderChecksList({ worktreeId: 'wt-attached-9' })
await act(async () => {
await Promise.resolve()
})
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes('View full logs')
)
expect(button).toBeDefined()
act(() => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(openCheckRunDetails).toHaveBeenCalledWith(
'wt-attached-9',
'repo:42',
failingCheck,
expect.objectContaining({
details: checkDetails,
loading: false,
error: null
})
)
})
it('keeps the generic label while inline details are still loading', async () => {
renderChecksList({
worktreeId: 'wt-child-1',
onLoadCheckDetails: () => new Promise(() => {})
})
await act(async () => {
await Promise.resolve()
})
const stickyBar = container.querySelector('.sticky.top-0')
expect(stickyBar?.textContent).toContain('View full details')
expect(stickyBar?.textContent).not.toContain('View full logs')
})
})
@@ -15,6 +15,7 @@ import {
Plus,
ChevronDown,
ChevronRight,
PanelRight,
SendHorizontal,
Sparkles,
RefreshCw,
@@ -469,16 +470,46 @@ export function getFailedChecksForDetails(checks: PRCheckDetail[]): PRCheckDetai
return checks.filter(isFailedCheck)
}
type CheckDetailsStickySurface = 'sidebar' | 'card'
function getCheckDetailsStickySurfaceClass(surface: CheckDetailsStickySurface): string {
return surface === 'card' ? 'bg-card/95' : 'bg-sidebar/95'
}
function ViewFullCheckDetailsButton({
onClick,
label
}: {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
label: string
}): React.JSX.Element {
return (
<Button
type="button"
variant="outline"
size="xs"
className="h-6 min-w-[7.25rem] shrink-0 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
onClick={onClick}
>
<PanelRight className="size-3" />
{label}
</Button>
)
}
function CheckRunDetails({
check,
state,
checkDetailsContextKey
checkDetailsContextKey,
worktreeId,
detailsStickySurface = 'sidebar'
}: {
check: PRCheckDetail
state: CheckDetailsLoadState | undefined
checkDetailsContextKey: string
worktreeId: string | null
detailsStickySurface?: CheckDetailsStickySurface
}): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const openCheckRunDetails = useAppStore((s) => s.openCheckRunDetails)
const details = state?.details
const startedAt = formatCheckTimestamp(details?.startedAt)
@@ -499,19 +530,49 @@ function CheckRunDetails({
const hasJobs = jobs.length > 0
const hasLogTail = jobs.some((job) => Boolean(job.logTail))
// Why: wait until inline details finish loading before switching to the logs label
// so the sticky button does not resize mid-fetch.
const fullDetailsLabel =
!state?.loading && hasLogTail
? translate('auto.components.right.sidebar.checks.panel.content.b8c4e2a1f7', 'View full logs')
: translate(
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
'View full details'
)
const openFullDetailsTab = (): void => {
if (!activeWorktree) {
if (!worktreeId) {
return
}
openCheckRunDetails(activeWorktree.id, checkDetailsContextKey, check, {
openCheckRunDetails(worktreeId, checkDetailsContextKey, check, {
details: state?.details ?? null,
loading: state?.loading ?? false,
error: state?.error ?? null
})
}
const handleOpenFullDetails = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation()
openFullDetailsTab()
}
return (
<div className="mb-1 ml-[26px] mr-3 min-w-0 border-l border-border pl-3">
{worktreeId && (
// Why: inline check details can be long; pinning the affordance keeps it
// visible while scrolling through annotations and job output.
<div
className={cn(
'sticky top-0 z-10 -ml-3 flex min-w-0 items-center gap-2 border-b border-border/60 py-1 pl-3 backdrop-blur-sm',
getCheckDetailsStickySurfaceClass(detailsStickySurface)
)}
>
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-foreground">
{check.name}
</span>
<ViewFullCheckDetailsButton label={fullDetailsLabel} onClick={handleOpenFullDetails} />
</div>
)}
{state?.loading ? (
<div className="flex min-w-0 flex-col gap-2 py-1.5">
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
@@ -521,25 +582,6 @@ function CheckRunDetails({
'Loading check details…'
)}
</div>
{activeWorktree && (
<div className="flex justify-start">
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation()
openFullDetailsTab()
}}
>
{translate(
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
'View full details'
)}
</Button>
</div>
)}
</div>
) : (
<div className="flex min-w-0 flex-col gap-2.5 py-1.5">
@@ -744,26 +786,6 @@ function CheckRunDetails({
)}
</div>
)}
<div className="flex justify-start pt-1">
{activeWorktree && (
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation()
openFullDetailsTab()
}}
>
{translate(
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
'View full details'
)}
</Button>
)}
</div>
</div>
)}
</div>
@@ -777,14 +799,20 @@ export function ChecksList({
checks,
checksLoading,
checkDetailsContextKey,
onLoadCheckDetails
onLoadCheckDetails,
worktreeId: worktreeIdOverride,
detailsStickySurface = 'sidebar'
}: {
checks: PRCheckDetail[]
checksLoading: boolean
checkDetailsContextKey: string
onLoadCheckDetails?: (check: PRCheckDetail) => Promise<PRCheckRunDetails | null>
/** Why: folder-workspace PR checks render rows for attached worktrees, not the active one. */
worktreeId?: string
detailsStickySurface?: CheckDetailsStickySurface
}): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const resolvedWorktreeId = worktreeIdOverride ?? activeWorktree?.id ?? null
const patchOpenCheckRunDetails = useAppStore((s) => s.patchOpenCheckRunDetails)
const [checksExpanded, setChecksExpanded] = useState(true)
const [expandedCheckKeys, setExpandedCheckKeys] = useState<Set<string>>(new Set())
@@ -952,7 +980,7 @@ export function ChecksList({
}, [checksExpanded, detailsByCheckKey, expandedCheckKeys, requestCheckDetails, rows])
useEffect(() => {
if (!activeWorktree) {
if (!resolvedWorktreeId) {
return
}
for (const row of rows) {
@@ -960,13 +988,19 @@ export function ChecksList({
if (!detailsState) {
continue
}
patchOpenCheckRunDetails(activeWorktree.id, checkDetailsContextKey, row.check, {
patchOpenCheckRunDetails(resolvedWorktreeId, checkDetailsContextKey, row.check, {
details: detailsState.details ?? null,
loading: detailsState.loading ?? false,
error: detailsState.error ?? null
})
}
}, [activeWorktree, checkDetailsContextKey, detailsByCheckKey, patchOpenCheckRunDetails, rows])
}, [
checkDetailsContextKey,
detailsByCheckKey,
patchOpenCheckRunDetails,
resolvedWorktreeId,
rows
])
const toggleCheckExpanded = useCallback(
(row: { check: PRCheckDetail; key: string }) => {
@@ -1124,6 +1158,8 @@ export function ChecksList({
check={check}
state={detailsByCheckKey[row.key]}
checkDetailsContextKey={checkDetailsContextKey}
worktreeId={resolvedWorktreeId}
detailsStickySurface={detailsStickySurface}
/>
)}
</div>
@@ -1501,7 +1537,7 @@ function CommentRow({
<div className="flex-1" />
{!editing && resolveSelectionAction}
{!editing && (
<div className="flex items-center gap-0.5 opacity-0 group-hover/comment:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 can-hover:opacity-0 group-hover/comment:opacity-100 transition-opacity">
{showResolve && comment.threadId != null && onResolve && (
<ResolveButton
threadId={comment.threadId}
@@ -1,4 +1,4 @@
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
import type {
GitPushTarget,
GitStatusResult,
@@ -83,3 +83,74 @@ export async function refreshGitStatusForWorktree({
runtimeTargetSettings: settings
})
}
export async function refreshGitStatusForWorktreeStrict({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
pushTarget?: GitPushTarget
deps: Omit<GitStatusRefreshDeps, 'fetchUpstreamStatus'> & {
fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus']
}
}): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
// gives us the new identity without a separate worktree-list poll.
deps.updateWorktreeGitIdentity(worktreeId, {
head: status.head,
// Why: detached HEAD reports a head oid and no branch. Pass null as an
// explicit clear signal so stale branch names don't linger in the UI.
branch: status.branch ?? (status.head ? null : undefined)
})
if (pushTarget) {
// Why: porcelain status reports Git's configured upstream. Source Control
// actions for PR-created worktrees must instead reconcile with Orca's
// explicit publish target.
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
pushTarget
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
if (status.upstreamStatus) {
if (
status.upstreamStatus.ahead > 0 &&
status.upstreamStatus.behind > 0 &&
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
) {
// Why: porcelain status has counts but cannot tell stale post-rebase
// upstream commits from real remote work. Writing it first makes the
// primary action flicker between Sync and Force Push on every poll.
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
undefined
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
return { status, upstreamStatus: status.upstreamStatus }
}
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
undefined
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest'
import {
createCreatePrIntentRunToken,
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
getCreatePrIntentStagePaths,
resolveCreatePrIntentRemoteStep
} from './source-control-create-pr-intent-flow'
import type { GitStatusEntry } from '../../../../shared/types'
describe('source-control Create PR intent flow helpers', () => {
it('matches async completions only to the original repo, worktree, path, and branch', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(123)
try {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature'
})
expect(token.startedAt).toBe(123)
expect(createPrIntentRunTokenMatches(token, token)).toBe(true)
expect(createPrIntentRunTokenMatches(token, { ...token, branch: 'other' })).toBe(false)
expect(createPrIntentRunTokenMatches(token, { ...token, worktreeId: 'wt-2' })).toBe(false)
} finally {
now.mockRestore()
}
})
it('matches strict git status snapshots to the original branch', () => {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature/pr'
})
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe(
true
)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'feature/pr' })).toBe(true)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/other' })).toBe(false)
expect(createPrIntentGitStatusMatchesToken(token, { branch: null })).toBe(false)
})
it('stages only safe unstaged and untracked paths', () => {
const unresolved = {
path: 'conflicted.ts',
status: 'modified',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
} satisfies GitStatusEntry
expect(
getCreatePrIntentStagePaths({
unstaged: [{ path: 'safe.ts', status: 'modified', area: 'unstaged' }, unresolved],
untracked: [{ path: 'new.ts', status: 'untracked', area: 'untracked' }]
})
).toEqual(['safe.ts', 'new.ts'])
})
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
).toBe('publish')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
).toBe('push')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: {
hasUpstream: true,
ahead: 3,
behind: 2,
behindCommitsArePatchEquivalent: true
},
branchCommitsAhead: 3,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
).toBe('force_push')
})
it('blocks ordinary diverged branches and unpublished branches without commits', () => {
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 },
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
).toBe('blocked')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
).toBe('blocked')
})
})
@@ -0,0 +1,93 @@
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types'
import { getStageAllPaths } from './discard-all-sequence'
export type CreatePrIntentRemoteStep = 'publish' | 'push' | 'force_push' | 'blocked' | 'none'
export type CreatePrIntentRunToken = {
repoId: string
worktreeId: string
worktreePath: string
branch: string
startedAt: number
}
export type CreatePrIntentCurrentTarget = {
repoId?: string | null
worktreeId?: string | null
worktreePath?: string | null
branch?: string | null
}
export function createCreatePrIntentRunToken(input: Omit<CreatePrIntentRunToken, 'startedAt'>) {
return { ...input, startedAt: Date.now() }
}
export function createPrIntentRunTokenMatches(
token: CreatePrIntentRunToken,
current: CreatePrIntentCurrentTarget
): boolean {
return (
token.repoId === current.repoId &&
token.worktreeId === current.worktreeId &&
token.worktreePath === current.worktreePath &&
token.branch === current.branch
)
}
export function createPrIntentGitStatusMatchesToken(
token: CreatePrIntentRunToken,
status: { branch?: string | null }
): boolean {
const branch = normalizeHostedReviewHeadRef(status.branch ?? '')
return branch.length > 0 && branch === token.branch
}
export function getCreatePrIntentStagePaths(grouped: {
unstaged: GitStatusEntry[]
untracked: GitStatusEntry[]
}): string[] {
return [
...getStageAllPaths(grouped.unstaged, 'unstaged'),
...getStageAllPaths(grouped.untracked, 'untracked')
]
}
export function resolveCreatePrIntentRemoteStep({
upstreamStatus,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch
}: {
upstreamStatus: GitUpstreamStatus | undefined
hostedReviewCreation?: HostedReviewCreationEligibility | null
branchCommitsAhead?: number
hasCurrentBranch: boolean
}): CreatePrIntentRemoteStep {
if (!hasCurrentBranch || !hostedReviewCreation || hostedReviewCreation.canCreate) {
return 'none'
}
if (hostedReviewCreation.blockedReason === 'no_upstream') {
return branchCommitsAhead && branchCommitsAhead > 0 ? 'publish' : 'blocked'
}
if (hostedReviewCreation.blockedReason === 'needs_push') {
return 'push'
}
if (
hostedReviewCreation.blockedReason === 'needs_sync' &&
shouldForcePushWithLeaseForUpstream(upstreamStatus)
) {
return 'force_push'
}
if (hostedReviewCreation.blockedReason === 'needs_sync') {
return 'blocked'
}
return 'none'
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
import type { PrimaryAction } from './source-control-primary-action-types'
const createPrIntentAction: PrimaryAction = {
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Preparing branch for review…',
disabled: true
}
const createPrAction: PrimaryAction = {
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
}
describe('resolveVisibleCreatePrHeaderAction', () => {
it('hides the header when the hosted-review composer owns direct Create PR', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrAction,
directCreatePrAction: createPrAction,
isCreatePrIntentInFlight: false,
primaryActionKind: 'create_pr'
})
).toBeNull()
})
it('hides the header while Create PR intent is in flight on the commit-area primary', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrIntentAction,
directCreatePrAction: null,
isCreatePrIntentInFlight: true,
primaryActionKind: 'create_pr_intent'
})
).toBeNull()
})
it('keeps the header visible when intent is in flight but the primary is a prerequisite action', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrIntentAction,
directCreatePrAction: null,
isCreatePrIntentInFlight: true,
primaryActionKind: 'publish'
})
).toEqual(createPrIntentAction)
})
})
@@ -0,0 +1,98 @@
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
import type { GitUpstreamStatus } from '../../../../shared/types'
import type { PrimaryAction } from './source-control-primary-action-types'
export type CreatePrIntentKind =
| 'dirty'
| 'message_required'
| 'no_upstream'
| 'needs_push'
| 'force_push'
export type CreatePrIntentEligibility = {
eligible: boolean
kind: CreatePrIntentKind | null
}
export function resolveCreatePrIntentEligibility({
stagedCount,
hasStageableChanges,
hasMessage,
hasUnresolvedConflicts,
upstreamStatus,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch = true
}: {
stagedCount: number
hasStageableChanges: boolean
hasMessage: boolean
hasUnresolvedConflicts: boolean
upstreamStatus: GitUpstreamStatus | undefined
hostedReviewCreation?: HostedReviewCreationEligibility | null
branchCommitsAhead?: number
hasCurrentBranch?: boolean
}): CreatePrIntentEligibility {
if (
hasUnresolvedConflicts ||
!hasCurrentBranch ||
!hostedReviewCreation ||
hostedReviewCreation.canCreate ||
!supportsHostedReviewCreation(hostedReviewCreation.provider)
) {
return { eligible: false, kind: null }
}
if (hostedReviewCreation.blockedReason === 'dirty') {
if (stagedCount > 0 && !hasMessage) {
return { eligible: true, kind: 'message_required' }
}
return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' }
}
if (hostedReviewCreation.blockedReason === 'no_upstream') {
const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0
return {
eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges,
kind: 'no_upstream'
}
}
if (hostedReviewCreation.blockedReason === 'needs_push') {
return { eligible: true, kind: 'needs_push' }
}
if (
hostedReviewCreation.blockedReason === 'needs_sync' &&
shouldForcePushWithLeaseForUpstream(upstreamStatus)
) {
return { eligible: true, kind: 'force_push' }
}
return { eligible: false, kind: null }
}
export function resolveVisibleCreatePrHeaderAction({
createPrHeaderAction,
directCreatePrAction,
isCreatePrIntentInFlight,
primaryActionKind
}: {
createPrHeaderAction: PrimaryAction | null
directCreatePrAction: PrimaryAction | null
isCreatePrIntentInFlight: boolean
primaryActionKind: PrimaryAction['kind']
}): PrimaryAction | null {
if (directCreatePrAction) {
return null
}
// Why: CommitArea already mirrors in-flight Create PR intent on the primary;
// keeping a second spinning header button stacks redundant spinners once
// message generation also shows one.
if (isCreatePrIntentInFlight && primaryActionKind === 'create_pr_intent') {
return null
}
return createPrHeaderAction
}
@@ -596,6 +596,54 @@ describe('resolveDropdownItems', () => {
expect(byKind.push_create_pr.disabled).toBe(false)
})
it.each(['azure-devops', 'gitea'] as const)(
'enables push-before-PR recovery for %s review creation',
(provider) => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.create_pr.label).toBe('Create PR')
expect(byKind.create_pr.hint).toBe('Push first')
expect(byKind.push_create_pr.label).toBe('Push before PR')
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request')
expect(byKind.push_create_pr.disabled).toBe(false)
}
)
it.each([
['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'],
['gitea', 'Set ORCA_GITEA_TOKEN in this environment']
] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'auth_required',
nextAction: 'authenticate'
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.create_pr.hint).toBe(hint)
})
it('uses GitLab auth copy when MR creation needs authentication', () => {
const items = resolveDropdownItems(
inputs({
@@ -4,6 +4,7 @@
import type { PrimaryActionInputs } from './source-control-primary-action'
import type { GitConflictOperation } from '../../../../shared/types'
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
@@ -96,11 +97,19 @@ function formatRebaseBaseRef(baseRef: string): string {
function reviewCopy(
provider: NonNullable<PrimaryActionInputs['hostedReviewCreation']>['provider'] | undefined
): ReturnType<typeof localizedHostedReviewCopy> & {
authCommand: 'gh auth login' | 'glab auth login'
authInstruction: string
} {
const authInstruction =
provider === 'gitlab'
? 'Run glab auth login'
: provider === 'azure-devops'
? 'Set ORCA_AZURE_DEVOPS_TOKEN'
: provider === 'gitea'
? 'Set ORCA_GITEA_TOKEN'
: 'Run gh auth login'
return {
...localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider)),
authCommand: provider === 'gitlab' ? 'glab auth login' : 'gh auth login'
authInstruction
}
}
@@ -491,7 +500,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
case 'needs_sync':
return shouldForcePushWithLease ? 'Force Push first' : 'Sync first'
case 'auth_required':
return `Run ${createReviewCopy.authCommand} in this environment`
return `${createReviewCopy.authInstruction} in this environment`
case 'unsupported_provider':
return 'Unsupported provider'
case 'existing_review':
@@ -521,7 +530,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
const canPushAndCreate =
!globalBusy &&
!upstreamLoading &&
(hostedReviewCreation?.provider === 'github' || hostedReviewCreation?.provider === 'gitlab') &&
supportsHostedReviewCreation(hostedReviewCreation?.provider) &&
(hostedReviewCreation.blockedReason === 'needs_push' ||
(hostedReviewCreation.blockedReason === 'needs_sync' && shouldForcePushWithLease))
const pushCreatePRItem: DropdownItem = {
@@ -0,0 +1,171 @@
import React, { useState } from 'react'
import { ChevronDown, RefreshCw, SlidersHorizontal, Sparkle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { TuiAgent } from '../../../../shared/types'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import { translate } from '@/i18n/i18n'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
export type SourceControlFixSplitButtonProps = {
label: string
actionId: SourceControlLaunchActionId
dialogTitle: string
dialogDescription: string
launchSource: LaunchSource
contextUnavailableLabel: string
primaryTitle: string
primaryAriaLabel: string
chevronTitle: string
chevronAriaLabel: string
worktreeId: string | null
groupId: string | null
connectionId?: string | null
repoId?: string | null
launchPlatform?: NodeJS.Platform
prompt: string | null
isLaunching: boolean
disabledReason?: string
variant: React.ComponentProps<typeof Button>['variant']
size: React.ComponentProps<typeof Button>['size']
iconClassName: string
primaryClassName?: string
chevronClassName?: string
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onOpenSettings?: () => void
onFixWithDefaultAgent: (promptOverride?: string) => Promise<boolean> | boolean
onPromptDelivered?: () => void
}
export function SourceControlFixSplitButton({
label,
actionId,
dialogTitle,
dialogDescription,
launchSource,
contextUnavailableLabel,
primaryTitle,
primaryAriaLabel,
chevronTitle,
chevronAriaLabel,
worktreeId,
groupId,
connectionId,
repoId,
launchPlatform,
prompt,
isLaunching,
disabledReason,
variant,
size,
iconClassName,
primaryClassName,
chevronClassName,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
onSaveAgentDefault,
onOpenSettings,
onFixWithDefaultAgent,
onPromptDelivered
}: SourceControlFixSplitButtonProps): React.JSX.Element {
const [composerOpen, setComposerOpen] = useState(false)
const canLaunch = Boolean(worktreeId && groupId && prompt && !disabledReason)
const dividerClass = variant === 'default' ? 'border-primary-foreground/20' : 'border-border'
return (
<>
<DropdownMenu>
<div className="flex shrink-0 items-stretch">
<Button
type="button"
variant={variant}
size={size}
className={cn('rounded-r-none', primaryClassName)}
disabled={isLaunching || !canLaunch}
title={disabledReason ?? primaryTitle}
aria-label={primaryAriaLabel}
onClick={() => void onFixWithDefaultAgent()}
>
{isLaunching ? (
<RefreshCw className={cn(iconClassName, 'animate-spin')} />
) : (
<Sparkle className={iconClassName} />
)}
{label}
</Button>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
className={cn('rounded-l-none border-l', dividerClass, chevronClassName)}
disabled={isLaunching || !canLaunch}
title={chevronTitle}
aria-label={chevronAriaLabel}
>
<ChevronDown className={iconClassName} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end" className="min-w-[210px] p-1">
{worktreeId && groupId && prompt && !disabledReason ? (
<DropdownMenuItem
onSelect={() => setComposerOpen(true)}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<SlidersHorizontal className="size-4 text-muted-foreground" />
{translate(
'auto.components.right.sidebar.SourceControl.f0a2dc9e46',
'Customize launch...'
)}
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>{contextUnavailableLabel}</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{worktreeId && groupId && prompt ? (
<SourceControlAgentActionDialog
open={composerOpen}
onOpenChange={setComposerOpen}
actionId={actionId}
title={dialogTitle}
description={dialogDescription}
baseCommandInput={prompt}
worktreeId={worktreeId}
groupId={groupId}
connectionId={connectionId}
repoId={repoId}
promptDelivery="submit-after-ready"
launchPlatform={launchPlatform}
launchSource={launchSource}
savedAgentId={savedAgentId}
savedCommandInputTemplate={savedCommandInputTemplate}
savedAgentArgs={savedAgentArgs}
onSaveAgentDefault={onSaveAgentDefault}
onOpenSettings={onOpenSettings}
onLaunched={onPromptDelivered}
/>
) : null}
</>
)
}
@@ -15,6 +15,7 @@ export type PrimaryActionKind =
| 'pull'
| 'sync'
| 'publish'
| 'create_pr_intent'
| 'create_pr'
// Why: the in-flight remote op tracker stores which action the user actually
@@ -65,6 +66,7 @@ export type PrimaryActionInputs = {
// Why: detached HEAD can look like an unpublished branch from upstream
// status alone, but it has no branch ref that Publish Branch can push.
hasCurrentBranch?: boolean
isPrIntentInFlight?: boolean
}
export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>, string> = {
@@ -73,5 +75,6 @@ export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>,
pull: 'Pull',
sync: 'Sync',
publish: 'Publish Branch',
create_pr_intent: 'Create PR',
create_pr: 'Create PR'
}
@@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
import { resolveCreatePrHeaderAction } from './source-control-primary-create-pr-intent-action'
function inputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
const upstreamInSync = {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0
}
describe('resolvePrimaryAction Create PR intent', () => {
it('returns Create PR intent for an unpublished clean branch with commits to publish', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for patch-equivalent force-push before review', () => {
const result = resolvePrimaryAction(
inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for a branch that needs a safe push before review', () => {
const input = inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
const result = resolvePrimaryAction(input)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
expect(resolveCreatePrHeaderAction(input)).toEqual(result)
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Push',
title: 'Push 2 commits',
disabled: false
})
})
it('returns Create PR intent for a dirty tree when hosted review prep can commit changes', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
})
it('returns Create PR intent for staged changes without a message so the flow can request one', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create MR intent with provider copy for a GitLab dirty branch', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.label).toBe('Create MR')
expect(result.title).toBe('Prepare this branch and create a merge request')
})
it('keeps in-flight Create MR intent copy provider-aware', () => {
const input = inputs({
isPrIntentInFlight: true,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR intent for a %s branch that needs a safe push before review',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
}
)
it('separates Publish Branch from the Create PR header action for unpublished commits', () => {
const input = inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'publish',
label: 'Publish Branch',
title: 'Publish this branch to origin',
disabled: false
})
})
it('separates Force Push from the Create PR header action for patch-equivalent divergence', () => {
const input = inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Force Push',
title:
'Remote only has older copies of local commits. Force push 4 branch commits with lease to update origin/feature.',
disabled: false
})
})
it('returns direct Create PR as a header action when the branch is ready', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
})
})
@@ -1,6 +1,9 @@
/* eslint-disable max-lines -- Why: this state-machine table intentionally keeps every primary-action priority case together so merge regressions are visible in one file. */
import { describe, expect, it } from 'vitest'
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
// Why: a shared defaults object keeps each case row terse while making the
// "this is the one knob that differs from the baseline" intent obvious.
@@ -380,6 +383,58 @@ describe('resolvePrimaryAction', () => {
expect(result.disabled).toBe(false)
})
it('keeps Stage All available in the commit area when Create PR intent is additive', () => {
const input = inputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes',
disabled: false
})
})
it('keeps the partial-staging reason on the additive commit-area Stage All action', () => {
const input = inputs({
stagedCount: 1,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: true,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes before committing partially staged files',
disabled: false
})
})
it('still resolves to Commit when staged and unrelated unstaged files exist', () => {
const result = resolvePrimaryAction(
inputs({
@@ -486,4 +541,28 @@ describe('resolvePrimaryAction', () => {
disabled: false
})
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR when a clean tracked %s branch is eligible for review creation',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider,
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
)
expect(result).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
}
)
})
@@ -14,6 +14,11 @@ import {
describePushCount,
describeSyncCounts
} from './source-control-primary-action-titles'
import {
resolveCreatePrIntentInFlightPrimaryAction,
resolveCreatePrIntentPrimaryAction
} from './source-control-primary-create-pr-intent-action'
import { resolveUnpublishedPrimaryAction } from './source-control-primary-unpublished-action'
export type {
PrimaryActionKind,
@@ -34,13 +39,15 @@ export type {
* 1. In-flight commit locks the primary to a disabled "Commit".
* 2. In-flight remote operation keeps the current label but disables it.
* 3. Unresolved conflicts block the commit path entirely.
* 4. Has partially staged files → "Stage All" to avoid hook-time partial
* 4. Create PR intent can own the primary; manual prerequisites are
* exposed as a visible sibling action by CommitArea.
* 5. Has partially staged files → "Stage All" to avoid hook-time partial
* stash conflicts.
* 5. Has staged files + message → plain "Commit" (compound flows live in
* the dropdown; after the commit lands, step 7 rotates the primary to
* the appropriate single remote action).
* 6. Has staged files + no message → disabled "Commit" with a reason.
* 7. Clean tree → adaptive remote action (or disabled "Commit" no-op).
* 6. Has staged files + message → plain "Commit" (compound flows live in
* the dropdown; after the commit lands, the clean-tree rung rotates
* the primary to the appropriate single remote action).
* 7. Has staged files + no message → disabled "Commit" with a reason.
* 8. Clean tree → adaptive remote action (or disabled "Commit" no-op).
*
* An undefined upstream status means fetchUpstreamStatus has not resolved
* yet for this worktree. We return a disabled Commit so the button has a
@@ -62,9 +69,14 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
isPRStateLoading,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch = true
hasCurrentBranch = true,
isPrIntentInFlight = false
} = inputs
if (isPrIntentInFlight) {
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
}
// 1. Commit in flight — lock the primary no matter what else is true.
if (isCommitting) {
return {
@@ -101,11 +113,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
}
const createPrIntent = resolveCreatePrIntentPrimaryAction(inputs)
if (createPrIntent) {
return createPrIntent
}
const hasStaged = stagedCount > 0
// 4. A path with both staged and unstaged edits can make lint-staged's
// partial-stash restore fail after formatters rewrite the staged copy. Push
// the user through Stage All first so the index matches the worktree.
// Why: partial staging can break hook-time restores during the intent flow;
// keep Stage All visible as a sibling prerequisite without replacing Create PR.
if (hasStaged && hasPartiallyStagedChanges) {
return {
kind: 'stage',
@@ -194,78 +210,12 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
if (!upstreamStatus.hasUpstream) {
if (!hasCurrentBranch) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
'Check out a branch before publishing commits.'
),
disabled: true
}
}
if (branchCommitsAhead === 0) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.acce237921',
'Nothing to commit. Branch has no changes to publish.'
),
disabled: true
}
}
if (isPRStateLoading) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
'Checking PR status…'
),
disabled: true
}
}
if (prState === 'merged') {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
'Nothing to commit. PR is already merged.'
),
disabled: true
}
}
return {
kind: 'publish',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
'Publish Branch'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
'Publish this branch to origin'
),
disabled: false
}
return resolveUnpublishedPrimaryAction({
hasCurrentBranch,
branchCommitsAhead,
isPRStateLoading,
prState
})
}
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
@@ -347,3 +297,13 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
disabled: true
}
}
export function resolveCommitAreaPrimaryAction(inputs: PrimaryActionInputs): PrimaryAction {
// Why: review creation is additive chrome. The commit area should keep the
// same local/remote primary action it would have without review eligibility.
return resolvePrimaryAction({
...inputs,
hostedReviewCreation: null,
isPrIntentInFlight: false
})
}
@@ -0,0 +1,96 @@
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
resolveSupportedHostedReviewCopyProvider
} from '@/i18n/hosted-review-localized-copy'
import type { PrimaryAction, PrimaryActionInputs } from './source-control-primary-action-types'
import { resolveCreatePrIntentEligibility } from './source-control-create-pr-intent-state'
export function resolveCreatePrIntentInFlightPrimaryAction(
inputs?: Pick<PrimaryActionInputs, 'hostedReviewCreation'>
): PrimaryAction {
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs?.hostedReviewCreation?.provider)
)
return {
kind: 'create_pr_intent',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.d37e68f61d',
'Preparing branch for review…'
),
disabled: true
}
}
export function resolveCreatePrIntentPrimaryAction(
inputs: PrimaryActionInputs
): PrimaryAction | null {
const createPrIntent = resolveCreatePrIntentEligibility({
stagedCount: inputs.stagedCount,
hasStageableChanges: inputs.hasStageableChanges,
hasMessage: inputs.hasMessage,
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
upstreamStatus: inputs.upstreamStatus,
hostedReviewCreation: inputs.hostedReviewCreation,
branchCommitsAhead: inputs.branchCommitsAhead,
hasCurrentBranch: inputs.hasCurrentBranch
})
if (!createPrIntent.eligible) {
return null
}
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider)
)
return {
kind: 'create_pr_intent',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.c72e5e65d1',
'Prepare this branch and create a {{value0}}',
{ value0: copy.reviewLabel }
),
disabled: false
}
}
export function resolveCreatePrHeaderAction(inputs: PrimaryActionInputs): PrimaryAction | null {
if (inputs.isPrIntentInFlight) {
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
}
if (inputs.isCommitting || inputs.isRemoteOperationActive || inputs.hasUnresolvedConflicts) {
return null
}
if (inputs.hostedReviewCreation?.canCreate) {
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation.provider)
)
return {
kind: 'create_pr',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.946a8a05ea',
'Create a {{value0}} for this branch',
{ value0: copy.reviewLabel }
),
disabled: false
}
}
return resolveCreatePrIntentPrimaryAction(inputs)
}
@@ -0,0 +1,88 @@
import { translate } from '@/i18n/i18n'
import type { PrimaryAction } from './source-control-primary-action-types'
import type { PRState } from '../../../../shared/types'
export function resolveUnpublishedPrimaryAction({
hasCurrentBranch,
branchCommitsAhead,
isPRStateLoading,
prState
}: {
hasCurrentBranch: boolean
branchCommitsAhead?: number
isPRStateLoading?: boolean
prState?: PRState | null
}): PrimaryAction {
if (!hasCurrentBranch) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
'Check out a branch before publishing commits.'
),
disabled: true
}
}
if (branchCommitsAhead === 0) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.acce237921',
'Nothing to commit. Branch has no changes to publish.'
),
disabled: true
}
}
if (isPRStateLoading) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
'Checking PR status…'
),
disabled: true
}
}
if (prState === 'merged') {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
'Nothing to commit. PR is already merged.'
),
disabled: true
}
}
return {
kind: 'publish',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
'Publish Branch'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
'Publish this branch to origin'
),
disabled: false
}
}
@@ -369,7 +369,9 @@ export function useCreatePullRequestDialogFields({
base: stripBaseRef(base.trim()),
title,
body,
draft
draft,
provider: eligibility?.provider,
useTemplate: resolvedPrDefaults.useTemplate
},
overrides
)
@@ -417,9 +419,11 @@ export function useCreatePullRequestDialogFields({
draft,
effectiveGenerating,
applyGeneratedFields,
eligibility?.provider,
generation,
generateDisabled,
onBranchChangedByGeneration,
resolvedPrDefaults.useTemplate,
settings,
title,
worktreeId,
@@ -81,7 +81,7 @@ export function MobilePairingQrSection({
)}
className="size-48"
/>
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 can-hover:opacity-0 transition-opacity group-hover:opacity-100" />
</button>
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
<p className="text-muted-foreground max-w-xs text-center text-xs">
@@ -3,7 +3,7 @@
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
import { getExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
import {
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
RUNTIME_PROTOCOL_VERSION,
@@ -15,7 +15,8 @@ import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection'
let container: HTMLDivElement
let root: Root
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'displayName' | 'path'>): Repo {
return {
@@ -143,7 +144,7 @@ describe('RepositoryHostSetupsSection', () => {
renderSection(localRepo)
expect(container.textContent).toContain('Viewing host')
expect(container.textContent).toContain(localHostLabel)
expect(container.textContent).toContain(LOCAL_HOST_LABEL)
})
it('opens the selected host setup settings pane through the setup repo id', () => {
@@ -160,7 +160,7 @@ export function ShortcutBindingRow({
{/* Reset/Disable reveal on hover or keyboard focus to keep the row calm;
they stay reachable via focus-within for keyboard users. */}
{hasBinding ? (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/shortcut:opacity-100 group-focus-within/shortcut:opacity-100">
<div className="flex items-center gap-0.5 can-hover:opacity-0 transition-opacity group-hover/shortcut:opacity-100 group-focus-within/shortcut:opacity-100">
{modified ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -162,12 +162,12 @@ export function VoiceSpeechModelSection({
)
)
}}
className="shrink-0 p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive transition-all rounded"
className="shrink-0 p-1 text-muted-foreground can-hover:opacity-0 group-hover:opacity-100 hover:text-destructive transition-all rounded"
>
<Trash2 className="size-3" />
</button>
) : !isCloud && !isReady && !isDownloading ? (
<span className="shrink-0 p-1 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
<span className="shrink-0 p-1 text-muted-foreground can-hover:opacity-0 group-hover:opacity-100 transition-opacity">
<Download className="size-3" />
</span>
) : null}
@@ -3,12 +3,14 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
GitHubIntegrationCard,
GitLabIntegrationCard
} from './cli-source-control-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
openSettingsPage: () => void
@@ -48,7 +50,6 @@ vi.mock('./source-control-preflight-card-status', () => ({
let root: Root | null = null
let container: HTMLDivElement | null = null
const localHostLabel = getLocalExecutionHostLabel()
async function renderCard(card: React.ReactNode): Promise<HTMLDivElement> {
container = document.createElement('div')
@@ -90,7 +91,7 @@ describe('CLI source-control integration card account scope', () => {
expect(rendered.textContent).toContain('GitHub')
expect(rendered.textContent).toContain('Connected')
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
)
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderAccountScope, getProviderRateLimitScope } from './provider-account-scope'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('getProviderAccountScope', () => {
it('describes provider accounts as client-owned without an active runtime', () => {
expect(getProviderAccountScope({ activeRuntimeEnvironmentId: null })).toEqual({
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
description:
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
})
@@ -21,7 +23,7 @@ describe('getProviderAccountScope', () => {
it('describes provider API budgets as host-scoped', () => {
expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: null }, 'GitHub')).toEqual({
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
description:
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.'
})
@@ -1,8 +1,10 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { GitHubRateLimitPanel } from '@/components/github/github-rate-limit-display'
import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-display'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
@@ -19,7 +21,6 @@ const mocks = vi.hoisted(() => ({
} as StoreState
}
}))
const localHostLabel = getLocalExecutionHostLabel()
vi.mock('@/store', () => ({
useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.store.current)
@@ -35,7 +36,7 @@ describe('provider rate-limit panels account scope', () => {
const markup = renderToStaticMarkup(<GitHubRateLimitPanel />)
expect(markup).toContain(`Budget scope: ${localHostLabel}`)
expect(markup).toContain(`Budget scope: ${LOCAL_HOST_LABEL}`)
expect(markup).toContain(
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings &gt; Remote Orca Servers &gt; Advanced to view server-owned budgets.'
)
@@ -3,10 +3,12 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { LinearIntegrationCard } from './task-tracker-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
linearStatus: {
connected: boolean
@@ -46,7 +48,6 @@ vi.mock('@/components/linear-api-key-dialog', () => ({
let root: Root | null = null
let container: HTMLDivElement | null = null
const localHostLabel = getLocalExecutionHostLabel()
function installStore(
connected: boolean,
@@ -108,7 +109,7 @@ describe('LinearIntegrationCard account scope', () => {
const rendered = await renderCard()
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
)
@@ -187,7 +187,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
variant="ghost"
size="icon-xs"
type="button"
className="size-5 shrink-0 text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/host-header:opacity-100 data-[state=open]:opacity-100"
className="size-5 shrink-0 text-muted-foreground can-hover:opacity-0 transition-opacity focus-visible:opacity-100 group-hover/host-header:opacity-100 data-[state=open]:opacity-100"
aria-label={translate(
'auto.components.sidebar.HostSectionHeaderMenu.4f2c8a9b10',
'Host actions for {{value0}}',
@@ -84,7 +84,7 @@ export function PendingWorktreeRow({
onClick={() => useAppStore.getState().removePendingWorktreeCreation(creationId)}
className={cn(
'mr-1 flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:opacity-100',
isError ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
isError ? 'opacity-100' : 'can-hover:opacity-0 group-hover:opacity-100'
)}
>
<X className="size-3.5" />
@@ -182,7 +182,7 @@ export default function WorkspaceKanbanStatusLane({
variant="secondary"
size="xs"
className={cn(
'mt-2 h-7 w-full opacity-0 transition-opacity',
'mt-2 h-7 w-full can-hover:opacity-0 transition-opacity',
'group-hover/lane:opacity-100 group-focus-within/lane:opacity-100'
)}
aria-label={createTooltip}
@@ -242,7 +242,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
</span>
</TooltipContent>
</Tooltip>
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 can-hover:opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
<PortAction
label={translate(
'auto.components.sidebar.WorktreeCardPorts.33bc7d7495',

Some files were not shown because too many files have changed in this diff Show More