Merge remote-tracking branch 'origin/main' into brennanb2025/open-scroll

This commit is contained in:
brennanb2025
2026-05-19 13:36:58 -07:00
92 changed files with 7942 additions and 910 deletions
+2 -28
View File
@@ -52,6 +52,7 @@ import {
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys'
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
@@ -274,33 +275,6 @@ type TerminalCreateResult = {
type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
type AccessoryKey = {
label: string
bytes: string
accessibilityLabel?: string
repeatable?: boolean
}
const ACCESSORY_KEYS: AccessoryKey[] = [
{ label: 'Esc', bytes: '\x1b' },
{ label: 'Tab', bytes: '\t' },
{ label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{ label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true },
{ label: '↑', bytes: '\x1b[A', repeatable: true },
{ label: '↓', bytes: '\x1b[B', repeatable: true },
{ label: '←', bytes: '\x1b[D', repeatable: true },
{ label: '→', bytes: '\x1b[C', repeatable: true },
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
const STATUS_LABELS: Record<ConnectionState, string> = {
connecting: 'Connecting',
handshaking: 'Securing',
@@ -2961,7 +2935,7 @@ export default function SessionScreen() {
</Text>
</Pressable>
)}
{ACCESSORY_KEYS.map((key) => (
{TERMINAL_ACCESSORY_KEYS.map((key) => (
<Pressable
key={key.label}
style={({ pressed }) => [
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { TERMINAL_ACCESSORY_KEYS } from './terminal-accessory-keys'
describe('TERMINAL_ACCESSORY_KEYS', () => {
it('sends reverse-tab with a non-repeatable Shift+Tab key', () => {
const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.label === 'Shift+Tab')
expect(key).toEqual({
label: 'Shift+Tab',
bytes: '\x1b[Z',
accessibilityLabel: 'Shift Tab'
})
})
it('keeps repeat behavior explicit for built-in terminal keys', () => {
const repeatableLabels = new Set(['⌫', 'Del', '↑', '↓', '←', '→'])
for (const key of TERMINAL_ACCESSORY_KEYS) {
expect(key.repeatable === true).toBe(repeatableLabels.has(key.label))
}
})
})
@@ -0,0 +1,28 @@
export type TerminalAccessoryKey = {
label: string
bytes: string
accessibilityLabel?: string
repeatable?: boolean
}
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
{ label: 'Esc', bytes: '\x1b' },
{ label: 'Tab', bytes: '\t' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{ label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true },
{ label: '↑', bytes: '\x1b[A', repeatable: true },
{ label: '↓', bytes: '\x1b[B', repeatable: true },
{ label: '←', bytes: '\x1b[D', repeatable: true },
{ label: '→', bytes: '\x1b[C', repeatable: true },
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.9-rc.0",
"version": "1.4.9",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+9
View File
@@ -212,6 +212,15 @@ type GitExecOptions = {
env?: NodeJS.ProcessEnv
}
export function gitOptionalLocksDisabledEnv(
env: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
return {
...env,
GIT_OPTIONAL_LOCKS: '0'
}
}
/**
* Async git command execution. Drop-in replacement for
* `execFileAsync('git', args, { cwd, encoding, ... })`.
+9 -5
View File
@@ -13,7 +13,11 @@ const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, readFileMock, rmMock,
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileAsyncBuffer: gitExecFileAsyncBufferMock
gitExecFileAsyncBuffer: gitExecFileAsyncBufferMock,
gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({
...env,
GIT_OPTIONAL_LOCKS: '0'
})
}))
vi.mock('fs/promises', () => ({
@@ -363,7 +367,7 @@ describe('getStatus', () => {
'--branch',
'--untracked-files=all'
],
{ cwd: '/repo' }
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
)
expect(result.entries).toEqual([
{ path: 'docs/日本語/sample.md', status: 'modified', area: 'unstaged' }
@@ -389,7 +393,7 @@ describe('getStatus', () => {
'--untracked-files=all',
'--ignored=matching'
],
{ cwd: '/repo' }
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
)
expect(result.ignoredPaths).toEqual(['dist/', 'generated/file.js'])
})
@@ -458,7 +462,7 @@ describe('getStatus', () => {
'--branch',
'--untracked-files=all'
],
{ cwd: '/repo' }
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
)
expect('ignoredPaths' in result).toBe(false)
})
@@ -482,7 +486,7 @@ describe('getStatus', () => {
'--untracked-files=all',
'--ignored=matching'
],
{ cwd: '/repo' }
{ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) }
)
expect(result.ignoredPaths).toEqual(['dist/', '.env', 'coverage/'])
expect(result.entries).toEqual([])
+7 -2
View File
@@ -16,7 +16,7 @@ import type {
GitStatusResult
} from '../../shared/types'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { gitExecFileAsync, gitExecFileAsyncBuffer } from './runner'
import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv } from './runner'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
@@ -59,7 +59,12 @@ export async function getStatus(
if (options.includeIgnored) {
statusArgs.push('--ignored=matching')
}
const statusPromise = gitExecFileAsync(statusArgs, { cwd: worktreePath })
const statusPromise = gitExecFileAsync(statusArgs, {
cwd: worktreePath,
// Why: status polling is read-like; avoid refreshing the index and racing
// terminal Git commands on `.git/worktrees/*/index.lock`.
env: gitOptionalLocksDisabledEnv()
})
const conflictOperation = await conflictPromise
try {
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
rateLimitGuardMock: vi.fn(() => ({ blocked: false }))
}))
vi.mock('./gh-utils', () => ({
execFileAsync: vi.fn(),
ghExecFileAsync: ghExecFileAsyncMock,
githubRepoContext: (repoPath: string, connectionId?: string | null) => ({
repoPath,
connectionId: connectionId ?? null
}),
ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }),
getOwnerRepo: getOwnerRepoMock,
getIssueOwnerRepo: vi.fn(),
extractExecError: vi.fn((err: unknown) => ({ stderr: String(err), stdout: '' })),
acquire: vi.fn(),
release: vi.fn(),
_resetOwnerRepoCache: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: vi.fn()
}))
vi.mock('./rate-limit', () => ({
rateLimitGuard: rateLimitGuardMock,
noteRateLimitSpend: vi.fn()
}))
import { getPRCheckDetails, _resetOwnerRepoCache } from './client'
describe('getPRCheckDetails', () => {
beforeEach(() => {
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
_resetOwnerRepoCache()
})
it('fetches check-run output, annotations, and workflow jobs for inline details', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
const actionUrl = 'https://github.com/acme/widgets/actions/runs/77/job/88'
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
name: 'track-community-pr',
status: 'completed',
conclusion: 'success',
html_url: actionUrl,
details_url: actionUrl,
started_at: '2026-05-18T19:00:00Z',
completed_at: '2026-05-18T19:02:00Z',
output: {
title: 'Successful',
summary: 'Tracked community PR',
text: 'No issues found.'
},
check_suite: { workflow_run: { id: 77 } }
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
path: '.github/workflows/community.yml',
start_line: 12,
end_line: 12,
annotation_level: 'notice',
title: 'Tracked',
message: 'Community PR tracked.',
raw_details: 'details'
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
jobs: [
{
name: 'track-community-pr',
status: 'completed',
conclusion: 'success',
started_at: '2026-05-18T19:00:00Z',
completed_at: '2026-05-18T19:02:00Z',
html_url: actionUrl,
steps: [{ name: 'Run tracker', status: 'completed', conclusion: 'success' }]
}
]
})
})
const details = await getPRCheckDetails('/repo-root', {
checkRunId: 88,
checkName: 'track-community-pr'
})
expect(details).toMatchObject({
name: 'track-community-pr',
status: 'completed',
conclusion: 'success',
title: 'Successful',
summary: 'Tracked community PR',
text: 'No issues found.',
annotations: [
{
path: '.github/workflows/community.yml',
startLine: 12,
endLine: 12,
annotationLevel: 'notice',
title: 'Tracked',
message: 'Community PR tracked.',
rawDetails: 'details'
}
],
jobs: [
{
name: 'track-community-pr',
conclusion: 'success',
steps: [{ name: 'Run tracker', status: 'completed', conclusion: 'success' }]
}
]
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['api', 'repos/acme/widgets/check-runs/88'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', 'repos/acme/widgets/check-runs/88/annotations?per_page=20'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', 'repos/acme/widgets/actions/runs/77/jobs?per_page=100'],
{ cwd: '/repo-root' }
)
})
})
+186
View File
@@ -8,6 +8,7 @@ import type {
PRInfo,
PRMergeableState,
PRCheckDetail,
PRCheckRunDetails,
GitHubCommentResult,
GitHubPRReviewCommentInput,
PRComment,
@@ -1848,6 +1849,159 @@ export async function getPRChecks(
}
}
function nullableString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function nullableNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] {
if (!Array.isArray(raw)) {
return []
}
return raw
.filter((annotation): annotation is Record<string, unknown> => Boolean(annotation))
.map((annotation) => ({
path: nullableString(annotation.path),
startLine: nullableNumber(annotation.start_line),
endLine: nullableNumber(annotation.end_line),
annotationLevel: nullableString(annotation.annotation_level),
title: nullableString(annotation.title),
message: nullableString(annotation.message) ?? '',
rawDetails: nullableString(annotation.raw_details)
}))
}
function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] {
if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) {
return []
}
const jobs = (raw as { jobs: unknown[] }).jobs
.filter((job): job is Record<string, unknown> => Boolean(job))
.map((job) => ({
name: nullableString(job.name) ?? 'Unnamed job',
status: nullableString(job.status),
conclusion: nullableString(job.conclusion),
startedAt: nullableString(job.started_at),
completedAt: nullableString(job.completed_at),
url: nullableString(job.html_url),
steps: Array.isArray(job.steps)
? job.steps
.filter((step): step is Record<string, unknown> => Boolean(step))
.map((step) => ({
name: nullableString(step.name) ?? 'Unnamed step',
status: nullableString(step.status),
conclusion: nullableString(step.conclusion),
startedAt: nullableString(step.started_at),
completedAt: nullableString(step.completed_at)
}))
: []
}))
const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : []
return exactMatches.length > 0 ? exactMatches : jobs
}
function getWorkflowRunIdFromCheckRun(
checkRun: Record<string, unknown> | null
): number | undefined {
const checkSuite = checkRun?.check_suite
if (!checkSuite || typeof checkSuite !== 'object') {
return undefined
}
const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run
if (!workflowRun || typeof workflowRun !== 'object') {
return undefined
}
const id = (workflowRun as { id?: unknown }).id
return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined
}
export async function getPRCheckDetails(
repoPath: string,
args: {
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: OwnerRepo | null
},
connectionId?: string | null
): Promise<PRCheckRunDetails | null> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId))
if (!ownerRepo) {
return null
}
await acquire()
try {
let checkRun: Record<string, unknown> | null = null
let annotations: PRCheckRunDetails['annotations'] = []
if (args.checkRunId) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`],
ghOptions
)
checkRun = JSON.parse(stdout) as Record<string, unknown>
try {
const annotationsResult = await ghExecFileAsync(
[
'api',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20`
],
ghOptions
)
annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout))
} catch (err) {
console.warn('getPRCheckDetails annotations fetch failed:', err)
}
}
const workflowRunId = args.workflowRunId ?? getWorkflowRunIdFromCheckRun(checkRun)
let jobs: PRCheckRunDetails['jobs'] = []
if (workflowRunId) {
try {
const { stdout } = await ghExecFileAsync(
[
'api',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${workflowRunId}/jobs?per_page=100`
],
ghOptions
)
jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName)
} catch (err) {
console.warn('getPRCheckDetails workflow jobs fetch failed:', err)
}
}
const output =
checkRun?.output && typeof checkRun.output === 'object'
? (checkRun.output as Record<string, unknown>)
: null
return {
name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check',
status: nullableString(checkRun?.status),
conclusion: nullableString(checkRun?.conclusion),
url: nullableString(checkRun?.html_url) ?? args.url ?? null,
detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null,
startedAt: nullableString(checkRun?.started_at),
completedAt: nullableString(checkRun?.completed_at),
title: nullableString(output?.title),
summary: nullableString(output?.summary),
text: nullableString(output?.text),
annotations,
jobs
}
} catch (err) {
console.warn('getPRCheckDetails failed:', err)
return null
} finally {
release()
}
}
function parseActionsRunId(url: string | null | undefined): number | undefined {
if (!url) {
return undefined
@@ -2534,6 +2688,38 @@ export async function requestPRReviewers(
}
}
export async function removePRReviewers(
repoPath: string,
prNumber: number,
reviewers: string[],
connectionId?: string | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean)
if (logins.length === 0) {
return { ok: false, error: 'Enter at least one reviewer' }
}
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
await acquire()
try {
const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: message }
} finally {
release()
}
}
/**
* Update a PR's title.
*/
+14 -1
View File
@@ -27,7 +27,7 @@ vi.mock('./gh-utils', async () => {
}
})
import { createIssue, getIssue, listIssues } from './issues'
import { createIssue, getIssue, listIssues, updateIssue } from './issues'
describe('issue source operations', () => {
beforeEach(() => {
@@ -125,4 +125,17 @@ describe('issue source operations', () => {
{ cwd: '/repo-root' }
)
})
it('updates issue body through the REST issue endpoint', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await expect(updateIssue('/repo-root', 924, { body: 'Updated body' })).resolves.toEqual({
ok: true
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/924', '--raw-field', 'body=Updated body'],
{ cwd: '/repo-root' }
)
})
})
+22
View File
@@ -242,6 +242,28 @@ export async function updateIssue(
}
}
if (updates.body !== undefined) {
await acquire()
try {
await ghExecFileAsync(
[
'api',
'-X',
'PATCH',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`,
'--raw-field',
`body=${updates.body}`
],
ghOptions
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGhError(stderr).message)
} finally {
release()
}
}
// Field edits (labels, assignees, title) via gh issue edit
const editArgs: string[] = ['issue', 'edit', String(issueNumber), '--repo', repo]
let hasEditArgs = false
+92 -1
View File
@@ -29,6 +29,8 @@ const {
listWorktreesMock,
resolveCommitMessageSettingsMock,
generateCommitMessageFromContextMock,
discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemoteMock,
cancelGenerateCommitMessageLocalMock,
getSshFilesystemProviderMock,
getSshGitProviderMock
@@ -58,6 +60,8 @@ const {
listWorktreesMock: vi.fn(),
resolveCommitMessageSettingsMock: vi.fn(),
generateCommitMessageFromContextMock: vi.fn(),
discoverCommitMessageModelsLocalMock: vi.fn(),
discoverCommitMessageModelsRemoteMock: vi.fn(),
cancelGenerateCommitMessageLocalMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn(),
getSshGitProviderMock: vi.fn()
@@ -129,6 +133,8 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
vi.mock('../text-generation/commit-message-text-generation', () => ({
resolveCommitMessageSettings: resolveCommitMessageSettingsMock,
generateCommitMessageFromContext: generateCommitMessageFromContextMock,
discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock,
cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock
}))
@@ -205,6 +211,8 @@ describe('registerFilesystemHandlers', () => {
listWorktreesMock,
resolveCommitMessageSettingsMock,
generateCommitMessageFromContextMock,
discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemoteMock,
cancelGenerateCommitMessageLocalMock,
getSshFilesystemProviderMock,
getSshGitProviderMock
@@ -960,7 +968,7 @@ describe('registerFilesystemHandlers', () => {
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'claude', model: 'claude-haiku-4-5' }
const params = { agentId: 'claude', model: 'haiku' }
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
@@ -1000,6 +1008,89 @@ describe('registerFilesystemHandlers', () => {
}
})
it('passes per-agent command overrides into local model discovery', async () => {
discoverCommitMessageModelsLocalMock.mockResolvedValue({
success: true,
capability: {
id: 'codex',
label: 'Codex',
modelSource: 'dynamic',
defaultModelId: 'gpt-5.5',
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }]
},
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }],
defaultModelId: 'gpt-5.5'
})
const storeWithOverride = {
...store,
getSettings: () => ({
workspaceDir: WORKSPACE_DIR,
agentCmdOverrides: { codex: 'npx codex' }
})
}
registerFilesystemHandlers(storeWithOverride as never)
await handlers.get('git:discoverCommitMessageModels')!(null, { agentId: 'codex' })
expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
'codex',
undefined,
'npx codex'
)
})
it('routes SSH model discovery through the remote git provider', async () => {
discoverCommitMessageModelsRemoteMock.mockResolvedValue({
success: true,
capability: {
id: 'cursor',
label: 'Cursor',
modelSource: 'dynamic',
defaultModelId: 'auto',
models: [{ id: 'auto', label: 'Auto' }]
},
models: [{ id: 'auto', label: 'Auto' }],
defaultModelId: 'auto'
})
const executeCommitMessagePlan = vi.fn()
getSshGitProviderMock.mockReturnValue({ executeCommitMessagePlan })
const storeWithOverride = {
...store,
getSettings: () => ({
workspaceDir: WORKSPACE_DIR,
agentCmdOverrides: { cursor: 'npx cursor-agent' }
})
}
registerFilesystemHandlers(storeWithOverride as never)
await handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'cursor',
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
expect(discoverCommitMessageModelsRemoteMock).toHaveBeenCalledWith(
'cursor',
'/remote/repo',
expect.any(Function),
'npx cursor-agent'
)
const execute = discoverCommitMessageModelsRemoteMock.mock.calls[0]?.[2] as (
plan: unknown,
cwd: string,
timeoutMs: number
) => Promise<unknown>
await execute({ binary: 'cursor-agent', args: ['--list-models'] }, '/remote/repo', 60_000)
expect(executeCommitMessagePlan).toHaveBeenCalledWith(
{ binary: 'cursor-agent', args: ['--list-models'] },
'/remote/repo',
60_000
)
expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled()
})
it('generates an SSH commit message using remote staged context and relay execution', async () => {
const context = {
branch: 'main',
+48 -3
View File
@@ -17,7 +17,8 @@ import type {
GitStatusResult,
MarkdownDocument,
SearchOptions,
SearchResult
SearchResult,
TuiAgent
} from '../../shared/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import {
@@ -49,9 +50,12 @@ import { getHistory } from '../git/history'
import {
cancelGenerateCommitMessageLocal,
cancelGeneratePullRequestFieldsLocal,
discoverCommitMessageModelsLocal,
discoverCommitMessageModelsRemote,
generateCommitMessageFromContext,
generatePullRequestFieldsFromContext,
resolveCommitMessageSettings,
type DiscoverCommitMessageModelsResult,
type GenerateCommitMessageResult,
type GeneratePullRequestFieldsResult
} from '../text-generation/commit-message-text-generation'
@@ -60,6 +64,7 @@ import { getUpstreamStatus } from '../git/upstream'
import { gitFetch, gitPull, gitPush } from '../git/remote'
import { checkIgnoredPaths } from '../git/check-ignored-paths'
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import { validateGitPushTarget } from '../git/push-target-validation'
import { getRemoteFileUrl } from '../git/repo'
import {
@@ -623,7 +628,8 @@ export function registerFilesystemHandlers(
connectionId?: string
}
): Promise<GenerateCommitMessageResult> => {
const resolvedSettings = resolveCommitMessageSettings(store.getSettings())
const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null)
const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
@@ -701,6 +707,44 @@ export function registerFilesystemHandlers(
}
)
ipcMain.handle(
'git:discoverCommitMessageModels',
async (
_event,
args: { agentId: string; worktreePath?: string; connectionId?: string }
): Promise<DiscoverCommitMessageModelsResult> => {
const agentId = args.agentId
const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent]
if (args.connectionId) {
if (!args.worktreePath) {
return { success: false, error: 'Missing worktree path for remote model discovery.' }
}
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
return {
success: false,
error: `No git provider for connection "${args.connectionId}"`
}
}
return discoverCommitMessageModelsRemote(
agentId as TuiAgent,
args.worktreePath,
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
agentCommandOverride
)
}
const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, commitMessageAgentEnv)
if (!localEnv.ok) {
return { success: false, error: localEnv.error }
}
return discoverCommitMessageModelsLocal(
agentId as TuiAgent,
localEnv.env,
agentCommandOverride
)
}
)
ipcMain.handle(
'git:generatePullRequestFields',
async (
@@ -714,7 +758,8 @@ export function registerFilesystemHandlers(
connectionId?: string
}
): Promise<GeneratePullRequestFieldsResult> => {
const resolvedSettings = resolveCommitMessageSettings(store.getSettings())
const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null)
const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
+50
View File
@@ -28,6 +28,7 @@ import {
listAssignableUsers,
getAuthenticatedViewer,
getPRChecks,
getPRCheckDetails,
getPRComments,
resolveReviewThread,
setPRFileViewed,
@@ -38,6 +39,7 @@ import {
updatePRState,
rerunPRChecks,
requestPRReviewers,
removePRReviewers,
checkOrcaStarred,
starOrca
} from '../github/client'
@@ -301,6 +303,34 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
}
)
ipcMain.handle(
'gh:prCheckDetails',
(
_event,
args: {
repoPath: string
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
return getPRCheckDetails(
repo.path,
{
checkRunId: args.checkRunId,
workflowRunId: args.workflowRunId,
checkName: args.checkName,
url: args.url,
prRepo: args.prRepo ?? null
},
repoConnectionId(repo)
)
}
)
ipcMain.handle(
'gh:prComments',
(
@@ -617,6 +647,26 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
}
)
ipcMain.handle(
'gh:removePRReviewers',
async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => {
const repo = assertRegisteredRepo(args, store)
const result = await removePRReviewers(
repo.path,
args.prNumber,
args.reviewers,
repoConnectionId(repo)
)
if (result.ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber },
event.sender.id
)
}
return result
}
)
ipcMain.handle(
'gh:updateIssue',
async (
+10 -2
View File
@@ -44,7 +44,8 @@ const {
registerOnboardingHandlersMock,
registerSpeechHandlersMock,
registerSkillsHandlersMock,
registerWorkspaceSpaceHandlersMock
registerWorkspaceSpaceHandlersMock,
registerWorkspacePortHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
registerPreflightHandlersMock: vi.fn(),
@@ -87,7 +88,8 @@ const {
registerOnboardingHandlersMock: vi.fn(),
registerSpeechHandlersMock: vi.fn(),
registerSkillsHandlersMock: vi.fn(),
registerWorkspaceSpaceHandlersMock: vi.fn()
registerWorkspaceSpaceHandlersMock: vi.fn(),
registerWorkspacePortHandlersMock: vi.fn()
}))
vi.mock('./onboarding', () => ({
@@ -166,6 +168,10 @@ vi.mock('./workspace-space', () => ({
registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock
}))
vi.mock('./workspace-ports', () => ({
registerWorkspacePortHandlers: registerWorkspacePortHandlersMock
}))
vi.mock('./telemetry', () => ({
registerTelemetryHandlers: registerTelemetryHandlersMock
}))
@@ -297,6 +303,7 @@ describe('registerCoreHandlers', () => {
registerSpeechHandlersMock.mockReset()
registerSkillsHandlersMock.mockReset()
registerWorkspaceSpaceHandlersMock.mockReset()
registerWorkspacePortHandlersMock.mockReset()
})
it('passes the store through to handler registrars that need it', () => {
@@ -349,6 +356,7 @@ describe('registerCoreHandlers', () => {
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store)
expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store)
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
+2
View File
@@ -32,6 +32,7 @@ import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerSkillsHandlers } from './skills'
import { registerWorkspaceSpaceHandlers } from './workspace-space'
import { registerWorkspacePortHandlers } from './workspace-ports'
import { registerAutomationHandlers } from './automations'
import { registerTelemetryHandlers } from './telemetry'
import { registerBrowserHandlers } from './browser'
@@ -131,6 +132,7 @@ export function registerCoreHandlers(
registerSessionHandlers(store)
registerUIHandlers(store)
registerWorkspaceSpaceHandlers(store)
registerWorkspacePortHandlers(store)
if (commitMessageAgentEnv) {
registerFilesystemHandlers(store, commitMessageAgentEnv)
} else {
+4
View File
@@ -730,6 +730,10 @@ export function registerSshHandlers(
clearProviderPtyState(ptyId)
deletePtyOwnership(ptyId)
}
// Why: reset's connect() can trip onCredentialRequest, which adds to
// credentialRequestedForTarget. Without this delete, a later doConnect
// that doesn't prompt would still persist lastRequiredPassphrase=true.
credentialRequestedForTarget.delete(targetId)
await connectionManager!.disconnect(targetId)
}
}
+241
View File
@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspacePort, WorkspacePortScanResult } from '../../shared/workspace-ports'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>()
const { handleMock, scanWorkspacePortsMock, processKillMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
scanWorkspacePortsMock: vi.fn(),
processKillMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('../ports/local-workspace-port-scanner', () => ({
scanWorkspacePorts: scanWorkspacePortsMock
}))
import { registerWorkspacePortHandlers } from './workspace-ports'
const EMPTY_SCAN: WorkspacePortScanResult = {
ports: [],
platform: process.platform,
scannedAt: 0
}
function makeStore() {
return {
getRepos: vi.fn(() => [
{
id: 'local-repo',
path: '/workspace/repo',
displayName: 'Local Repo',
badgeColor: '#000',
addedAt: 0
},
{
id: 'remote-repo',
path: '/remote/repo',
displayName: 'Remote Repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-target'
},
{
id: 'other-repo',
path: '/workspace/other',
displayName: 'Other Repo',
badgeColor: '#000',
addedAt: 0
}
]),
getAllWorktreeMeta: vi.fn(() => ({
'local-repo::/workspace/repo': { displayName: 'Primary' },
'remote-repo::/remote/repo': { displayName: 'Remote' },
'other-repo::/workspace/other': { displayName: 'Other' },
'malformed-worktree-id': { displayName: 'Malformed' }
}))
}
}
describe('registerWorkspacePortHandlers', () => {
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
scanWorkspacePortsMock.mockReset()
handleMock.mockImplementation((channel, handler) => {
handlers.set(channel, handler)
})
scanWorkspacePortsMock.mockResolvedValue(EMPTY_SCAN)
processKillMock.mockReset()
vi.spyOn(process, 'kill').mockImplementation(processKillMock)
})
it('derives local worktree probes from the main store instead of renderer input', async () => {
const store = makeStore()
registerWorkspacePortHandlers(store as never)
const handler = handlers.get('workspacePorts:scan')
expect(handler).toBeDefined()
await handler?.(null, {
repoId: 'local-repo',
worktrees: [{ id: 'attacker', path: '/tmp/not-authorized', repoId: 'local-repo' }]
})
expect(scanWorkspacePortsMock).toHaveBeenCalledWith([
{
id: 'local-repo::/workspace/repo',
repoId: 'local-repo',
displayName: 'Primary',
path: '/workspace/repo'
}
])
})
it('deduplicates concurrent scans for the same store-derived probe set', async () => {
const store = makeStore()
let resolveScan: (result: WorkspacePortScanResult) => void = () => {}
scanWorkspacePortsMock.mockReturnValue(
new Promise<WorkspacePortScanResult>((resolve) => {
resolveScan = resolve
})
)
registerWorkspacePortHandlers(store as never)
const handler = handlers.get('workspacePorts:scan')
expect(handler).toBeDefined()
const first = handler?.(null, { repoId: 'local-repo' })
const second = handler?.(null, { repoId: 'local-repo' })
expect(scanWorkspacePortsMock).toHaveBeenCalledTimes(1)
resolveScan(EMPTY_SCAN)
await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_SCAN, EMPTY_SCAN])
})
it('handles malformed renderer scan input as an unfiltered local scan', async () => {
const store = makeStore()
registerWorkspacePortHandlers(store as never)
await handlers.get('workspacePorts:scan')?.(null, undefined)
expect(scanWorkspacePortsMock).toHaveBeenCalledWith([
{
id: 'local-repo::/workspace/repo',
repoId: 'local-repo',
displayName: 'Primary',
path: '/workspace/repo'
},
{
id: 'other-repo::/workspace/other',
repoId: 'other-repo',
displayName: 'Other',
path: '/workspace/other'
}
])
})
it('stops a process only after the current scan proves the pid owns a workspace port', async () => {
const store = makeStore()
const port = workspacePort({ pid: 1234, port: 5173 })
scanWorkspacePortsMock.mockResolvedValue({
...EMPTY_SCAN,
ports: [port]
})
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, {
repoId: 'local-repo',
pid: 1234,
port: 5173
})
expect(result).toEqual({ ok: true })
expect(processKillMock).toHaveBeenCalledWith(1234, 'SIGTERM')
})
it('refuses to stop external ports', async () => {
const store = makeStore()
scanWorkspacePortsMock.mockResolvedValue({
...EMPTY_SCAN,
ports: [
{
id: '127.0.0.1:55182:2222',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 55182,
pid: 2222,
processName: 'node',
protocol: 'unknown',
kind: 'external'
}
]
})
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, {
repoId: 'local-repo',
pid: 2222,
port: 55182
})
expect(result).toEqual({
ok: false,
reason: 'Only workspace-owned local processes can be stopped here.'
})
expect(processKillMock).not.toHaveBeenCalled()
})
it('refuses stale or forged pid requests that do not match the current scan', async () => {
const store = makeStore()
scanWorkspacePortsMock.mockResolvedValue({
...EMPTY_SCAN,
ports: [workspacePort({ pid: 1234, port: 5173 })]
})
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, {
repoId: 'local-repo',
pid: 9999,
port: 5173
})
expect(result).toEqual({ ok: false, reason: 'The port is no longer listening.' })
expect(processKillMock).not.toHaveBeenCalled()
})
it('refuses malformed renderer kill input without throwing', async () => {
const store = makeStore()
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, { pid: '1234', port: 5173 })
expect(result).toEqual({ ok: false, reason: 'Invalid process or port.' })
expect(scanWorkspacePortsMock).not.toHaveBeenCalled()
expect(processKillMock).not.toHaveBeenCalled()
})
})
function workspacePort({ pid, port }: { pid: number; port: number }): WorkspacePort {
return {
id: `127.0.0.1:${port}:${pid}`,
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port,
pid,
processName: 'node',
protocol: 'unknown',
kind: 'workspace',
owner: {
worktreeId: 'local-repo::/workspace/repo',
repoId: 'local-repo',
displayName: 'Primary',
path: '/workspace/repo',
confidence: 'cwd'
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import {
getStoreWorkspacePortProbes,
killWorkspacePort,
scanWorkspacePortProbes
} from '../ports/workspace-port-ownership'
export function registerWorkspacePortHandlers(store: Store): void {
const inFlightScans = new Map<string, Promise<WorkspacePortScanResult>>()
ipcMain.handle(
'workspacePorts:scan',
(_event, rawArgs?: unknown): Promise<WorkspacePortScanResult> => {
const args = parseScanRequest(rawArgs)
const worktrees = getStoreWorkspacePortProbes(store, args?.repoId)
const key = JSON.stringify(
worktrees
.map((worktree) => [worktree.id, worktree.repoId, worktree.displayName, worktree.path])
.sort(([a], [b]) => String(a).localeCompare(String(b)))
)
const existing = inFlightScans.get(key)
if (existing) {
return existing
}
const promise = scanWorkspacePortProbes(worktrees).finally(() => {
if (inFlightScans.get(key) === promise) {
inFlightScans.delete(key)
}
})
inFlightScans.set(key, promise)
return promise
}
)
ipcMain.handle(
'workspacePorts:kill',
async (_event, rawArgs?: unknown): Promise<WorkspacePortKillResult> => {
const args = parseKillRequest(rawArgs)
if (!args) {
return { ok: false, reason: 'Invalid process or port.' }
}
const worktrees = getStoreWorkspacePortProbes(store, args.repoId)
return killWorkspacePort(worktrees, args)
}
)
}
function parseScanRequest(value: unknown): WorkspacePortScanRequest | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const repoId = (value as { repoId?: unknown }).repoId
return typeof repoId === 'string' && repoId.length > 0 ? { repoId } : undefined
}
function parseKillRequest(value: unknown): WorkspacePortKillRequest | null {
if (!value || typeof value !== 'object') {
return null
}
const args = value as { repoId?: unknown; pid?: unknown; port?: unknown }
if (!Number.isSafeInteger(args.pid) || !Number.isSafeInteger(args.port)) {
return null
}
const pid = args.pid as number
const port = args.port as number
return {
...(typeof args.repoId === 'string' && args.repoId.length > 0 ? { repoId: args.repoId } : {}),
pid,
port
}
}
+2 -2
View File
@@ -233,7 +233,7 @@ describe('Store', () => {
const store = await createStore()
const ui = store.getUI()
expect(ui.sidebarWidth).toBe(280)
expect(ui.groupBy).toBe('repo')
expect(ui.groupBy).toBe('workspace-status')
expect(ui.lastActiveRepoId).toBeNull()
expect(ui.dismissedUpdateVersion).toBeNull()
expect(ui.lastUpdateCheckAt).toBeNull()
@@ -1116,7 +1116,7 @@ describe('Store', () => {
store.updateUI({ sidebarWidth: 400 })
const ui = store.getUI()
expect(ui.sidebarWidth).toBe(400)
expect(ui.groupBy).toBe('repo') // default preserved
expect(ui.groupBy).toBe('workspace-status') // default preserved
expect(ui.dismissedUpdateVersion).toBeNull()
})
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import {
attributePortToWorkspace,
isContainerProcess,
parseLsofListeningOutput,
parseNetstatListeningOutput,
parseProcNetTcp
} from './local-workspace-port-scanner'
const worktrees = [
{
id: 'repo::/repo',
repoId: 'repo',
displayName: 'main',
path: '/repo'
},
{
id: 'repo::/repo/worktrees/feature',
repoId: 'repo',
displayName: 'feature',
path: '/repo/worktrees/feature'
}
]
describe('local workspace port scanner parsing', () => {
it('parses lsof field output into listening ports', () => {
const ports = parseLsofListeningOutput(
['p123', 'cnode', 'n127.0.0.1:5173', 'p456', 'cnginx', 'n*:8080'].join('\n')
)
expect(ports).toEqual([
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 5173 },
{ pid: 456, processName: 'nginx', host: '*', port: 8080 }
])
})
it('parses multiple lsof listening ports for the same process', () => {
const ports = parseLsofListeningOutput(
['p123', 'cnode', 'n127.0.0.1:5173', 'n127.0.0.1:55173'].join('\n')
)
expect(ports).toEqual([
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 5173 },
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 55173 }
])
})
it('parses Windows netstat listening rows', () => {
const ports = parseNetstatListeningOutput(
[
'Proto Local Address Foreign Address State PID',
'TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 4242',
'TCP [::]:5173 [::]:0 LISTENING 5151'
].join('\n')
)
expect(ports).toEqual([
{ host: '127.0.0.1', port: 3000, pid: 4242 },
{ host: '::', port: 5173, pid: 5151 }
])
})
it('parses Linux proc tcp listeners', () => {
const ports = parseProcNetTcp(
[
' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode',
' 0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0'
].join('\n')
)
expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }])
})
})
describe('attributePortToWorkspace', () => {
it('uses cwd ancestry and picks the deepest matching worktree', () => {
const owner = attributePortToWorkspace(
{ cwd: '/repo/worktrees/feature/packages/app', commandLine: 'node server.js' },
worktrees
)
expect(owner).toMatchObject({
worktreeId: 'repo::/repo/worktrees/feature',
displayName: 'feature',
confidence: 'cwd'
})
})
it('falls back to command-line path evidence', () => {
const owner = attributePortToWorkspace(
{ commandLine: 'node /repo/worktrees/feature/node_modules/vite/bin/vite.js' },
worktrees
)
expect(owner).toMatchObject({
worktreeId: 'repo::/repo/worktrees/feature',
confidence: 'command'
})
})
it('requires command-line path boundary evidence', () => {
const owner = attributePortToWorkspace(
{ commandLine: 'node /repo/worktrees/feature-other/server.js' },
[worktrees[1]]
)
expect(owner).toBeUndefined()
})
it('keeps path case significant on case-sensitive platforms', () => {
const owner = attributePortToWorkspace({ cwd: '/Repo/worktrees/feature' }, worktrees)
if (process.platform === 'win32') {
expect(owner).toMatchObject({ worktreeId: 'repo::/repo/worktrees/feature' })
} else {
expect(owner).toBeUndefined()
}
})
it('does not guess when there is no worktree evidence', () => {
const owner = attributePortToWorkspace({ cwd: '/Applications/ContainerRuntime.app' }, worktrees)
expect(owner).toBeUndefined()
})
})
describe('container process classification', () => {
it('detects common container listener owners without workspace attribution', () => {
expect(isContainerProcess({ processName: 'com.container.backend' })).toBe(true)
expect(isContainerProcess({ processName: 'com.vendor.backend' })).toBe(true)
expect(isContainerProcess({ commandLine: '/usr/bin/container-runtime port-forward' })).toBe(
true
)
expect(isContainerProcess({ processName: 'node', commandLine: 'node server.js' })).toBe(false)
})
})
@@ -0,0 +1,513 @@
/* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing,
attribution, and normalization rules that must stay in lockstep. */
import { execFile } from 'child_process'
import { promisify } from 'util'
import { readFile, readdir, readlink } from 'fs/promises'
import path from 'path'
import type {
WorkspacePort,
WorkspacePortOwner,
WorkspacePortProbe,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
const execFileAsync = promisify(execFile)
const COMMAND_TIMEOUT_MS = 4_000
const MAX_PORTS = 200
const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888])
const HTTPS_PORTS = new Set([443, 8443])
type RawListeningPort = {
host: string
port: number
pid?: number
processName?: string
commandLine?: string
cwd?: string
}
type ProcessMetadata = {
processName?: string
commandLine?: string
cwd?: string
}
export async function scanWorkspacePorts(
worktrees: WorkspacePortProbe[]
): Promise<WorkspacePortScanResult> {
try {
const rawPorts = await scanPlatformListeningPorts()
const ports = rawPorts
.map((port) => enrichPort(port, worktrees))
.sort(compareWorkspacePorts)
.slice(0, MAX_PORTS)
return { platform: process.platform, scannedAt: Date.now(), ports }
} catch (error) {
console.warn('[workspace-ports] scan failed', error)
return {
platform: process.platform,
scannedAt: Date.now(),
ports: [],
unavailableReason: `Port scanning is unavailable on ${process.platform}.`
}
}
}
export function attributePortToWorkspace(
port: Pick<RawListeningPort, 'cwd' | 'commandLine'>,
worktrees: WorkspacePortProbe[]
): WorkspacePortOwner | undefined {
const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null
const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null
const cwdMatches = cwd
? worktrees
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
.filter(({ normalizedPath }) => isSameOrDescendant(cwd, normalizedPath))
: []
const cwdMatch = pickDeepestMatch(cwdMatches)
if (cwdMatch) {
return toOwner(cwdMatch.worktree, 'cwd')
}
if (!commandLine) {
return undefined
}
const commandMatches = worktrees
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
.filter(({ normalizedPath }) => includesPathBoundary(commandLine, normalizedPath))
const commandMatch = pickDeepestMatch(commandMatches)
return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined
}
export function parseLsofListeningOutput(output: string): RawListeningPort[] {
const ports: RawListeningPort[] = []
let currentPid: number | undefined
let currentProcessName: string | undefined
for (const line of output.split('\n')) {
if (!line) {
continue
}
const tag = line[0]
const value = line.slice(1)
if (tag === 'p') {
const pid = Number.parseInt(value, 10)
currentPid = Number.isFinite(pid) ? pid : undefined
currentProcessName = undefined
} else if (tag === 'c') {
currentProcessName = value
} else if (tag === 'n') {
const parsed = parseAddressWithPort(value)
if (parsed) {
ports.push({ pid: currentPid, processName: currentProcessName, ...parsed })
}
}
}
return dedupeRawPorts(ports)
}
export function parseNetstatListeningOutput(output: string): RawListeningPort[] {
const ports: RawListeningPort[] = []
for (const line of output.split('\n')) {
const trimmed = line.trim()
if (!trimmed.toUpperCase().startsWith('TCP')) {
continue
}
const fields = trimmed.split(/\s+/)
const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING')
if (stateIndex < 2) {
continue
}
const parsed = parseAddressWithPort(fields[1])
const pid = Number.parseInt(fields[stateIndex + 1] ?? '', 10)
if (!parsed) {
continue
}
ports.push({ ...parsed, pid: Number.isFinite(pid) ? pid : undefined })
}
return dedupeRawPorts(ports)
}
export function parseProcNetTcp(content: string): { host: string; port: number; inode: number }[] {
const results: { host: string; port: number; inode: number }[] = []
const lines = content.split('\n')
for (let i = 1; i < lines.length; i++) {
const fields = lines[i].trim().split(/\s+/)
if (fields.length < 10 || fields[3] !== '0A') {
continue
}
const parsed = parseProcAddress(fields[1])
const inode = Number.parseInt(fields[9], 10)
if (!parsed || !Number.isFinite(inode) || inode === 0) {
continue
}
results.push({ ...parsed, inode })
}
return results
}
async function scanPlatformListeningPorts(): Promise<RawListeningPort[]> {
if (process.platform === 'linux') {
return scanLinuxProcPorts()
}
if (process.platform === 'darwin') {
return scanDarwinLsofPorts()
}
if (process.platform === 'win32') {
return scanWindowsNetstatPorts()
}
throw new Error(`Port scanning is not supported on ${process.platform}`)
}
async function scanDarwinLsofPorts(): Promise<RawListeningPort[]> {
const { stdout } = await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'])
const ports = parseLsofListeningOutput(stdout)
const metadata = await loadDarwinProcessMetadata(
new Set(ports.flatMap((p) => (p.pid ? [p.pid] : [])))
)
return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port }))
}
async function scanWindowsNetstatPorts(): Promise<RawListeningPort[]> {
const { stdout } = await runCommand('netstat', ['-ano', '-p', 'tcp'])
const ports = parseNetstatListeningOutput(stdout)
const metadata = await loadWindowsProcessMetadata(
new Set(ports.flatMap((p) => (p.pid ? [p.pid] : [])))
)
return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port }))
}
async function scanLinuxProcPorts(): Promise<RawListeningPort[]> {
const [tcp4, tcp6] = await Promise.all([
readProcNet('/proc/net/tcp'),
readProcNet('/proc/net/tcp6')
])
const sockets = [...tcp4, ...tcp6]
const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode)))
const metadata = new Map<number, ProcessMetadata>()
const rawPorts: RawListeningPort[] = []
for (const socket of sockets) {
const pid = inodeToPid.get(socket.inode)
if (pid != null && !metadata.has(pid)) {
metadata.set(pid, await loadLinuxProcessMetadata(pid))
}
rawPorts.push({
host: socket.host,
port: socket.port,
pid,
...metadata.get(pid ?? -1)
})
}
return dedupeRawPorts(rawPorts)
}
async function readProcNet(
filePath: string
): Promise<{ host: string; port: number; inode: number }[]> {
try {
return parseProcNetTcp(await readFile(filePath, 'utf-8'))
} catch {
return []
}
}
async function mapLinuxInodesToPids(inodes: Set<number>): Promise<Map<number, number>> {
const result = new Map<number, number>()
let pids: string[]
try {
pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry))
} catch {
return result
}
for (const pidText of pids) {
let fds: string[]
try {
fds = await readdir(`/proc/${pidText}/fd`)
} catch {
continue
}
const pid = Number.parseInt(pidText, 10)
for (const fd of fds) {
let link: string
try {
link = await readlink(`/proc/${pidText}/fd/${fd}`)
} catch {
continue
}
const match = link.match(/^socket:\[(\d+)\]$/)
if (!match) {
continue
}
const inode = Number.parseInt(match[1], 10)
if (inodes.has(inode)) {
result.set(inode, pid)
}
}
}
return result
}
async function loadLinuxProcessMetadata(pid: number): Promise<ProcessMetadata> {
const [comm, cmdline, cwd] = await Promise.all([
readTextIfAvailable(`/proc/${pid}/comm`),
readTextIfAvailable(`/proc/${pid}/cmdline`),
readlink(`/proc/${pid}/cwd`).catch(() => undefined)
])
return {
processName: comm?.trim() || undefined,
commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined,
cwd
}
}
async function loadDarwinProcessMetadata(pids: Set<number>): Promise<Map<number, ProcessMetadata>> {
const result = new Map<number, ProcessMetadata>()
const pidList = Array.from(pids).join(',')
if (!pidList) {
return result
}
const [cwdOutput, commandOutput] = await Promise.all([
runCommand('lsof', ['-a', '-p', pidList, '-d', 'cwd', '-Fn']).catch(() => null),
runCommand('ps', ['-p', pidList, '-o', 'pid=', '-o', 'command=']).catch(() => null)
])
let currentPid: number | null = null
for (const line of cwdOutput?.stdout.split('\n') ?? []) {
if (line.startsWith('p')) {
const pid = Number.parseInt(line.slice(1), 10)
currentPid = Number.isFinite(pid) ? pid : null
} else if (line.startsWith('n') && currentPid != null) {
result.set(currentPid, { ...result.get(currentPid), cwd: line.slice(1) || undefined })
}
}
for (const line of commandOutput?.stdout.split('\n') ?? []) {
const match = line.match(/^\s*(\d+)\s+(.+)$/)
if (!match) {
continue
}
const pid = Number.parseInt(match[1], 10)
result.set(pid, { ...result.get(pid), commandLine: match[2].trim() || undefined })
}
return result
}
async function loadWindowsProcessMetadata(
pids: Set<number>
): Promise<Map<number, ProcessMetadata>> {
const result = new Map<number, ProcessMetadata>()
if (pids.size === 0) {
return result
}
try {
const pidFilter = Array.from(pids)
.filter(Number.isFinite)
.map((pid) => `ProcessId=${pid}`)
.join(' OR ')
const { stdout } = await runCommand('powershell.exe', [
'-NoProfile',
'-Command',
`Get-CimInstance Win32_Process -Filter "${pidFilter}" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`
])
const parsed = JSON.parse(stdout) as
| { ProcessId: number; Name?: string; CommandLine?: string }
| { ProcessId: number; Name?: string; CommandLine?: string }[]
for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
if (pids.has(row.ProcessId)) {
result.set(row.ProcessId, {
processName: row.Name,
commandLine: row.CommandLine
})
}
}
} catch {
// Process metadata is optional; port rows still render without attribution.
}
return result
}
async function runCommand(command: string, args: string[]): Promise<{ stdout: string }> {
const { stdout } = await execFileAsync(command, args, {
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: 2 * 1024 * 1024,
windowsHide: true
})
return { stdout: String(stdout) }
}
async function readTextIfAvailable(filePath: string): Promise<string | undefined> {
try {
return await readFile(filePath, 'utf-8')
} catch {
return undefined
}
}
function enrichPort(port: RawListeningPort, worktrees: WorkspacePortProbe[]): WorkspacePort {
const owner = attributePortToWorkspace(port, worktrees)
const base = {
id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`,
bindHost: port.host,
connectHost: connectHostForBindHost(port.host),
port: port.port,
pid: port.pid,
processName: port.processName,
protocol: inferProtocol(port.port)
}
if (owner) {
return { ...base, kind: 'workspace', owner }
}
if (isContainerProcess(port)) {
return { ...base, kind: 'container' }
}
return { ...base, kind: 'external' }
}
function compareWorkspacePorts(a: WorkspacePort, b: WorkspacePort): number {
const aRank = a.kind === 'workspace' ? 0 : a.kind === 'container' ? 1 : 2
const bRank = b.kind === 'workspace' ? 0 : b.kind === 'container' ? 1 : 2
return aRank - bRank || a.port - b.port || a.connectHost.localeCompare(b.connectHost)
}
function inferProtocol(port: number): 'http' | 'https' | 'unknown' {
if (HTTPS_PORTS.has(port)) {
return 'https'
}
if (HTTP_PORTS.has(port)) {
return 'http'
}
return 'unknown'
}
export function isContainerProcess(
port: Pick<RawListeningPort, 'processName' | 'commandLine'>
): boolean {
const haystack = `${port.processName ?? ''} ${port.commandLine ?? ''}`.toLowerCase()
return /\b(com\.[\w.-]+\.backend|com\.container\w*|container\w*)\b/.test(haystack)
}
function toOwner(
worktree: WorkspacePortProbe,
confidence: WorkspacePortOwner['confidence']
): WorkspacePortOwner {
return {
worktreeId: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.path,
confidence
}
}
function pickDeepestMatch<T extends { normalizedPath: string }>(matches: T[]): T | undefined {
return matches.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)[0]
}
function isSameOrDescendant(candidate: string, parent: string): boolean {
return candidate === parent || candidate.startsWith(`${parent}/`)
}
function includesPathBoundary(commandLine: string, normalizedPath: string): boolean {
let index = commandLine.indexOf(normalizedPath)
while (index !== -1) {
const before = index === 0 ? '' : commandLine[index - 1]
const after = commandLine[index + normalizedPath.length] ?? ''
const startsOnBoundary = before === '' || /\s|["'=]/.test(before)
const endsOnBoundary = after === '' || /[\s"'/:]/.test(after)
if (startsOnBoundary && endsOnBoundary) {
return true
}
index = commandLine.indexOf(normalizedPath, index + normalizedPath.length)
}
return false
}
function normalizeComparablePath(input: string): string {
return normalizeComparableText(path.resolve(input))
}
function normalizeComparableText(input: string): string {
const normalized = input.replace(/\\/g, '/').replace(/\/+/g, '/')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function connectHostForBindHost(host: string): string {
if (host === '*' || host === '0.0.0.0' || host === '::') {
return 'localhost'
}
return host
}
function dedupeRawPorts(ports: RawListeningPort[]): RawListeningPort[] {
const seen = new Set<string>()
const result: RawListeningPort[] = []
for (const port of ports) {
const key = `${connectHostForBindHost(port.host)}:${port.port}:${port.pid ?? 'unknown'}`
if (seen.has(key)) {
continue
}
seen.add(key)
result.push(port)
}
return result
}
function parseAddressWithPort(value: string): { host: string; port: number } | null {
const trimmed = value.trim().replace(/\s+\(LISTEN\)$/i, '')
const bracketed = trimmed.match(/^\[([^\]]+)\]:(\d+)$/)
if (bracketed) {
return { host: bracketed[1], port: Number.parseInt(bracketed[2], 10) }
}
const match = trimmed.match(/^(.+):(\d+)$/)
if (!match) {
return null
}
const port = Number.parseInt(match[2], 10)
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
return null
}
return { host: match[1], port }
}
function parseProcAddress(hexAddress: string): { host: string; port: number } | null {
const [addrHex, portHex] = hexAddress.split(':')
const port = Number.parseInt(portHex, 16)
if (!Number.isFinite(port) || port === 0) {
return null
}
if (addrHex.length === 8) {
const bytes = [6, 4, 2, 0].map((index) => Number.parseInt(addrHex.slice(index, index + 2), 16))
return { host: bytes.join('.'), port }
}
if (addrHex.length === 32) {
if (addrHex === '00000000000000000000000000000000') {
return { host: '::', port }
}
if (addrHex === '00000000000000000000000001000000') {
return { host: '::1', port }
}
return { host: formatIPv6Address(addrHex), port }
}
return null
}
function formatIPv6Address(hex: string): string {
const groups: string[] = []
for (let i = 0; i < 32; i += 8) {
const chunk = hex.slice(i, i + 8)
const reversed = chunk.slice(6, 8) + chunk.slice(4, 6) + chunk.slice(2, 4) + chunk.slice(0, 2)
groups.push(reversed.slice(0, 4), reversed.slice(4, 8))
}
return groups.map((group) => group.replace(/^0+/, '') || '0').join(':')
}
+102
View File
@@ -0,0 +1,102 @@
import path from 'path'
import type { Store } from '../persistence'
import { splitWorktreeId } from '../../shared/worktree-id'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortProbe,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import { scanWorkspacePorts } from './local-workspace-port-scanner'
export type WorkspacePortProbeInput = WorkspacePortProbe & {
connectionId?: string | null
}
export function getStoreWorkspacePortProbes(
store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>,
repoId?: string
): WorkspacePortProbe[] {
const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo]))
return Object.entries(store.getAllWorktreeMeta()).flatMap(([worktreeId, meta]) => {
const parsed = splitWorktreeId(worktreeId)
if (!parsed || (repoId && parsed.repoId !== repoId)) {
return []
}
const repo = reposById.get(parsed.repoId)
if (!repo || repo.connectionId) {
return []
}
return [
{
id: worktreeId,
repoId: parsed.repoId,
displayName: meta.displayName || path.basename(parsed.worktreePath),
path: parsed.worktreePath
}
]
})
}
export function filterWorkspacePortProbes(
worktrees: readonly WorkspacePortProbeInput[],
repoId?: string
): WorkspacePortProbe[] {
return worktrees.flatMap((worktree) => {
if ((repoId && worktree.repoId !== repoId) || worktree.connectionId) {
return []
}
return [
{
id: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName || path.basename(worktree.path),
path: worktree.path
}
]
})
}
export async function killWorkspacePort(
worktrees: readonly WorkspacePortProbe[],
args: WorkspacePortKillRequest
): Promise<WorkspacePortKillResult> {
if (!Number.isSafeInteger(args.pid) || args.pid <= 0 || !Number.isSafeInteger(args.port)) {
return { ok: false, reason: 'Invalid process or port.' }
}
const scan = await scanWorkspacePorts([...worktrees])
const port = scan.ports.find(
(candidate) => candidate.pid === args.pid && candidate.port === args.port
)
if (!port) {
return { ok: false, reason: 'The port is no longer listening.' }
}
if (port.kind !== 'workspace') {
return { ok: false, reason: 'Only workspace-owned local processes can be stopped here.' }
}
const pid = port.pid
if (!pid) {
return { ok: false, reason: 'The owning process is unknown.' }
}
if (pid === process.pid) {
return { ok: false, reason: 'Orca cannot stop its own process.' }
}
try {
// Why: caller-supplied pids are not trusted; the re-scan above proves
// this pid still owns the requested workspace listener before SIGTERM.
process.kill(pid, 'SIGTERM')
return { ok: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { ok: false, reason: message || 'Failed to stop the process.' }
}
}
export async function scanWorkspacePortProbes(
worktrees: readonly WorkspacePortProbe[]
): Promise<WorkspacePortScanResult> {
return scanWorkspacePorts([...worktrees])
}
+65 -1
View File
@@ -10,7 +10,8 @@ import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runt
const mocks = vi.hoisted(() => ({
getStagedCommitContext: vi.fn(),
generateCommitMessageFromContext: vi.fn(),
resolveCommitMessageSettings: vi.fn()
resolveCommitMessageSettings: vi.fn(),
getSshGitProvider: vi.fn()
}))
vi.mock('../git/status', async () => ({
@@ -26,6 +27,10 @@ vi.mock('../text-generation/commit-message-text-generation', async () => ({
resolveCommitMessageSettings: mocks.resolveCommitMessageSettings
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: mocks.getSshGitProvider
}))
const tempDirs: string[] = []
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
@@ -55,6 +60,7 @@ describe('RuntimeGitCommands', () => {
mocks.getStagedCommitContext.mockReset()
mocks.generateCommitMessageFromContext.mockReset()
mocks.resolveCommitMessageSettings.mockReset()
mocks.getSshGitProvider.mockReset()
})
afterEach(() => {
@@ -109,6 +115,12 @@ describe('RuntimeGitCommands', () => {
message: 'docs: update readme'
})
expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith(
expect.objectContaining({
commitMessageAi: { enabled: true, agentId: 'codex' }
}),
'local'
)
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
context,
params,
@@ -119,4 +131,56 @@ describe('RuntimeGitCommands', () => {
})
)
})
it('resolves remote commit-message settings against the SSH host cache', async () => {
const worktreePath = '/remote/repo'
const context = {
branch: 'main',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'cursor', model: 'remote-model' }
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
mocks.generateCommitMessageFromContext.mockResolvedValue({
success: true,
message: 'docs: update remote readme'
})
const provider = {
getStagedCommitContext: vi.fn().mockResolvedValue(context),
executeCommitMessagePlan: vi.fn()
}
mocks.getSshGitProvider.mockReturnValue(provider)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree(worktreePath),
connectionId: 'conn-1'
}),
getRuntimeSettings: () =>
({
commitMessageAi: {
enabled: true,
agentId: 'cursor',
selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } }
}
}) as unknown as GlobalSettings
})
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
success: true,
message: 'docs: update remote readme'
})
expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith(
expect.any(Object),
'ssh:conn-1'
)
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'remote',
cwd: worktreePath
})
)
})
})
+65 -11
View File
@@ -9,9 +9,11 @@ import type {
GitUpstreamStatus,
GitWorktreeInfo,
GlobalSettings,
TuiAgent,
Worktree
} from '../../shared/types'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import { getRemoteFileUrl } from '../git/repo'
import {
@@ -42,9 +44,12 @@ import { checkIgnoredPaths } from '../git/check-ignored-paths'
import {
cancelGenerateCommitMessageLocal,
cancelGeneratePullRequestFieldsLocal,
discoverCommitMessageModelsLocal,
discoverCommitMessageModelsRemote,
generateCommitMessageFromContext,
generatePullRequestFieldsFromContext,
resolveCommitMessageSettings,
type DiscoverCommitMessageModelsResult,
type GenerateCommitMessageResult,
type GeneratePullRequestFieldsResult
} from '../text-generation/commit-message-text-generation'
@@ -57,7 +62,9 @@ import { gitExecFileAsync } from '../git/runner'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
type RuntimeCommitMessageSettingsOverride = Partial<
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
>
> & {
commitMessageDiscoveryHostKey?: string
}
function normalizeRuntimeGitRelativePath(filePath: string): string {
const relativePath = normalizeRuntimeRelativePath(filePath)
@@ -333,15 +340,21 @@ export class RuntimeGitCommands {
worktreeSelector: string,
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GenerateCommitMessageResult> {
const resolvedSettings = resolveCommitMessageSettings({
...this.host.getRuntimeSettings(),
...settingsOverride
})
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const discoveryHostKey =
settingsOverride?.commitMessageDiscoveryHostKey ??
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
const resolvedSettings = resolveCommitMessageSettings(
{
...this.host.getRuntimeSettings(),
...settingsOverride
},
discoveryHostKey
)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@@ -408,15 +421,21 @@ export class RuntimeGitCommands {
input: { base: string; title: string; body: string; draft: boolean },
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GeneratePullRequestFieldsResult> {
const resolvedSettings = resolveCommitMessageSettings({
...this.host.getRuntimeSettings(),
...settingsOverride
})
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const discoveryHostKey =
settingsOverride?.commitMessageDiscoveryHostKey ??
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
const resolvedSettings = resolveCommitMessageSettings(
{
...this.host.getRuntimeSettings(),
...settingsOverride
},
discoveryHostKey
)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId && !provider) {
return {
@@ -478,6 +497,41 @@ export class RuntimeGitCommands {
return { ok: true }
}
async discoverRuntimeCommitMessageModels(
worktreeSelector: string,
agentId: string,
settingsOverride?: Pick<RuntimeCommitMessageSettingsOverride, 'agentCmdOverrides'>
): Promise<DiscoverCommitMessageModelsResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const typedAgentId = agentId as TuiAgent
const agentCommandOverride =
settingsOverride?.agentCmdOverrides?.[typedAgentId] ??
this.host.getRuntimeSettings().agentCmdOverrides?.[typedAgentId]
if (target.connectionId) {
const provider = getSshGitProvider(target.connectionId)
if (!provider) {
return {
success: false,
error: `No git provider for connection "${target.connectionId}"`
}
}
return discoverCommitMessageModelsRemote(
typedAgentId,
target.worktree.path,
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
agentCommandOverride
)
}
const localEnv = await prepareLocalCommitMessageAgentEnv(
typedAgentId,
this.host.getCommitMessageAgentEnvironment?.()
)
if (!localEnv.ok) {
return { success: false, error: localEnv.error }
}
return discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride)
}
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
+1
View File
@@ -448,6 +448,7 @@ describe('OrcaRuntimeService', () => {
expect(status.runtimeProtocolVersion).toBe(status.protocolVersion)
expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion)
expect(status.capabilities).toContain('terminal.binary-stream.v1')
expect(status.capabilities).toContain('workspace-ports.v1')
expect(typeof status.protocolVersion).toBe('number')
expect(typeof status.minCompatibleMobileVersion).toBe('number')
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)
+68
View File
@@ -58,6 +58,17 @@ import {
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortProbe,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import {
filterWorkspacePortProbes,
killWorkspacePort,
scanWorkspacePortProbes
} from '../ports/workspace-port-ownership'
import type {
RuntimeGraphStatus,
RuntimeRepoSearchRefs,
@@ -113,6 +124,7 @@ import {
listWorkItems,
countWorkItems,
getPRChecks,
getPRCheckDetails,
rerunPRChecks,
getPRComments,
getIssue,
@@ -123,6 +135,7 @@ import {
mergePR,
updatePRState,
requestPRReviewers,
removePRReviewers,
createIssue,
updateIssue,
addIssueComment,
@@ -1694,6 +1707,8 @@ export class OrcaRuntimeService {
)
generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] =
this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands)
discoverRuntimeCommitMessageModels: RuntimeGitCommands['discoverRuntimeCommitMessageModels'] =
this.gitCommands.discoverRuntimeCommitMessageModels.bind(this.gitCommands)
cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] =
this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands)
generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] =
@@ -5182,6 +5197,21 @@ export class OrcaRuntimeService {
return rerunPRChecks(repo.path, prNumber, options)
}
async getRepoPRCheckDetails(
repoSelector: string,
args: {
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
}
): Promise<Awaited<ReturnType<typeof getPRCheckDetails>>> {
const repo = await this.resolveRepoSelector(repoSelector)
this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_check_details')
return getPRCheckDetails(repo.path, { ...args, prRepo: args.prRepo ?? null })
}
async getRepoPRComments(
repoSelector: string,
prNumber: number,
@@ -5274,6 +5304,16 @@ export class OrcaRuntimeService {
return requestPRReviewers(repo.path, prNumber, reviewers)
}
async removeRepoPRReviewers(
repoSelector: string,
prNumber: number,
reviewers: string[]
): Promise<Awaited<ReturnType<typeof removePRReviewers>>> {
const repo = await this.resolveRepoSelector(repoSelector)
this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_reviewers')
return removePRReviewers(repo.path, prNumber, reviewers)
}
async createRepoIssue(
repoSelector: string,
title: string,
@@ -5651,6 +5691,34 @@ export class OrcaRuntimeService {
return await this.resolveWorktreeSelector(worktreeSelector)
}
async scanWorkspacePorts(repoId?: string): Promise<WorkspacePortScanResult> {
return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId))
}
async killWorkspacePort(args: WorkspacePortKillRequest): Promise<WorkspacePortKillResult> {
return killWorkspacePort(await this.getWorkspacePortProbes(args.repoId), args)
}
// Why: remote clients may invoke this over RPC, so the runtime derives
// allowed worktree paths from its own store instead of trusting client paths.
private async getWorkspacePortProbes(repoId?: string): Promise<WorkspacePortProbe[]> {
const reposById = new Map(
this.requireStore()
.getRepos()
.map((repo) => [repo.id, repo])
)
return filterWorkspacePortProbes(
(await this.listResolvedWorktrees()).map((worktree) => ({
id: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.git.path,
connectionId: reposById.get(worktree.repoId)?.connectionId ?? null
})),
repoId
)
}
async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> {
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
// Why: sleep is renderer-initiated on desktop (it tears down tab state
+19 -1
View File
@@ -78,10 +78,22 @@ export const GitCommit = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing commit message'))
})
const CommitMessageModelCapability = z.object({
id: z.string(),
label: z.string(),
thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
defaultThinkingLevel: z.string().optional()
})
const CommitMessageAiSettings = z.object({
enabled: z.boolean(),
agentId: z.string().nullable(),
selectedModelByAgent: z.record(z.string(), z.string()),
selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(),
discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(),
discoveredModelsByAgentByHost: z
.record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability)))
.optional(),
selectedThinkingByModel: z.record(z.string(), z.string()),
customPrompt: z.string(),
customAgentCommand: z.string()
@@ -90,7 +102,13 @@ const CommitMessageAiSettings = z.object({
export const GitGenerateCommitMessage = WorktreeSelector.extend({
commitMessageAi: CommitMessageAiSettings.optional(),
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
enableGitHubAttribution: z.boolean().optional()
enableGitHubAttribution: z.boolean().optional(),
commitMessageDiscoveryHostKey: z.string().optional()
})
export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({
agentId: z.string().min(1, 'Missing agent id'),
agentCmdOverrides: z.record(z.string(), z.string()).optional()
})
export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
+28 -2
View File
@@ -191,6 +191,11 @@ describe('git RPC methods', () => {
generateRuntimeCommitMessage: vi
.fn()
.mockResolvedValue({ success: true, message: 'feat: test' }),
discoverRuntimeCommitMessageModels: vi.fn().mockResolvedValue({
success: true,
models: [{ id: 'auto', label: 'Auto' }],
defaultModelId: 'auto'
}),
cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }),
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
@@ -201,6 +206,13 @@ describe('git RPC methods', () => {
makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' })
)
await dispatcher.dispatch(makeRequest('git.generateCommitMessage', { worktree: 'id:wt-1' }))
await dispatcher.dispatch(
makeRequest('git.discoverCommitMessageModels', {
worktree: 'id:wt-1',
agentId: 'cursor',
agentCmdOverrides: { cursor: 'cursor-agent' }
})
)
await dispatcher.dispatch(
makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' })
)
@@ -221,6 +233,9 @@ describe('git RPC methods', () => {
expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test')
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1')
expect(runtime.discoverRuntimeCommitMessageModels).toHaveBeenCalledWith('id:wt-1', 'cursor', {
agentCmdOverrides: { cursor: 'cursor-agent' }
})
expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1')
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' })
expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' })
@@ -231,6 +246,15 @@ describe('git RPC methods', () => {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' },
selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } },
discoveredModelsByAgent: {
cursor: [{ id: 'local-model', label: 'Local Model' }]
},
discoveredModelsByAgentByHost: {
'ssh:conn-1': {
cursor: [{ id: 'remote-model', label: 'Remote Model' }]
}
},
selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' },
customPrompt: '',
customAgentCommand: ''
@@ -247,14 +271,16 @@ describe('git RPC methods', () => {
worktree: 'id:wt-1',
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true
enableGitHubAttribution: true,
commitMessageDiscoveryHostKey: 'runtime:env-1'
})
)
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', {
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true
enableGitHubAttribution: true,
commitMessageDiscoveryHostKey: 'runtime:env-1'
})
})
+25 -2
View File
@@ -8,6 +8,7 @@ import {
GitCommit,
GitCommitCompare,
GitCommitDiff,
GitDiscoverCommitMessageModels,
GitDiff,
GitFilePath,
GitGenerateCommitMessage,
@@ -127,7 +128,8 @@ export const GIT_METHODS: RpcMethod[] = [
if (
params.commitMessageAi === undefined &&
params.agentCmdOverrides === undefined &&
params.enableGitHubAttribution === undefined
params.enableGitHubAttribution === undefined &&
params.commitMessageDiscoveryHostKey === undefined
) {
return runtime.generateRuntimeCommitMessage(params.worktree)
}
@@ -142,10 +144,27 @@ export const GIT_METHODS: RpcMethod[] = [
: {}),
...(params.enableGitHubAttribution !== undefined
? { enableGitHubAttribution: params.enableGitHubAttribution }
: {}),
...(params.commitMessageDiscoveryHostKey !== undefined
? { commitMessageDiscoveryHostKey: params.commitMessageDiscoveryHostKey }
: {})
})
}
}),
defineMethod({
name: 'git.discoverCommitMessageModels',
params: GitDiscoverCommitMessageModels,
handler: async (params, { runtime }) =>
runtime.discoverRuntimeCommitMessageModels(
params.worktree,
params.agentId,
params.agentCmdOverrides !== undefined
? {
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
}
: {}
)
}),
defineMethod({
name: 'git.cancelGenerateCommitMessage',
params: WorktreeSelector,
@@ -165,7 +184,8 @@ export const GIT_METHODS: RpcMethod[] = [
if (
params.commitMessageAi === undefined &&
params.agentCmdOverrides === undefined &&
params.enableGitHubAttribution === undefined
params.enableGitHubAttribution === undefined &&
params.commitMessageDiscoveryHostKey === undefined
) {
return runtime.generateRuntimePullRequestFields(params.worktree, input)
}
@@ -180,6 +200,9 @@ export const GIT_METHODS: RpcMethod[] = [
: {}),
...(params.enableGitHubAttribution !== undefined
? { enableGitHubAttribution: params.enableGitHubAttribution }
: {}),
...(params.commitMessageDiscoveryHostKey !== undefined
? { commitMessageDiscoveryHostKey: params.commitMessageDiscoveryHostKey }
: {})
})
}
+31
View File
@@ -63,6 +63,14 @@ const PullRequestChecks = PullRequest.extend({
headSha: OptionalString
})
const PullRequestCheckDetails = RepoSelector.extend({
checkRunId: z.number().int().positive().optional(),
workflowRunId: z.number().int().positive().optional(),
checkName: OptionalString,
url: OptionalString.nullable().optional(),
prRepo: SlugRepo.nullable().optional()
})
const RerunPullRequestChecks = PullRequest.extend({
headSha: OptionalString,
failedOnly: z.boolean().optional()
@@ -112,6 +120,11 @@ const RequestPrReviewers = RepoSelector.extend({
reviewers: z.array(z.string()).min(1)
})
const RemovePrReviewers = RepoSelector.extend({
prNumber: z.number().int().positive(),
reviewers: z.array(z.string()).min(1)
})
const CreateIssue = RepoSelector.extend({
title: requiredString('Missing title'),
body: z.string()
@@ -315,6 +328,18 @@ export const GITHUB_METHODS: RpcMethod[] = [
noCache: params.noCache
})
}),
defineMethod({
name: 'github.prCheckDetails',
params: PullRequestCheckDetails,
handler: async (params, { runtime }) =>
runtime.getRepoPRCheckDetails(params.repo, {
checkRunId: params.checkRunId,
workflowRunId: params.workflowRunId,
checkName: params.checkName,
url: params.url,
prRepo: params.prRepo ?? null
})
}),
defineMethod({
name: 'github.rerunPRChecks',
params: RerunPullRequestChecks,
@@ -385,6 +410,12 @@ export const GITHUB_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.requestRepoPRReviewers(params.repo, params.prNumber, params.reviewers)
}),
defineMethod({
name: 'github.removePRReviewers',
params: RemovePrReviewers,
handler: async (params, { runtime }) =>
runtime.removeRepoPRReviewers(params.repo, params.prNumber, params.reviewers)
}),
defineMethod({
name: 'github.createIssue',
params: CreateIssue,
+2
View File
@@ -21,6 +21,7 @@ import { HOSTED_REVIEW_METHODS } from './hosted-review'
import { LINEAR_METHODS } from './linear'
import { SPEECH_METHODS } from './speech'
import { CLIENT_UI_METHODS } from './client-ui'
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@@ -47,5 +48,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...HOSTED_REVIEW_METHODS,
...LINEAR_METHODS,
...SPEECH_METHODS,
...WORKSPACE_PORT_METHODS,
...CLIENT_UI_METHODS
]
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('workspace port RPC methods', () => {
it('scans workspace ports on the runtime host', async () => {
const scan: WorkspacePortScanResult = {
platform: process.platform,
scannedAt: 123,
ports: []
}
const runtime = {
getRuntimeId: () => 'test-runtime',
scanWorkspacePorts: vi.fn().mockResolvedValue(scan)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('workspacePorts.scan', { repoId: 'repo-1' })
)
expect(runtime.scanWorkspacePorts).toHaveBeenCalledWith('repo-1')
expect(response).toMatchObject({ ok: true, result: scan })
})
it('kills a workspace-owned port on the runtime host', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
killWorkspacePort: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('workspacePorts.kill', {
repoId: 'repo-1',
pid: 1234,
port: 5173
})
)
expect(runtime.killWorkspacePort).toHaveBeenCalledWith({
repoId: 'repo-1',
pid: 1234,
port: 5173
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
})
@@ -0,0 +1,31 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalString, requiredNumber } from '../schemas'
const WorkspacePortScanParams = z.object({
repoId: OptionalString
})
const WorkspacePortKillParams = z.object({
repoId: OptionalString,
pid: requiredNumber('Missing process id'),
port: requiredNumber('Missing port')
})
export const WORKSPACE_PORT_METHODS: RpcMethod[] = [
defineMethod({
name: 'workspacePorts.scan',
params: WorkspacePortScanParams,
handler: async (params, { runtime }) => runtime.scanWorkspacePorts(params.repoId)
}),
defineMethod({
name: 'workspacePorts.kill',
params: WorkspacePortKillParams,
handler: async (params, { runtime }) =>
runtime.killWorkspacePort({
repoId: params.repoId,
pid: params.pid,
port: params.port
})
})
]
@@ -17,6 +17,7 @@ import { getBitbucketRepoSlug } from '../bitbucket/client'
import { getGiteaRepoSlug } from '../gitea/client'
import { createGitHubPullRequest, getRepoSlug } from '../github/client'
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
import { gitOptionalLocksDisabledEnv } from '../git/runner'
import { resolveDefaultBaseRefViaExec } from '../git/repo'
import { getUpstreamStatus } from '../git/upstream'
import { getProjectSlug } from '../gitlab/client'
@@ -104,7 +105,12 @@ async function getCurrentBranch(repoPath: string): Promise<string> {
}
async function hasUncommittedChanges(repoPath: string): Promise<boolean> {
const { stdout } = await gitExecFileAsync(['status', '--porcelain'], { cwd: repoPath })
const { stdout } = await gitExecFileAsync(['status', '--porcelain'], {
cwd: repoPath,
// Why: create-PR validation should not take Git's optional index lock while
// the user may be running fetch/pull/rebase from a terminal.
env: gitOptionalLocksDisabledEnv()
})
return stdout.trim().length > 0
}
@@ -0,0 +1,107 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment'
const originalEnv = { ...process.env }
const tempDirs: string[] = []
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key]
}
}
Object.assign(process.env, originalEnv)
while (tempDirs.length > 0) {
rmSync(tempDirs.pop()!, { recursive: true, force: true })
}
})
function makeHome(): string {
const dir = mkdtempSync(join(tmpdir(), 'orca-commit-env-'))
tempDirs.push(dir)
process.env.HOME = dir
process.env.SHELL = '/bin/zsh'
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
return dir
}
describe('prepareLocalCommitMessageAgentEnv', () => {
it('hydrates OpenCode config dir from shell startup files for headless generation', async () => {
const home = makeHome()
delete process.env.OPENCODE_CONFIG_DIR
writeFileSync(join(home, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n')
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined)
expect(result).toEqual({
ok: true,
env: expect.objectContaining({
OPENCODE_CONFIG_DIR: join(home, 'company/opencode')
})
})
})
it('prefers the original OpenCode config root over inherited PTY overlays', async () => {
process.env.OPENCODE_CONFIG_DIR = '/tmp/orca-opencode-overlay'
process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/Users/tester/company/opencode'
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined)
expect(result).toEqual({
ok: true,
env: expect.objectContaining({
OPENCODE_CONFIG_DIR: '/Users/tester/company/opencode'
})
})
})
it('hydrates Pi agent dir from shell startup files for headless generation', async () => {
const home = makeHome()
delete process.env.PI_CODING_AGENT_DIR
writeFileSync(join(home, '.zshrc'), 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n')
const result = await prepareLocalCommitMessageAgentEnv('pi', undefined)
expect(result).toEqual({
ok: true,
env: expect.objectContaining({
PI_CODING_AGENT_DIR: join(home, '.config/pi-agent')
})
})
})
it('prefers the original Pi agent root over inherited PTY overlays', async () => {
process.env.PI_CODING_AGENT_DIR = '/tmp/orca-pi-overlay'
process.env.ORCA_PI_SOURCE_AGENT_DIR = '/Users/tester/.pi/agent'
const result = await prepareLocalCommitMessageAgentEnv('pi', undefined)
expect(result).toEqual({
ok: true,
env: expect.objectContaining({
PI_CODING_AGENT_DIR: '/Users/tester/.pi/agent'
})
})
})
it('does not synthesize env for agents without shell-scoped auth or config roots', async () => {
makeHome()
await expect(prepareLocalCommitMessageAgentEnv('cursor', undefined)).resolves.toEqual({
ok: true
})
})
it('falls back to inherited env when managed account resolvers are unavailable', async () => {
await expect(prepareLocalCommitMessageAgentEnv('codex', undefined)).resolves.toEqual({
ok: true
})
await expect(prepareLocalCommitMessageAgentEnv('claude', undefined)).resolves.toEqual({
ok: true
})
})
})
@@ -1,5 +1,6 @@
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
export type CommitMessageAgentEnvironmentResolvers = {
prepareForCodexLaunch?: () => string | null
@@ -16,10 +17,46 @@ function cloneProcessEnv(): Record<string, string> {
return env
}
function readInheritedOrShellEnvVar(name: string, sourceName?: string): string | undefined {
return (
(sourceName ? process.env[sourceName] : undefined) ??
process.env[name] ??
readShellStartupEnvVar(name, process.env.HOME, process.env.SHELL)
)
}
function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.ProcessEnv } | null {
const configVar =
agentId === 'opencode' ? 'OPENCODE_CONFIG_DIR' : agentId === 'pi' ? 'PI_CODING_AGENT_DIR' : null
if (!configVar) {
return null
}
const sourceVar =
agentId === 'opencode'
? 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
: agentId === 'pi'
? 'ORCA_PI_SOURCE_AGENT_DIR'
: undefined
const value = readInheritedOrShellEnvVar(configVar, sourceVar)
if (!value) {
return { ok: true }
}
// Why: GUI-launched Orca may not inherit shell startup exports, but these
// vars point the headless CLI at the user's auth/config root. Nested Orca
// launches inherit PTY overlays, so prefer ORCA_*_SOURCE_* when present.
return { ok: true, env: { ...cloneProcessEnv(), [configVar]: value } }
}
export async function prepareLocalCommitMessageAgentEnv(
agentId: string,
resolvers: CommitMessageAgentEnvironmentResolvers | undefined
): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> {
const shellConfigEnv = prepareShellConfigDirEnv(agentId)
if (shellConfigEnv) {
return shellConfigEnv
}
if (!resolvers) {
return { ok: true }
}
@@ -8,6 +8,8 @@ import { getDefaultSettings } from '../../shared/constants'
import {
cancelGenerateCommitMessageLocal,
cancelGeneratePullRequestFieldsLocal,
discoverCommitMessageModelsLocal,
discoverCommitMessageModelsRemote,
generateCommitMessageFromContext,
generatePullRequestFieldsFromContext,
resolveCommitMessageSettings,
@@ -39,7 +41,7 @@ beforeEach(() => {
})
describe('resolveCommitMessageSettings', () => {
it('falls back to the agent default model when a persisted model is stale', () => {
it('falls back when a dynamic persisted model was not discovered', () => {
const settings = getDefaultSettings('/tmp')
settings.enableGitHubAttribution = true
settings.commitMessageAi = {
@@ -64,6 +66,29 @@ describe('resolveCommitMessageSettings', () => {
})
})
it('falls back from stale Claude version ids to the CLI alias default', () => {
const settings = getDefaultSettings('/tmp')
settings.commitMessageAi = {
enabled: true,
agentId: 'claude',
selectedModelByAgent: { claude: 'claude-sonnet-4-6' },
selectedThinkingByModel: { sonnet: 'low' },
customPrompt: '',
customAgentCommand: ''
}
const result = resolveCommitMessageSettings(settings)
expect(result).toMatchObject({
ok: true,
params: {
agentId: 'claude',
model: 'sonnet',
thinkingLevel: 'low'
}
})
})
it("uses the user's default agent when the AI setting has no explicit agent", () => {
const settings = getDefaultSettings('/tmp')
settings.defaultTuiAgent = 'codex'
@@ -80,6 +105,66 @@ describe('resolveCommitMessageSettings', () => {
})
})
it('preserves dynamic persisted models that were discovered by the CLI', () => {
const settings = getDefaultSettings('/tmp')
settings.commitMessageAi = {
enabled: true,
agentId: 'cursor',
selectedModelByAgent: { cursor: 'gpt-5.2' },
discoveredModelsByAgent: {
cursor: [
{
id: 'gpt-5.2',
label: 'GPT 5.2',
thinkingLevels: [{ id: 'xhigh', label: 'Extra High' }],
defaultThinkingLevel: 'xhigh'
}
]
},
selectedThinkingByModel: { 'gpt-5.2': 'xhigh' },
customPrompt: '',
customAgentCommand: ''
}
const result = resolveCommitMessageSettings(settings)
expect(result).toMatchObject({
ok: true,
params: {
agentId: 'cursor',
model: 'gpt-5.2',
thinkingLevel: 'xhigh'
}
})
})
it('uses host-scoped discovered models for SSH worktrees', () => {
const settings = getDefaultSettings('/tmp')
settings.commitMessageAi = {
enabled: true,
agentId: 'cursor',
selectedModelByAgent: { cursor: 'auto' },
selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-only' } },
discoveredModelsByAgent: { cursor: [{ id: 'auto', label: 'Auto' }] },
discoveredModelsByAgentByHost: {
'ssh:conn-1': { cursor: [{ id: 'remote-only', label: 'Remote Only' }] }
},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: ''
}
const result = resolveCommitMessageSettings(settings, 'ssh:conn-1')
expect(result).toMatchObject({
ok: true,
params: {
agentId: 'cursor',
model: 'remote-only'
}
})
})
it('falls back to the model default thinking level when a persisted level is stale', () => {
const settings = getDefaultSettings('/tmp')
settings.commitMessageAi = {
@@ -126,6 +211,28 @@ describe('resolveCommitMessageSettings', () => {
})
})
it('falls back when persisted thinking belongs to an undiscovered dynamic model', () => {
const settings = getDefaultSettings('/tmp')
settings.commitMessageAi = {
enabled: true,
agentId: 'cursor',
selectedModelByAgent: { cursor: 'gpt-5.2' },
selectedThinkingByModel: { 'gpt-5.2': 'xhigh' },
customPrompt: '',
customAgentCommand: ''
}
const result = resolveCommitMessageSettings(settings)
expect(result).toMatchObject({
ok: true,
params: {
agentId: 'cursor',
model: 'auto'
}
})
})
it('requires a non-empty custom command for custom agents', () => {
const settings = getDefaultSettings('/tmp')
settings.commitMessageAi = {
@@ -144,7 +251,185 @@ describe('resolveCommitMessageSettings', () => {
})
})
describe('discoverCommitMessageModelsLocal', () => {
it('returns static catalog models without spawning for static agents', async () => {
const result = await discoverCommitMessageModelsLocal('amp', undefined)
expect(result).toMatchObject({
success: true,
defaultModelId: 'smart'
})
expect(spawnMock).not.toHaveBeenCalled()
})
it('discovers dynamic models through the agent CLI', async () => {
const listeners = new Map<string, (value: unknown) => void>()
const child = {
pid: 123,
kill: vi.fn(),
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
stdin: { end: vi.fn() },
on: vi.fn((event, callback) => listeners.set(event, callback))
}
spawnMock.mockReturnValue(child as never)
const pending = discoverCommitMessageModelsLocal('cursor', undefined)
listeners.get('stdout:data')?.(Buffer.from('auto - Auto\ngpt-5.2 - GPT-5.2\n'))
listeners.get('close')?.(0)
await expect(pending).resolves.toMatchObject({
success: true,
defaultModelId: 'auto',
models: [
{ id: 'auto', label: 'Auto' },
{ id: 'gpt-5.2', label: 'GPT-5.2' }
]
})
expect(spawnMock).toHaveBeenCalledWith(
'cursor-agent',
['--list-models'],
expect.objectContaining({ windowsHide: true })
)
})
it('discovers dynamic models through the configured agent command override', async () => {
const listeners = new Map<string, (value: unknown) => void>()
const child = {
pid: 123,
kill: vi.fn(),
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
stdin: { end: vi.fn() },
on: vi.fn((event, callback) => listeners.set(event, callback))
}
spawnMock.mockReturnValue(child as never)
const pending = discoverCommitMessageModelsLocal('cursor', undefined, 'npx cursor-agent')
listeners.get('stdout:data')?.(Buffer.from('auto - Auto\n'))
listeners.get('close')?.(0)
await expect(pending).resolves.toMatchObject({
success: true,
defaultModelId: 'auto'
})
expect(spawnMock).toHaveBeenCalledWith(
'npx',
['cursor-agent', '--list-models'],
expect.objectContaining({ windowsHide: true })
)
})
it('falls back to static models when dynamic discovery returns no parseable models', async () => {
const listeners = new Map<string, (value: unknown) => void>()
const child = {
pid: 123,
kill: vi.fn(),
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
stdin: { end: vi.fn() },
on: vi.fn((event, callback) => listeners.set(event, callback))
}
spawnMock.mockReturnValue(child as never)
const pending = discoverCommitMessageModelsLocal('pi', undefined)
listeners.get('stdout:data')?.(Buffer.from('provider model\n'))
listeners.get('close')?.(0)
await expect(pending).resolves.toMatchObject({
success: true,
defaultModelId: 'github-copilot/gpt-5.4-mini',
models: [{ id: 'github-copilot/gpt-5.4-mini' }]
})
})
it('parses Pi model discovery from stderr when the CLI exits successfully', async () => {
const listeners = new Map<string, (value: unknown) => void>()
const child = {
pid: 123,
kill: vi.fn(),
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
stdin: { end: vi.fn() },
on: vi.fn((event, callback) => listeners.set(event, callback))
}
spawnMock.mockReturnValue(child as never)
const pending = discoverCommitMessageModelsLocal('pi', undefined)
listeners.get('stderr:data')?.(
Buffer.from(
[
'provider model context max-out thinking images',
'github-copilot gpt-5.4-mini 400K 128K yes yes',
'openai-codex gpt-5.5 272K 128K yes yes'
].join('\n')
)
)
listeners.get('close')?.(0)
await expect(pending).resolves.toMatchObject({
success: true,
defaultModelId: 'github-copilot/gpt-5.4-mini',
models: [{ id: 'github-copilot/gpt-5.4-mini' }, { id: 'openai-codex/gpt-5.5' }]
})
})
})
describe('generateCommitMessageFromContext', () => {
it('discovers dynamic models through a remote execution plan', async () => {
const execute = vi.fn(async (plan, cwd, timeoutMs) => {
expect(plan).toEqual({
binary: 'npx',
args: ['cursor-agent', '--list-models'],
stdinPayload: null,
label: 'Cursor'
})
expect(cwd).toBe('/remote/repo')
expect(timeoutMs).toBe(60_000)
return {
stdout: 'auto - Auto\ngpt-5.2 - GPT-5.2\n',
stderr: '',
exitCode: 0,
timedOut: false
}
})
const result = await discoverCommitMessageModelsRemote(
'cursor',
'/remote/repo',
execute,
'npx cursor-agent'
)
expect(result).toMatchObject({
success: true,
defaultModelId: 'auto',
models: [
{ id: 'auto', label: 'Auto' },
{ id: 'gpt-5.2', label: 'GPT-5.2' }
]
})
})
it('reports remote model discovery spawn failures with remote install guidance', async () => {
const result = await discoverCommitMessageModelsRemote('cursor', '/remote/repo', async () => ({
stdout: '',
stderr: '',
exitCode: null,
timedOut: false,
spawnError: 'ENOENT'
}))
expect(result).toEqual({
success: false,
error: 'cursor-agent not found on the remote PATH. Install Cursor there.'
})
})
it('uses a prepared remote execution plan instead of running git on the remote side', async () => {
const result = await generateCommitMessageFromContext(
{
@@ -247,6 +532,37 @@ describe('generateCommitMessageFromContext', () => {
})
})
it('treats empty stdout plus an error on stderr as an agent failure', async () => {
const result = await generateCommitMessageFromContext(
{
branch: 'main',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
},
{
agentId: 'custom',
model: '',
customAgentCommand: 'agent'
},
{
kind: 'remote',
cwd: '/repo',
missingBinaryLocation: 'remote PATH',
execute: async () => ({
stdout: '',
stderr: '\u001b[91m\u001b[1mError: \u001b[0mNo payment method',
exitCode: 0,
timedOut: false
})
}
)
expect(result).toEqual({
success: false,
error: 'agent failed. Check the agent CLI configuration and try again.'
})
})
it('preserves the structured subject and body when formatting the final response', async () => {
const result = await generateCommitMessageFromContext(
{
@@ -24,12 +24,16 @@ import {
getCommitMessageAgentSpec,
getCommitMessageModel,
isCustomAgentId,
resolveCommitMessageAgentChoice
resolveCommitMessageAgentChoice,
type CommitMessageAgentCapability,
type CommitMessageModelCapability
} from '../../shared/commit-message-agent-spec'
import {
planAgentBinary,
planCommitMessageGeneration,
type CommitMessagePlan
} from '../../shared/commit-message-plan'
import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key'
import { resolveCliCommand } from '../codex-cli/command'
import {
getSpawnArgsForWindows,
@@ -53,6 +57,15 @@ export type GenerateCommitMessageResult =
| { success: true; message: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
export type DiscoverCommitMessageModelsResult =
| {
success: true
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
}
| { success: false; error: string }
export type GeneratePullRequestFieldsResult =
| { success: true; fields: GeneratedPullRequestFields; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
@@ -92,7 +105,8 @@ export function trimGeneratedCommitMessage(message: string): string {
}
export function resolveCommitMessageSettings(
settings: GlobalSettings
settings: GlobalSettings,
discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY
): ResolveCommitMessageSettingsResult {
const config = settings.commitMessageAi
if (!config?.enabled) {
@@ -134,9 +148,19 @@ export function resolveCommitMessageSettings(
return { ok: false, error: `Agent "${agentId}" does not support AI commit messages.` }
}
const persistedModelId = config.selectedModelByAgent[agentId] ?? spec.defaultModelId
const hostSelectedModels = config.selectedModelByAgentByHost?.[discoveryHostKey]
const legacySelectedModels =
discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY ? config.selectedModelByAgent : undefined
const persistedModelId =
hostSelectedModels?.[agentId] ?? legacySelectedModels?.[agentId] ?? spec.defaultModelId
const discoveredModels =
config.discoveredModelsByAgentByHost?.[discoveryHostKey]?.[agentId] ??
(discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? (config.discoveredModelsByAgent?.[agentId] ?? [])
: [])
const model =
getCommitMessageModel(agentId, persistedModelId) ??
spec.models.find((candidate) => candidate.id === persistedModelId) ??
discoveredModels.find((candidate) => candidate.id === persistedModelId) ??
getCommitMessageModel(agentId, spec.defaultModelId)
if (!model) {
return { ok: false, error: `No model is available for ${spec.label}.` }
@@ -176,6 +200,251 @@ function userFacingUnsafeWindowsBatchArgs(label: string): string {
return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.`
}
function toModelDiscoveryCapability(
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
models = spec.models,
defaultModelId = spec.defaultModelId
): Extract<DiscoverCommitMessageModelsResult, { success: true }> {
return {
success: true,
capability: {
id: spec.id,
label: spec.label,
modelSource: spec.modelSource,
defaultModelId,
models
},
models,
defaultModelId
}
}
function finalizeModelDiscoveryOutput(
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
stdout: string,
stderr: string,
code: number | null
): DiscoverCommitMessageModelsResult {
if (code !== 0) {
const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
console.error('[commit-message] Model discovery failed:', {
label: spec.label,
exitCode: code,
safeDetail,
stdout,
stderr
})
return {
success: false,
error: `${spec.label} model discovery failed. Check the agent CLI configuration and try again.`
}
}
let models = spec.modelDiscovery?.parse(stdout) ?? []
if (models.length === 0 && stderr.trim()) {
// Why: Pi currently writes its successful `--list-models` table to stderr,
// so exit code 0 must still allow stderr-backed discovery.
models = spec.modelDiscovery?.parse(stderr) ?? []
}
if (models.length === 0) {
if (spec.models.length > 0) {
console.warn('[commit-message] Model discovery returned no models; using static fallback:', {
label: spec.label
})
return toModelDiscoveryCapability(spec, spec.models, spec.defaultModelId)
}
return { success: false, error: `${spec.label} returned no available models.` }
}
const defaultModelId = models.some((model) => model.id === spec.defaultModelId)
? spec.defaultModelId
: models[0].id
return toModelDiscoveryCapability(spec, models, defaultModelId)
}
function planModelDiscovery(
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
agentCommandOverride?: string
): { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } {
const modelDiscovery = spec.modelDiscovery
if (!modelDiscovery) {
return { ok: false, error: `${spec.label} does not support dynamic model discovery.` }
}
const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride)
if (!command.ok) {
return command
}
return {
ok: true,
plan: {
binary: command.binary,
args: [...command.prefixArgs, ...modelDiscovery.args],
stdinPayload: null,
label: spec.label
}
}
}
export async function discoverCommitMessageModelsLocal(
agentId: TuiAgent,
env: NodeJS.ProcessEnv | undefined,
agentCommandOverride?: string
): Promise<DiscoverCommitMessageModelsResult> {
const spec = getCommitMessageAgentSpec(agentId)
if (!spec) {
return { success: false, error: `Agent "${agentId}" does not support AI commit messages.` }
}
if (spec.modelSource === 'static' || !spec.modelDiscovery) {
return toModelDiscoveryCapability(spec)
}
return new Promise((resolve) => {
let child: ChildProcess
const spawnEnv = env ?? process.env
try {
const planned = planModelDiscovery(spec, agentCommandOverride)
if (!planned.ok) {
resolve({ success: false, error: planned.error })
return
}
const resolvedBinary =
process.platform === 'win32'
? resolveCliCommand(planned.plan.binary, {
pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null
})
: planned.plan.binary
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args)
child = spawn(spawnCmd, spawnArgs, {
env: spawnEnv,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
console.error('[commit-message] Failed to spawn model discovery:', error)
resolve({
success: false,
error: `${spec.label} model discovery could not be started. Check the agent CLI configuration and try again.`
})
return
}
let stdout = ''
let stderr = ''
let outputLimitExceeded = false
let settled = false
const finish = (result: DiscoverCommitMessageModelsResult): void => {
if (settled) {
return
}
settled = true
resolve(result)
}
const timer = setTimeout(() => {
killProcessTree(child)
finish({
success: false,
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
})
}, GENERATION_TIMEOUT_MS)
const onData = (chunk: Buffer, append: (text: string) => void): void => {
if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) {
outputLimitExceeded = true
killProcessTree(child)
return
}
append(chunk.toString('utf-8'))
}
child.stdout?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stdout += text)))
child.stderr?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stderr += text)))
child.on('error', (error) => {
clearTimeout(timer)
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
finish({
success: false,
error: `${spec.modelDiscovery?.binary ?? spec.binary} not found on PATH. Install ${spec.label} to discover models.`
})
return
}
finish({
success: false,
error: `${spec.label} model discovery failed to start. Check the agent CLI configuration and try again.`
})
})
child.on('close', (code) => {
clearTimeout(timer)
if (outputLimitExceeded) {
finish({ success: false, error: `${spec.label} returned too much model data.` })
return
}
if (code !== 0) {
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
return
}
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
})
})
}
export async function discoverCommitMessageModelsRemote(
agentId: TuiAgent,
cwd: string,
execute: (
plan: CommitMessagePlan,
cwd: string,
timeoutMs: number
) => Promise<RemoteCommitMessageExecResult>,
agentCommandOverride?: string
): Promise<DiscoverCommitMessageModelsResult> {
const spec = getCommitMessageAgentSpec(agentId)
if (!spec) {
return { success: false, error: `Agent "${agentId}" does not support AI commit messages.` }
}
if (spec.modelSource === 'static' || !spec.modelDiscovery) {
return toModelDiscoveryCapability(spec)
}
const planned = planModelDiscovery(spec, agentCommandOverride)
if (!planned.ok) {
return { success: false, error: planned.error }
}
let result: RemoteCommitMessageExecResult
try {
result = await execute(planned.plan, cwd, GENERATION_TIMEOUT_MS)
} catch (error) {
console.error('[commit-message] Remote model discovery request failed:', error)
return {
success: false,
error: `${spec.label} model discovery could not be reached on the remote PATH. Try again after the SSH connection recovers.`
}
}
if (result.spawnError) {
if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) {
return { success: false, error: userFacingUnsafeWindowsBatchArgs(spec.label) }
}
if (/ENOENT/i.test(result.spawnError)) {
return {
success: false,
error: `${planned.plan.binary} not found on the remote PATH. Install ${spec.label} there.`
}
}
console.error('[commit-message] Remote model discovery spawn failed:', result.spawnError)
return {
success: false,
error: `${spec.label} model discovery could not be started on the remote PATH. Check the agent command there and try again.`
}
}
if (result.canceled) {
return { success: false, error: 'Model discovery canceled.' }
}
if (result.timedOut) {
return {
success: false,
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
}
}
return finalizeModelDiscoveryOutput(spec, result.stdout, result.stderr, result.exitCode)
}
// Why: on Windows, npm-installed CLIs like `claude` and `codex` are usually
// `.cmd` shims. We route those through cmd.exe so Node can launch them, and
// `child.kill()` would only terminate the wrapper. `taskkill /T /F` walks the
@@ -361,6 +630,18 @@ function finalizeFromAgentOutput(args: {
}
const cleaned = cleanGeneratedCommitMessage(stdout)
if (!cleaned) {
const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
if (safeDetail) {
console.error('[commit-message] Generator returned no stdout but reported an error:', {
label,
exitCode: code,
safeDetail,
stdout,
stderr
})
finalize({ success: false, error: userFacingAgentFailure(label) })
return
}
finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` })
return
}
+43
View File
@@ -80,6 +80,7 @@ import type {
PathSource,
PersistedUIState,
PRCheckDetail,
PRCheckRunDetails,
PRComment,
PRInfo,
Repo,
@@ -167,6 +168,10 @@ import type {
RuntimeSyncWindowGraph,
RuntimeTerminalDriverState
} from '../shared/runtime-types'
import type {
CommitMessageAgentCapability,
CommitMessageModelCapability
} from '../shared/commit-message-agent-spec'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult } from '../shared/skills'
import type {
@@ -217,6 +222,12 @@ import type {
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../shared/workspace-space-types'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
SshConnectionState,
@@ -654,6 +665,10 @@ export type PreloadApi = {
cancel: () => Promise<boolean>
onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void
}
workspacePorts: {
scan: (args: WorkspacePortScanRequest) => Promise<WorkspacePortScanResult>
kill: (args: WorkspacePortKillRequest) => Promise<WorkspacePortKillResult>
}
pty: {
spawn: (opts: {
cols: number
@@ -811,6 +826,15 @@ export type PreloadApi = {
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}) => Promise<PRCheckDetail[]>
prCheckDetails: (args: {
repoPath: string
repoId?: string
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
}) => Promise<PRCheckRunDetails | null>
rerunPRChecks: (args: {
repoPath: string
repoId?: string
@@ -865,6 +889,12 @@ export type PreloadApi = {
prNumber: number
reviewers: string[]
}) => Promise<{ ok: true } | { ok: false; error: string }>
removePRReviewers: (args: {
repoPath: string
repoId?: string
prNumber: number
reviewers: string[]
}) => Promise<{ ok: true } | { ok: false; error: string }>
updateIssue: (args: {
repoPath: string
repoId?: string
@@ -1481,6 +1511,19 @@ export type PreloadApi = {
| { success: true; message: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
>
discoverCommitMessageModels: (args: {
agentId: string
worktreePath?: string
connectionId?: string
}) => Promise<
| {
success: true
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
}
| { success: false; error: string }
>
cancelGenerateCommitMessage: (args: {
worktreePath: string
connectionId?: string
+36
View File
@@ -55,6 +55,12 @@ import type {
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../shared/workspace-space-types'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
AddIssueCommentBySlugArgs,
@@ -553,6 +559,13 @@ const api = {
}
},
workspacePorts: {
scan: (args: WorkspacePortScanRequest): Promise<WorkspacePortScanResult> =>
ipcRenderer.invoke('workspacePorts:scan', args),
kill: (args: WorkspacePortKillRequest): Promise<WorkspacePortKillResult> =>
ipcRenderer.invoke('workspacePorts:kill', args)
},
pty: {
spawn: (opts: {
cols: number
@@ -826,6 +839,16 @@ const api = {
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prChecks', args),
prCheckDetails: (args: {
repoPath: string
repoId?: string
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: { owner: string; repo: string } | null
}): Promise<unknown | null> => ipcRenderer.invoke('gh:prCheckDetails', args),
rerunPRChecks: (args: {
repoPath: string
repoId?: string
@@ -892,6 +915,14 @@ const api = {
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gh:requestPRReviewers', args),
removePRReviewers: (args: {
repoPath: string
repoId?: string
prNumber: number
reviewers: string[]
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gh:removePRReviewers', args),
updateIssue: (args: {
repoPath: string
repoId?: string
@@ -2029,6 +2060,11 @@ const api = {
worktreePath: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:generateCommitMessage', args),
discoverCommitMessageModels: (args: {
agentId: string
worktreePath?: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:discoverCommitMessageModels', args),
cancelGenerateCommitMessage: (args: {
worktreePath: string
connectionId?: string
+5 -1
View File
@@ -11,7 +11,11 @@ import { bufferToBlob, buildDiffResult, parseBranchDiff } from './git-handler-ut
// ─── Executor types ──────────────────────────────────────────────────
export type GitExec = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
export type GitExec = (
args: string[],
cwd: string,
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean }
) => Promise<{ stdout: string; stderr: string }>
export type GitBufferExec = (args: string[], cwd: string) => Promise<Buffer>
+5 -1
View File
@@ -94,7 +94,11 @@ export async function getStatusOp(
if (includeIgnored) {
statusArgs.push('--ignored=matching')
}
const { stdout } = await git(statusArgs, worktreePath)
const { stdout } = await git(statusArgs, worktreePath, {
// Why: status polling is read-like; avoid refreshing the index and racing
// terminal Git commands on `.git/worktrees/*/index.lock`.
disableOptionalLocks: true
})
const parsed = parseStatusOutput(stdout)
entries.push(...parsed.entries)
head = parsed.head
+6 -2
View File
@@ -67,11 +67,15 @@ export class GitHandler {
private async git(
args: string[],
cwd: string,
opts?: { maxBuffer?: number }
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean }
): Promise<{ stdout: string; stderr: string }> {
const env = buildRelayCommandEnv()
if (opts?.disableOptionalLocks) {
env.GIT_OPTIONAL_LOCKS = '0'
}
return execFileAsync('git', args, {
cwd: expandTilde(cwd),
env: buildRelayCommandEnv(),
env,
encoding: 'utf-8',
maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER
})
+190 -185
View File
@@ -58,6 +58,7 @@ import {
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { DictationController } from './components/dictation/DictationController'
import { CrashReportDialog } from './components/crash-report/CrashReportDialog'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
@@ -1351,66 +1352,67 @@ function App(): React.JSX.Element {
}
>
<TooltipProvider delayDuration={400}>
{/* Why: leaf-mounted retention sync keeps agent-status retention
<ConfirmationDialogProvider>
{/* Why: leaf-mounted retention sync keeps agent-status retention
subscriptions from re-rendering the App tree. */}
<RetainedAgentsSyncGate />
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: the non-workspace titlebar lives inside this left+center
<RetainedAgentsSyncGate />
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: the non-workspace titlebar lives inside this left+center
wrapper so it does not span over the right-sidebar column —
when the right sidebar is open, its own header anchors at the
top alongside the titlebar instead of being pushed below it. */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: in workspace view (split groups always enabled), the
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: in workspace view (split groups always enabled), the
full-width titlebar is removed so tab groups + terminal extend
to the top of the window. Left titlebar controls move to a
header above the sidebar. Settings, landing, and the tasks
page keep the titlebar. */}
{!workspaceActive ? (
<div className="titlebar">
<div
className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`}
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
>
{titlebarLeftControls}
</div>
{activeView === 'activity' ? (
<ActivityTitlebarControls />
) : (
{!workspaceActive ? (
<div className="titlebar">
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label="Collapse pane"
disabled={!activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Collapse pane
</TooltipContent>
</Tooltip>
)}
{/* Why: when the right sidebar is open, its own header renders
className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`}
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
>
{titlebarLeftControls}
</div>
{activeView === 'activity' ? (
<ActivityTitlebarControls />
) : (
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label="Collapse pane"
disabled={!activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Collapse pane
</TooltipContent>
</Tooltip>
)}
{/* Why: when the right sidebar is open, its own header renders
an identical close button — hide this copy so only one is
visible at a time. */}
{!rightSidebarOpen && rightSidebarToggle}
{/* Why: reserve space so content is not obscured by the
{!rightSidebarOpen && rightSidebarToggle}
{/* Why: reserve space so content is not obscured by the
fixed-position window-controls overlay on Windows. */}
{isWindows && <div className="window-controls-titlebar-spacer" />}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{showSidebar ? (
workspaceActive ? (
/* Why: left column wraps the sidebar with a titlebar-height
{isWindows && <div className="window-controls-titlebar-spacer" />}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{showSidebar ? (
workspaceActive ? (
/* Why: left column wraps the sidebar with a titlebar-height
header above it. The header holds the same controls
(traffic lights, sidebar toggle, "Orca" title, agent badge)
that the full-width titlebar held while the center and right
@@ -1418,173 +1420,176 @@ function App(): React.JSX.Element {
When the sidebar is collapsed, take this header out of flex
layout so the terminal/editor reclaim the left edge instead of
leaving behind a content-width blank strip. */
<div
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
<div
// Why: when the sidebar is collapsed, titlebar-left floats
// absolutely on top of the center column's own `border-l`
// (see TabGroupSplitLayout), occluding that seam. Add a
// `border-r` in the floating state so the vertical line
// between the traffic-light/nav cluster and the tab strip
// stays visible in both states. w-max keeps the floating
// header sized to its own controls instead of the w-0
// sidebar wrapper.
className={`titlebar-left${
sidebarOpen
? ''
: ' absolute top-0 left-0 z-10 w-max border-r border-border'
}`}
style={{
// Why: the Sidebar resize hook updates the sidebar DOM width
// directly during drag and only persists to Zustand on
// mouseup. In workspace view, size this header from the
// wrapper's live width so it tracks those in-flight resizes
// instead of leaving a stale-width gap until the drag ends.
width: sidebarOpen ? '100%' : undefined
}}
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
{titlebarLeftControls}
</div>
<div className="flex min-h-0 flex-1">
{/* Why: the workspace-view wrapper adds a fixed 36px header
<div
// Why: when the sidebar is collapsed, titlebar-left floats
// absolutely on top of the center column's own `border-l`
// (see TabGroupSplitLayout), occluding that seam. Add a
// `border-r` in the floating state so the vertical line
// between the traffic-light/nav cluster and the tab strip
// stays visible in both states. w-max keeps the floating
// header sized to its own controls instead of the w-0
// sidebar wrapper.
className={`titlebar-left${
sidebarOpen
? ''
: ' absolute top-0 left-0 z-10 w-max border-r border-border'
}`}
style={{
// Why: the Sidebar resize hook updates the sidebar DOM width
// directly during drag and only persists to Zustand on
// mouseup. In workspace view, size this header from the
// wrapper's live width so it tracks those in-flight resizes
// instead of leaving a stale-width gap until the drag ends.
width: sidebarOpen ? '100%' : undefined
}}
>
{titlebarLeftControls}
</div>
<div className="flex min-h-0 flex-1">
{/* Why: the workspace-view wrapper adds a fixed 36px header
above the sidebar. Without a flex-1/min-h-0 slot here,
the sidebar falls back to its content height, so the
worktree list loses its scroll viewport and the fixed
bottom toolbar (including Add Project) gets pushed offscreen. */}
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
</div>
</div>
</div>
) : (
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
)
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: right sidebar toggle floats at the top-right of the center
) : (
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
)
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: right sidebar toggle floats at the top-right of the center
column so it's always accessible whether the right sidebar is
open or closed. Match the RightSidebar header's 36px height and
top-0 anchor so the icon's vertical center is identical between
open and closed states — otherwise toggling makes the icon jump
a few pixels, which reads as layout jitter. */}
{workspaceActive && !rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: right: var(--window-controls-width) is the single
// mechanism that keeps the toggle clear of the
// fixed-position window-controls overlay on Windows (138px)
// and sits at the right edge on non-Windows (0px). No
// internal spacer needed — adding one would push the button
// a further 138px to the left and cover the pane-actions
// Ellipsis button with an un-clickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{rightSidebarToggle}
{workspaceActive && !rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: right: var(--window-controls-width) is the single
// mechanism that keeps the toggle clear of the
// fixed-position window-controls overlay on Windows (138px)
// and sits at the right edge on non-Windows (0px). No
// internal spacer needed — adding one would push the button
// a further 138px to the left and cover the pane-actions
// Ellipsis button with an un-clickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{rightSidebarToggle}
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Terminal />
</div>
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Terminal />
</div>
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
// Why: anchor the floating trigger to the center surface so it
// cannot cover the worktree sidebar or right sidebar.
className="absolute bottom-8 right-3"
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>
) : null}
</div>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
// Why: anchor the floating trigger to the center surface so it
// cannot cover the worktree sidebar or right sidebar.
className="absolute bottom-8 right-3"
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>
) : null}
</div>
</div>
</div>
{/* Why: keep RightSidebar mounted even when closed so that its
{/* Why: keep RightSidebar mounted even when closed so that its
child components (FileExplorer, SourceControl, etc.) and their
filesystem watchers + cached directory trees survive across
open/close toggles. Unmount on the tasks view since that
surface is intentionally distraction-free. */}
{showRightSidebarControls ? <RightSidebar /> : null}
</div>
{floatingTerminalEnabled ? (
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
) : null}
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
{showRightSidebarControls ? <RightSidebar /> : null}
</div>
{floatingTerminalEnabled ? (
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
) : null}
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
the shared provider so lazy surfaces mount safely from any entry point. */}
<Suspense fallback={null}>
{mountedLazyModalIds.has('new-workspace-composer') ? <NewWorkspaceComposerModal /> : null}
{mountedLazyModalIds.has('workspace-cleanup') ? <WorkspaceCleanupDialog /> : null}
</Suspense>
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
{mountedLazyModalIds.has('feature-wall') ? <FeatureWallModal /> : null}
{mountedLazyModalIds.has('feature-tips') ? <FeatureTipsModal /> : null}
</Suspense>
{/* Why: mount PetOverlay only when the experimental flag is on AND
<Suspense fallback={null}>
{mountedLazyModalIds.has('new-workspace-composer') ? (
<NewWorkspaceComposerModal />
) : null}
{mountedLazyModalIds.has('workspace-cleanup') ? <WorkspaceCleanupDialog /> : null}
</Suspense>
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
{mountedLazyModalIds.has('feature-wall') ? <FeatureWallModal /> : null}
{mountedLazyModalIds.has('feature-tips') ? <FeatureTipsModal /> : null}
</Suspense>
{/* Why: mount PetOverlay only when the experimental flag is on AND
the user hasn't hit "Hide pet" in the status-bar menu. Both
conditions must be true — see design doc (pet-overlay.md) on why
the two toggles are kept independent. */}
{petEnabled && petVisible ? (
<Suspense fallback={null}>
<PetOverlay />
</Suspense>
) : null}
<UpdateCard />
<FeatureTourNudge />
<StarNagCard />
{/* Why: the existing-user opt-in banner mounts at App root so it
{petEnabled && petVisible ? (
<Suspense fallback={null}>
<PetOverlay />
</Suspense>
) : null}
<UpdateCard />
<FeatureTourNudge />
<StarNagCard />
{/* Why: the existing-user opt-in banner mounts at App root so it
renders once per renderer session, not per view. It gates
internally on the cohort markers populated by the migration,
so it only shows for users who installed before the telemetry
release and have not yet resolved consent. New users get no
first-launch surface — see telemetry-plan.md §First-launch
experience. */}
<TelemetryFirstLaunchSurface />
<ZoomOverlay />
<SshPassphraseDialog />
<DeleteWorktreeDialog />
<CrashReportDialog />
{onboarding && shouldShowOnboarding(onboarding) && !onboardingSettingsDetour ? (
<Suspense fallback={null}>
<OnboardingFlow
onboarding={onboarding}
onOnboardingChange={setOnboarding}
onSettingsDetourStart={beginOnboardingSettingsDetour}
/>
</Suspense>
) : null}
<DictationController />
<RecentTabSwitcher />
<TelemetryFirstLaunchSurface />
<ZoomOverlay />
<SshPassphraseDialog />
<DeleteWorktreeDialog />
<CrashReportDialog />
{onboarding && shouldShowOnboarding(onboarding) && !onboardingSettingsDetour ? (
<Suspense fallback={null}>
<OnboardingFlow
onboarding={onboarding}
onOnboardingChange={setOnboarding}
onSettingsDetourStart={beginOnboardingSettingsDetour}
/>
</Suspense>
) : null}
<DictationController />
<RecentTabSwitcher />
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
File diff suppressed because it is too large Load Diff
+32 -18
View File
@@ -76,6 +76,7 @@ import RepoDotLabel from '@/components/repo/RepoDotLabel'
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector'
import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import {
getGitHubPRPrimaryReviewer,
getGitHubPRReviewLabel,
@@ -89,7 +90,7 @@ import {
import { stripRepoQualifiers } from '../../../shared/task-query'
import { parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import GitHubItemDialog from '@/components/GitHubItemDialog'
import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDialog'
import GitLabItemDialog from '@/components/GitLabItemDialog'
import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper'
import LinearIssueWorkspace from '@/components/LinearIssueWorkspace'
@@ -1395,7 +1396,7 @@ function PRChecksCell({
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Open PR conversation and checks
Open PR checks
</TooltipContent>
</Tooltip>
)
@@ -1411,6 +1412,7 @@ function PRMergeCell({
onRefresh: () => void
}): React.JSX.Element {
const [merging, setMerging] = useState(false)
const confirm = useConfirmationDialog()
if (item.type !== 'pr') {
return <span className="text-[11px] text-muted-foreground">Issue</span>
}
@@ -1425,11 +1427,13 @@ function PRMergeCell({
if (!repo || mergeDisabled) {
return
}
const confirmed = window.confirm(
method === 'squash'
? `Squash and merge PR #${item.number}?`
: `${method === 'rebase' ? 'Rebase and merge' : 'Merge'} PR #${item.number}?`
)
const label =
method === 'squash' ? 'Squash and merge' : method === 'rebase' ? 'Rebase and merge' : 'Merge'
const confirmed = await confirm({
title: `${label} PR #${item.number}?`,
description: 'This will update the pull request on GitHub.',
confirmLabel: label
})
if (!confirmed) {
return
}
@@ -1893,11 +1897,12 @@ export default function TaskPage(): React.JSX.Element {
// this dialog for a read/review surface. The dialog's "Use" button routes
// through the same direct-launch flow as the row-level "Use" CTA so
// behavior is consistent regardless of entry point.
const [dialogWorkItemKey, setDialogWorkItemKey] = useState<{
id: string
repoId: string
} | null>(null)
const [dialogWorkItemFallback, setDialogWorkItemFallback] = useState<GitHubWorkItem | null>(null)
const githubTaskDrawerWorkItem = useAppStore((s) => s.githubTaskDrawerWorkItem)
const setGithubTaskDrawerWorkItem = useAppStore((s) => s.setGithubTaskDrawerWorkItem)
const [dialogInitialTab, setDialogInitialTab] = useState<ItemDialogTab>('conversation')
const dialogWorkItemKey = githubTaskDrawerWorkItem
? { id: githubTaskDrawerWorkItem.id, repoId: githubTaskDrawerWorkItem.repoId }
: null
const appliedWorkItemsCacheQuery = useMemo(
() => stripRepoQualifiers(appliedTaskSearch.trim()),
@@ -1923,12 +1928,17 @@ export default function TaskPage(): React.JSX.Element {
const cachedDialogWorkItem = useAppStore((s) =>
findTaskPageDialogWorkItem(s.workItemsCache, dialogWorkItemKey)
)
const dialogWorkItem = dialogWorkItemKey ? (cachedDialogWorkItem ?? dialogWorkItemFallback) : null
const dialogWorkItem = dialogWorkItemKey
? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem)
: null
const setDialogWorkItem = useCallback((item: GitHubWorkItem | null) => {
setDialogWorkItemKey(item ? { id: item.id, repoId: item.repoId } : null)
setDialogWorkItemFallback(item)
}, [])
const setDialogWorkItem = useCallback(
(item: GitHubWorkItem | null, initialTab: ItemDialogTab = 'conversation') => {
setDialogInitialTab(item ? initialTab : 'conversation')
setGithubTaskDrawerWorkItem(item)
},
[setGithubTaskDrawerWorkItem]
)
const patchTaskPageWorkItemRows = useCallback(
(itemKey: { id: string; repoId: string }, patch: Partial<GitHubWorkItem>): void => {
@@ -4314,7 +4324,10 @@ export default function TaskPage(): React.JSX.Element {
</div>
<div className="flex min-w-0 items-center">
<PRChecksCell item={item} onOpen={() => setDialogWorkItem(item)} />
<PRChecksCell
item={item}
onOpen={() => setDialogWorkItem(item, 'checks')}
/>
</div>
<div className="flex min-w-0 items-center">
@@ -5399,6 +5412,7 @@ export default function TaskPage(): React.JSX.Element {
<GitHubItemDialog
workItem={dialogWorkItem}
initialTab={dialogInitialTab}
repoPath={
// Why: the dialog is for a single item — resolve its repoPath from the
// item's own repoId (set when fan-out merged the list) so it works in
@@ -0,0 +1,21 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import AgentCombobox from './AgentCombobox'
describe('AgentCombobox', () => {
it('keeps enough trigger width for GitHub Copilot when callers pass min-w-0', () => {
const markup = renderToStaticMarkup(
<AgentCombobox
agents={AGENT_CATALOG}
value="copilot"
onValueChange={vi.fn()}
triggerClassName="h-9 w-full min-w-0"
/>
)
expect(markup).toContain('GitHub Copilot')
expect(markup).toContain('!min-w-[260px]')
expect(markup).toContain('flex-1')
})
})
@@ -42,6 +42,7 @@ type AgentComboboxProps = {
}
const BLANK_VALUE = '__none__'
const TRIGGER_MIN_WIDTH_CLASS = '!min-w-[260px]'
type ItemRenderArgs = {
key: string
@@ -239,18 +240,21 @@ export default function AgentCombobox({
aria-expanded={open}
onKeyDown={handleTriggerKeyDown}
className={cn(
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
triggerClassName
// Why: callers sometimes pass `min-w-0` for grid layouts, but
// the compact trigger still needs room for "GitHub Copilot".
'h-8 justify-between px-3 text-xs font-normal',
triggerClassName,
TRIGGER_MIN_WIDTH_CLASS
)}
data-agent-combobox-root="true"
>
{selectedAgent ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<AgentIcon agent={selectedAgent.id} />
<span className="truncate">{selectedAgent.label}</span>
</span>
) : (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<Terminal className="size-3.5" />
<span className="truncate">Blank Terminal</span>
</span>
@@ -0,0 +1,114 @@
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
type ConfirmationDialogOptions = {
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'default' | 'destructive'
}
type ConfirmationDialogRequest = {
id: number
options: ConfirmationDialogOptions
resolve: (confirmed: boolean) => void
}
type ConfirmationDialogContextValue = (options: ConfirmationDialogOptions) => Promise<boolean>
const ConfirmationDialogContext = createContext<ConfirmationDialogContextValue | null>(null)
export function ConfirmationDialogProvider({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
const nextIdRef = useRef(0)
const [queue, setQueue] = useState<ConfirmationDialogRequest[]>([])
const activeRequest = queue[0] ?? null
const [renderedRequest, setRenderedRequest] = useState<ConfirmationDialogRequest | null>(null)
const activeRequestRef = useRef<ConfirmationDialogRequest | null>(activeRequest)
const displayedRequest = activeRequest ?? renderedRequest
useEffect(() => {
activeRequestRef.current = activeRequest
if (activeRequest) {
setRenderedRequest(activeRequest)
}
}, [activeRequest])
const confirm = useCallback<ConfirmationDialogContextValue>((options) => {
return new Promise((resolve) => {
const request: ConfirmationDialogRequest = {
id: nextIdRef.current,
options,
resolve
}
nextIdRef.current += 1
setQueue((currentQueue) => [...currentQueue, request])
})
}, [])
const settleActiveRequest = useCallback((confirmed: boolean) => {
const request = activeRequestRef.current
if (!request) {
return
}
request.resolve(confirmed)
setQueue((currentQueue) => {
if (currentQueue[0]?.id === request.id) {
return currentQueue.slice(1)
}
return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
})
}, [])
return (
<ConfirmationDialogContext.Provider value={confirm}>
{children}
<Dialog
open={activeRequest !== null}
onOpenChange={(open) => !open && settleActiveRequest(false)}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{displayedRequest?.options.title}</DialogTitle>
{displayedRequest?.options.description ? (
<DialogDescription>{displayedRequest.options.description}</DialogDescription>
) : null}
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => settleActiveRequest(false)}>
{displayedRequest?.options.cancelLabel ?? 'Cancel'}
</Button>
<Button
type="button"
variant={displayedRequest?.options.confirmVariant ?? 'default'}
onClick={() => settleActiveRequest(true)}
>
{displayedRequest?.options.confirmLabel ?? 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmationDialogContext.Provider>
)
}
export function useConfirmationDialog(): ConfirmationDialogContextValue {
const confirm = useContext(ConfirmationDialogContext)
if (!confirm) {
throw new Error('useConfirmationDialog must be used inside ConfirmationDialogProvider')
}
return confirm
}
@@ -202,7 +202,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
<div
onClick={handleActivate}
className={cn(
'group relative flex flex-col pr-1.5 py-0.5',
'group relative flex flex-col -ml-2 px-2 py-1',
// Why: hover tints have to go in opposite directions per theme —
// dark mode adds light on dark (bg-accent/30), light mode needs to
// add *dark* on white. Alpha-on-accent in light mode collapses to
@@ -162,7 +162,7 @@ export function useEditorPanelContentState({
const compareAgainstHead = file.mode === 'edit'
const key = inFlightDiffKey(
{ ...file, diffSource: effectiveDiffSource },
gitScope,
gitScope ?? undefined,
compareAgainstHead
)
let pending = inFlightDiffReads.get(key)
@@ -1009,7 +1009,7 @@ export default function ChecksPanel(): React.JSX.Element {
</div>
)}
{/* Merge / Delete Worktree actions */}
{/* Merge / Delete Workspace actions */}
{activeWorktree && repo && (
<PRActions pr={pr} repo={repo} worktree={activeWorktree} onRefreshPR={handleRefreshPR} />
)}
@@ -187,7 +187,7 @@ export default function PRActions({
) : (
<Trash2 className="size-3.5" />
)}
{isDeletingWorktree ? 'Deleting…' : 'Delete Worktree'}
{isDeletingWorktree ? 'Deleting…' : 'Delete Workspace'}
</Button>
)
}
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../../shared/protocol-version'
import type { WorkspacePort, WorkspacePortScanResult } from '../../../../shared/workspace-ports'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
const { activateAndRevealWorktreeMock } = vi.hoisted(() => ({
activateAndRevealWorktreeMock: vi.fn()
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: activateAndRevealWorktreeMock
}))
import {
browserUrlForPort,
killWorkspacePortForTarget,
openWorkspacePortInBrowser,
scanWorkspacePortsForTarget
} from './PortsPanel'
const workspacePort: WorkspacePort = {
id: '127.0.0.1:63468:1234',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 63468,
pid: 1234,
processName: 'node',
protocol: 'unknown',
kind: 'workspace',
owner: {
worktreeId: 'repo::/workspace/app',
repoId: 'repo',
displayName: 'app',
path: '/workspace/app',
confidence: 'cwd'
}
}
const emptyScan: WorkspacePortScanResult = {
platform: process.platform,
scannedAt: 1,
ports: []
}
const compatibleStatus = {
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
}
const localScan = vi.fn()
const localKill = vi.fn()
const runtimeCall = vi.fn()
const runtimeEnvironmentCall = vi.fn()
beforeEach(() => {
localScan.mockReset()
localKill.mockReset()
runtimeCall.mockReset()
runtimeEnvironmentCall.mockReset()
activateAndRevealWorktreeMock.mockReset()
clearRuntimeCompatibilityCacheForTests()
vi.stubGlobal('window', {
api: {
workspacePorts: {
scan: localScan,
kill: localKill
},
runtime: {
call: runtimeCall
},
runtimeEnvironments: {
call: runtimeEnvironmentCall
}
}
})
})
describe('PortsPanel runtime routing', () => {
it('uses local IPC for local workspace port scans and kills', async () => {
localScan.mockResolvedValueOnce(emptyScan)
localKill.mockResolvedValueOnce({ ok: true })
await expect(scanWorkspacePortsForTarget({ kind: 'local' }, 'repo')).resolves.toBe(emptyScan)
await expect(
killWorkspacePortForTarget({ kind: 'local' }, { repoId: 'repo', pid: 1234, port: 63468 })
).resolves.toEqual({ ok: true })
expect(localScan).toHaveBeenCalledWith({ repoId: 'repo' })
expect(localKill).toHaveBeenCalledWith({ repoId: 'repo', pid: 1234, port: 63468 })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('routes remote scans through runtime RPC and degrades on older runtimes', async () => {
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) =>
Promise.resolve(
method === 'status.get'
? { id: method, ok: true, result: compatibleStatus, _meta: { runtimeId: 'runtime-1' } }
: {
id: method,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
)
)
const result = await scanWorkspacePortsForTarget(
{ kind: 'environment', environmentId: 'env-1' },
'repo'
)
expect(result).toMatchObject({
ports: [],
unavailableReason: 'The connected runtime does not support workspace port management yet.'
})
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get',
'workspacePorts.scan'
])
})
it('opens remote workspace ports in the server-side browser and binds the local page handle', async () => {
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) =>
Promise.resolve({
id: method,
ok: true,
result:
method === 'status.get' ? compatibleStatus : { browserPageId: 'remote-browser-page-1' },
_meta: { runtimeId: 'runtime-1' }
})
)
const createBrowserTab = vi.fn(() => ({ activePageId: 'local-page-1' }))
const setRemoteBrowserPageHandle = vi.fn()
await expect(
openWorkspacePortInBrowser({
port: workspacePort,
runtimeTarget: { kind: 'environment', environmentId: 'env-1' },
createBrowserTab: createBrowserTab as never,
setRemoteBrowserPageHandle: setRemoteBrowserPageHandle as never
})
).resolves.toEqual({ ok: true })
expect(activateAndRevealWorktreeMock).toHaveBeenCalledWith('repo::/workspace/app')
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get',
'browser.tabCreate'
])
expect(runtimeEnvironmentCall.mock.calls[1][0].params).toEqual({
worktree: 'id:repo::/workspace/app',
url: 'http://127.0.0.1:63468'
})
expect(createBrowserTab).toHaveBeenCalledWith(
'repo::/workspace/app',
'http://127.0.0.1:63468',
{
activate: true
}
)
expect(setRemoteBrowserPageHandle).toHaveBeenCalledWith('local-page-1', {
environmentId: 'env-1',
remotePageId: 'remote-browser-page-1'
})
})
it('defaults unknown protocols to http for built-in browser opens', () => {
expect(browserUrlForPort(workspacePort)).toBe('http://127.0.0.1:63468')
})
})
@@ -1,10 +1,30 @@
/* oxlint-disable max-lines -- Why: co-locates forwarded list, detected list, modal form, and
per-entry actions in one file to keep the data flow straightforward. */
import React, { useCallback, useMemo, useState } from 'react'
import { ExternalLink, Copy, Trash2, Plus, Unplug, ChevronRight, Pencil } from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ExternalLink,
Copy,
Trash2,
Plus,
Unplug,
ChevronRight,
Pencil,
RefreshCw,
Server,
Box,
Info
} from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
RuntimeRpcCallError,
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
Dialog,
DialogContent,
@@ -13,7 +33,28 @@ import {
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import type { PortForwardEntry, DetectedPort } from '../../../../shared/ssh-types'
import type {
WorkspacePort,
WorkspacePortKillResult,
WorkspacePortScanResult
} from '../../../../shared/workspace-ports'
const LOCAL_PORT_SCAN_INTERVAL_MS = 5_000
const LOCAL_PORT_MENU_CONTENT_CLASS =
'!rounded-md !border-border/60 !bg-popover !text-popover-foreground !shadow-[0_10px_24px_rgba(0,0,0,0.18)] !backdrop-blur-none'
const LOCAL_PORT_MENU_ITEM_CLASS =
'rounded-md focus:bg-accent focus:text-accent-foreground dark:focus:bg-accent'
const LOCAL_PORT_MENU_LABEL_CLASS = 'px-2 py-1 text-[11px] font-semibold text-muted-foreground'
// Why: ports < 1024 require root to bind on the local machine. Remap them
// to a high port so the default "Forward" action doesn't fail with EACCES.
@@ -24,7 +65,6 @@ function safeLocalPort(remotePort: number): number {
return remotePort
}
const HTTP_PORTS = new Set([80, 443, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8443, 8888])
const HTTPS_PORTS = new Set([443, 8443])
// Why: the scanner reports numeric addresses (127.0.0.1, 0.0.0.0, ::1, ::)
@@ -38,6 +78,118 @@ function normalizeHost(host: string | undefined): string {
return host
}
function hostForLocalAction(host: string): string {
if (!host) {
return 'localhost'
}
return host.includes(':') ? `[${host}]` : host
}
function addressForPort(port: WorkspacePort): string {
return `${hostForLocalAction(port.connectHost)}:${port.port}`
}
export function browserUrlForPort(port: WorkspacePort): string {
const protocol = port.protocol === 'https' ? 'https' : 'http'
return `${protocol}://${addressForPort(port)}`
}
type BrowserTabCreator = ReturnType<typeof useAppStore.getState>['createBrowserTab']
type RemoteBrowserPageHandleSetter = ReturnType<
typeof useAppStore.getState
>['setRemoteBrowserPageHandle']
export async function openWorkspacePortInBrowser(args: {
port: WorkspacePort
activeWorktreeId?: string | null
runtimeTarget: RuntimeClientTarget
createBrowserTab: BrowserTabCreator
setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter
}): Promise<{ ok: true } | { ok: false; reason: string }> {
const worktreeId =
args.port.kind === 'workspace' ? args.port.owner.worktreeId : args.activeWorktreeId
if (!worktreeId) {
return { ok: false, reason: 'No workspace selected for the browser.' }
}
const url = browserUrlForPort(args.port)
activateAndRevealWorktree(worktreeId)
if (args.runtimeTarget.kind === 'environment') {
try {
const remotePage = await callRuntimeRpc<{ browserPageId: string }>(
args.runtimeTarget,
'browser.tabCreate',
{ worktree: `id:${worktreeId}`, url },
{ timeoutMs: 30_000 }
)
const tab = args.createBrowserTab(worktreeId, url, { activate: true })
if (!tab.activePageId) {
return { ok: false, reason: 'Failed to create a browser page.' }
}
args.setRemoteBrowserPageHandle(tab.activePageId, {
environmentId: args.runtimeTarget.environmentId,
remotePageId: remotePage.browserPageId
})
return { ok: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { ok: false, reason: message || 'Failed to open remote browser.' }
}
}
args.createBrowserTab(worktreeId, url, { activate: true })
return { ok: true }
}
function runtimeTargetKey(target: RuntimeClientTarget): string {
return target.kind === 'local' ? 'local' : `environment:${target.environmentId}`
}
export async function scanWorkspacePortsForTarget(
target: RuntimeClientTarget,
repoId: string
): Promise<WorkspacePortScanResult> {
const params = { repoId }
if (target.kind === 'local') {
return window.api.workspacePorts.scan(params)
}
try {
return await callRuntimeRpc<WorkspacePortScanResult>(target, 'workspacePorts.scan', params, {
timeoutMs: 15_000
})
} catch (error) {
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
return {
platform: 'unknown',
scannedAt: Date.now(),
ports: [],
unavailableReason: 'The connected runtime does not support workspace port management yet.'
}
}
throw error
}
}
export async function killWorkspacePortForTarget(
target: RuntimeClientTarget,
args: { repoId: string; pid: number; port: number }
): Promise<WorkspacePortKillResult> {
if (target.kind === 'local') {
return window.api.workspacePorts.kill(args)
}
try {
return await callRuntimeRpc<WorkspacePortKillResult>(target, 'workspacePorts.kill', args, {
timeoutMs: 15_000
})
} catch (error) {
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
return {
ok: false,
reason: 'The connected runtime does not support workspace port management yet.'
}
}
throw error
}
}
type PortForwardDialogState =
| { mode: 'closed' }
| {
@@ -46,10 +198,563 @@ type PortForwardDialogState =
}
| { mode: 'edit'; entry: PortForwardEntry }
export default function PortsPanel(): React.JSX.Element {
export default function PortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
if (activeRepo?.connectionId) {
return <SshPortsPanel />
}
return <LocalWorkspacePortsPanel isVisible={isVisible} />
}
function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const settings = useAppStore((s) => s.settings)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
const [scan, setScan] = useState<{ key: string; result: WorkspacePortScanResult } | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [detailsPort, setDetailsPort] = useState<WorkspacePort | null>(null)
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({
other: true,
external: true
})
const inFlightScanRef = useRef<Promise<void> | null>(null)
const inFlightScanKeyRef = useRef<string | null>(null)
const scanGenerationRef = useRef(0)
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
const scanKey = `${runtimeTargetKey(runtimeTarget)}:${activeRepo?.id ?? ''}`
const refresh = useCallback(() => {
if (!activeRepo) {
setScan(null)
return Promise.resolve()
}
if (inFlightScanRef.current && inFlightScanKeyRef.current === scanKey) {
return inFlightScanRef.current
}
const generation = scanGenerationRef.current
setRefreshing(true)
const promise = scanWorkspacePortsForTarget(runtimeTarget, activeRepo.id)
.then((nextScan) => {
if (generation === scanGenerationRef.current) {
setScan({ key: scanKey, result: nextScan })
}
})
.finally(() => {
if (inFlightScanRef.current === promise) {
inFlightScanRef.current = null
inFlightScanKeyRef.current = null
}
if (generation === scanGenerationRef.current) {
setRefreshing(false)
}
})
inFlightScanRef.current = promise
inFlightScanKeyRef.current = scanKey
return promise
}, [activeRepo, runtimeTarget, scanKey])
useEffect(() => {
let cancelled = false
scanGenerationRef.current += 1
if (!isVisible) {
inFlightScanRef.current = null
inFlightScanKeyRef.current = null
setScan(null)
setRefreshing(false)
return () => {
cancelled = true
scanGenerationRef.current += 1
}
}
async function run(): Promise<void> {
try {
await refresh()
} catch {
// Why: a transient RPC failure must not halt the poll loop.
}
if (!cancelled) {
timeout = setTimeout(() => void run(), LOCAL_PORT_SCAN_INTERVAL_MS)
}
}
let timeout: ReturnType<typeof setTimeout> | null = null
setScan(null)
void run()
return () => {
cancelled = true
scanGenerationRef.current += 1
inFlightScanRef.current = null
inFlightScanKeyRef.current = null
if (timeout) {
clearTimeout(timeout)
}
}
}, [isVisible, refresh])
const displayScan = scan?.key === scanKey && isVisible ? scan.result : null
const toggleSection = useCallback((sectionId: string) => {
setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] }))
}, [])
const handleStopPort = useCallback(
async (port: WorkspacePort) => {
if (!activeRepo || !port.pid) {
return
}
const result = await killWorkspacePortForTarget(runtimeTarget, {
repoId: activeRepo.id,
pid: port.pid,
port: port.port
})
if (!result.ok) {
toast.error(result.reason)
return
}
toast.success(`Stopped process on :${port.port}`)
await refresh()
},
[activeRepo, refresh, runtimeTarget]
)
const handleOpenPortInBrowser = useCallback(
async (port: WorkspacePort) => {
const result = await openWorkspacePortInBrowser({
port,
activeWorktreeId: activeWorktree?.id,
runtimeTarget,
createBrowserTab,
setRemoteBrowserPageHandle
})
if (!result.ok) {
toast.error('Failed to open browser', { description: result.reason })
}
},
[activeWorktree?.id, createBrowserTab, runtimeTarget, setRemoteBrowserPageHandle]
)
const activePorts = useMemo(
() =>
(displayScan?.ports ?? []).filter(
(port) => port.kind === 'workspace' && port.owner.worktreeId === activeWorktree?.id
),
[activeWorktree?.id, displayScan?.ports]
)
const otherWorkspacePorts = useMemo(
() =>
(displayScan?.ports ?? []).filter(
(port) => port.kind === 'workspace' && port.owner.worktreeId !== activeWorktree?.id
),
[activeWorktree?.id, displayScan?.ports]
)
const externalPorts = useMemo(
() => (displayScan?.ports ?? []).filter((port) => port.kind !== 'workspace'),
[displayScan?.ports]
)
if (!activeRepo) {
return (
<div className="flex flex-col items-center justify-center h-full px-4 text-center text-muted-foreground">
<Server size={32} className="mb-3 opacity-50" />
<p className="text-sm">No workspace selected</p>
</div>
)
}
return (
<div className="flex flex-col h-full overflow-y-auto scrollbar-sleek">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Ports
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => void refresh()}
disabled={refreshing}
aria-label="Refresh Ports"
>
<RefreshCw size={14} className={cn(refreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Refresh Ports
</TooltipContent>
</Tooltip>
</div>
{displayScan?.unavailableReason && (
<div className="px-3 py-2 text-xs text-muted-foreground border-b border-border">
Port scan unavailable on {displayScan.platform}: {displayScan.unavailableReason}
</div>
)}
{!displayScan?.unavailableReason && (
<>
<LocalPortSection
id="active"
title="Active Workspace"
ports={activePorts}
emptyText={refreshing && !displayScan ? 'Scanning...' : 'No ports detected'}
collapsed={collapsedSections.active ?? false}
onToggle={() => toggleSection('active')}
onStopPort={(port) => void handleStopPort(port)}
onShowDetails={setDetailsPort}
onOpenInBrowser={handleOpenPortInBrowser}
/>
<LocalPortSection
id="other"
title="Other Workspaces"
ports={otherWorkspacePorts}
collapsed={collapsedSections.other ?? false}
onToggle={() => toggleSection('other')}
onStopPort={(port) => void handleStopPort(port)}
onShowDetails={setDetailsPort}
onOpenInBrowser={handleOpenPortInBrowser}
/>
<LocalPortSection
id="external"
title="External"
ports={externalPorts}
collapsed={collapsedSections.external ?? false}
onToggle={() => toggleSection('external')}
onStopPort={(port) => void handleStopPort(port)}
onShowDetails={setDetailsPort}
onOpenInBrowser={handleOpenPortInBrowser}
/>
</>
)}
{!displayScan?.unavailableReason &&
displayScan &&
activePorts.length === 0 &&
otherWorkspacePorts.length === 0 &&
externalPorts.length === 0 && (
<div className="flex flex-col items-center justify-center flex-1 px-4 text-center text-muted-foreground">
<Server size={32} className="mb-3 opacity-50" />
<p className="text-sm">No local ports detected</p>
</div>
)}
<LocalPortDetailsDialog port={detailsPort} onClose={() => setDetailsPort(null)} />
</div>
)
}
function LocalPortSection({
id,
title,
ports,
emptyText,
collapsed,
onToggle,
onStopPort,
onShowDetails,
onOpenInBrowser
}: {
id: string
title: string
ports: WorkspacePort[]
emptyText?: string
collapsed: boolean
onToggle: () => void
onStopPort: (port: WorkspacePort) => void
onShowDetails: (port: WorkspacePort) => void
onOpenInBrowser: (port: WorkspacePort) => void
}): React.JSX.Element | null {
if (ports.length === 0 && !emptyText) {
return null
}
return (
<div className="px-3 pt-2">
<button
type="button"
className="flex items-center gap-1 w-full text-left mb-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={onToggle}
aria-expanded={!collapsed}
aria-controls={`local-port-section-${id}`}
>
<ChevronRight
size={12}
className={cn('shrink-0 transition-transform', !collapsed && 'rotate-90')}
/>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</span>
{ports.length > 0 && (
<span className="text-[10px] text-muted-foreground/60 ml-1">{ports.length}</span>
)}
</button>
{!collapsed && (
<div id={`local-port-section-${id}`}>
{ports.length > 0
? ports.map((port) => (
<LocalPortRow
key={port.id}
port={port}
onStop={onStopPort}
onShowDetails={onShowDetails}
onOpenInBrowser={onOpenInBrowser}
/>
))
: emptyText && <div className="py-1 text-xs text-muted-foreground">{emptyText}</div>}
</div>
)}
</div>
)
}
function LocalPortRow({
port,
onStop,
onShowDetails,
onOpenInBrowser
}: {
port: WorkspacePort
onStop: (port: WorkspacePort) => void
onShowDetails: (port: WorkspacePort) => void
onOpenInBrowser: (port: WorkspacePort) => void
}): React.JSX.Element {
const handleCopy = useCallback(() => {
void window.api.ui.writeClipboardText(addressForPort(port))
}, [port])
const handleOpenBrowser = useCallback(() => {
void onOpenInBrowser(port)
}, [onOpenInBrowser, port])
const handleCopyButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleCopy()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleCopy]
)
const handleOpenBrowserButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleOpenBrowser()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleOpenBrowser]
)
const handleStopButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onStop(port)
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[onStop, port]
)
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
const ownerLabel =
port.kind === 'workspace'
? port.owner.displayName
: port.kind === 'container'
? 'Container or forwarded service'
: 'Unassigned'
const confidenceLabel =
port.kind === 'workspace' ? (port.owner.confidence === 'cwd' ? 'cwd' : 'command') : null
const canStopProcess =
port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron'
return (
<ContextMenu>
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
<ContextMenuTrigger asChild>
<div
className="flex min-w-0 flex-1 items-center gap-2 rounded focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
tabIndex={0}
aria-label={`Port ${port.port} menu`}
>
<div className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{port.kind === 'container' ? <Box size={13} /> : <Server size={13} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-xs font-medium text-foreground">:{port.port}</span>
<span className="truncate text-xs text-muted-foreground">{processLabel}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="truncate">{ownerLabel}</span>
{confidenceLabel && (
<span className="shrink-0 text-muted-foreground/70">{confidenceLabel}</span>
)}
</div>
</div>
</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">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={handleOpenBrowserButtonClick}
aria-label="Open in Orca Browser"
>
<ExternalLink size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Open in Orca Browser
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={handleCopyButtonClick}
aria-label="Copy Address"
>
<Copy size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Copy Address
</TooltipContent>
</Tooltip>
{canStopProcess && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-destructive"
onClick={handleStopButtonClick}
aria-label="Stop Process"
>
<Trash2 size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Stop Process
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
</div>
<ContextMenuContent className={LOCAL_PORT_MENU_CONTENT_CLASS}>
<ContextMenuLabel
className={LOCAL_PORT_MENU_LABEL_CLASS}
>{`:${port.port}`}</ContextMenuLabel>
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleOpenBrowser}>
<ExternalLink size={13} />
Open in Orca Browser
</ContextMenuItem>
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleCopy}>
<Copy size={13} />
Copy Address
</ContextMenuItem>
<ContextMenuItem
className={LOCAL_PORT_MENU_ITEM_CLASS}
onSelect={() => {
void window.api.ui.writeClipboardText(JSON.stringify(port, null, 2))
}}
>
<Copy size={13} />
Copy Details
</ContextMenuItem>
<ContextMenuItem
className={LOCAL_PORT_MENU_ITEM_CLASS}
onSelect={() => onShowDetails(port)}
>
<Info size={13} />
Show Details
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className={LOCAL_PORT_MENU_ITEM_CLASS}
variant="destructive"
disabled={!canStopProcess}
onSelect={() => onStop(port)}
>
<Trash2 size={13} />
Stop Process
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function LocalPortDetailsDialog({
port,
onClose
}: {
port: WorkspacePort | null
onClose: () => void
}): React.JSX.Element {
return (
<Dialog open={Boolean(port)} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{port ? `Port :${port.port}` : 'Port'}</DialogTitle>
<DialogDescription>
{port ? `${port.processName ?? 'Unknown process'} · ${addressForPort(port)}` : ''}
</DialogDescription>
</DialogHeader>
{port && (
<dl className="grid grid-cols-[88px_1fr] gap-x-3 gap-y-2 text-xs">
<dt className="text-muted-foreground">Address</dt>
<dd className="min-w-0 break-all text-foreground">{addressForPort(port)}</dd>
<dt className="text-muted-foreground">Bind</dt>
<dd className="min-w-0 break-all text-foreground">{`${port.bindHost}:${port.port}`}</dd>
<dt className="text-muted-foreground">Kind</dt>
<dd className="text-foreground">{port.kind}</dd>
<dt className="text-muted-foreground">Protocol</dt>
<dd className="text-foreground">{port.protocol}</dd>
<dt className="text-muted-foreground">Process</dt>
<dd className="min-w-0 break-all text-foreground">{port.processName ?? 'Unknown'}</dd>
<dt className="text-muted-foreground">PID</dt>
<dd className="text-foreground">{port.pid ?? 'Unknown'}</dd>
{port.kind === 'workspace' && (
<>
<dt className="text-muted-foreground">Workspace</dt>
<dd className="min-w-0 break-all text-foreground">{port.owner.displayName}</dd>
<dt className="text-muted-foreground">Evidence</dt>
<dd className="text-foreground">{port.owner.confidence}</dd>
</>
)}
</dl>
)}
</DialogContent>
</Dialog>
)
}
function SshPortsPanel(): React.JSX.Element {
const portForwardsByConnection = useAppStore((s) => s.portForwardsByConnection)
const detectedPortsByConnection = useAppStore((s) => s.detectedPortsByConnection)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
// Why: scope the panel to the active worktree's SSH connection so
// actions target the correct machine and the disconnected state
// reflects the active worktree, not some other SSH session.
@@ -107,6 +812,22 @@ export default function PortsPanel(): React.JSX.Element {
setDialogState({ mode: 'edit', entry })
}, [])
const handleOpenForwardInBrowser = useCallback(
(entry: PortForwardEntry) => {
if (!activeWorktree?.id) {
toast.error('No workspace selected for the browser.')
return
}
// Why: the protocol hint comes from the remote port (the actual service),
// not the local port which may be an arbitrary remap.
const protocol = HTTPS_PORTS.has(entry.remotePort) ? 'https' : 'http'
createBrowserTab(activeWorktree.id, `${protocol}://127.0.0.1:${entry.localPort}`, {
activate: true
})
},
[activeWorktree?.id, createBrowserTab]
)
const handleDialogClose = useCallback(() => {
setDialogState({ mode: 'closed' })
}, [])
@@ -162,7 +883,12 @@ export default function PortsPanel(): React.JSX.Element {
</button>
{!forwardedCollapsed &&
allForwards.map((entry) => (
<ForwardedPortRow key={entry.id} entry={entry} onEdit={() => handleEdit(entry)} />
<ForwardedPortRow
key={entry.id}
entry={entry}
onEdit={() => handleEdit(entry)}
onOpenInBrowser={() => handleOpenForwardInBrowser(entry)}
/>
))}
</div>
)}
@@ -231,10 +957,12 @@ export default function PortsPanel(): React.JSX.Element {
function ForwardedPortRow({
entry,
onEdit
onEdit,
onOpenInBrowser
}: {
entry: PortForwardEntry
onEdit: () => void
onOpenInBrowser: () => void
}): React.JSX.Element {
const [removing, setRemoving] = useState(false)
@@ -256,13 +984,48 @@ function ForwardedPortRow({
}, [entry.localPort])
const handleOpenBrowser = useCallback(() => {
// Why: the protocol hint comes from the remote port (the actual service),
// not the local port which may be an arbitrary remap.
const protocol = HTTPS_PORTS.has(entry.remotePort) ? 'https' : 'http'
void window.api.shell.openUrl(`${protocol}://127.0.0.1:${entry.localPort}`)
}, [entry.localPort, entry.remotePort])
onOpenInBrowser()
}, [onOpenInBrowser])
const isHttpPort = HTTP_PORTS.has(entry.remotePort)
const handleCopyButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleCopy()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleCopy]
)
const handleOpenBrowserButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleOpenBrowser()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleOpenBrowser]
)
const handleEditButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onEdit()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[onEdit]
)
const handleRemoveButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
void handleRemove()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleRemove]
)
return (
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
@@ -281,21 +1044,19 @@ function ForwardedPortRow({
</span>
</div>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{isHttpPort && (
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={handleOpenBrowser}
title="Open in Browser"
>
<ExternalLink size={13} />
</button>
)}
<div className="flex items-center gap-0.5 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"
onClick={handleCopy}
onClick={handleOpenBrowserButtonClick}
title="Open in Orca Browser"
>
<ExternalLink size={13} />
</button>
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={handleCopyButtonClick}
title="Copy Address"
>
<Copy size={13} />
@@ -303,7 +1064,7 @@ function ForwardedPortRow({
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={onEdit}
onClick={handleEditButtonClick}
title="Edit"
>
<Pencil size={13} />
@@ -314,7 +1075,7 @@ function ForwardedPortRow({
'p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground',
removing && 'opacity-50'
)}
onClick={handleRemove}
onClick={handleRemoveButtonClick}
disabled={removing}
title="Remove"
>
@@ -67,8 +67,6 @@ type ActivityBarItem = {
shortcut: string
/** When true, hidden for non-git (folder-mode) repos. */
gitOnly?: boolean
/** When true, only shown when at least one SSH connection is active. */
sshOnly?: boolean
}
const isMac = navigator.userAgent.includes('Mac')
@@ -107,8 +105,7 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
title: 'Ports',
// Why: Ctrl+Shift+I is the DevTools accelerator on Windows/Linux, so this
// shortcut is macOS-only. On other platforms the tooltip omits it.
shortcut: isMac ? `\u21E7${mod}I` : '',
sshOnly: true
shortcut: isMac ? `\u21E7${mod}I` : ''
}
]
@@ -128,84 +125,15 @@ function RightSidebarInner(): React.JSX.Element {
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
// Why: show the Ports tab only when the active worktree belongs to a
// remote (SSH) repo, not for any global SSH connection. Switching to a
// local worktree should hide the tab even if SSH sessions are alive.
const isRemoteWorktree = !!activeRepo?.connectionId
const hasActiveSshConnection = useAppStore((s) => {
if (!activeRepo?.connectionId) {
return false
}
const state = s.sshConnectionStates.get(activeRepo.connectionId)
return state?.status === 'connected'
})
// Why: when the SSH connection drops while the user is viewing the Ports
// panel, hiding the tab immediately would be jarring. Keep it visible
// during a 30-second grace period, then hide it.
const isPortsPanelActive = rightSidebarTab === 'ports'
// Why: graceActiveRef is set synchronously during render (not via useEffect)
// so that the very first render after disconnect already sees the grace flag,
// preventing a one-frame flicker to the Explorer tab.
const graceActiveRef = React.useRef(false)
const graceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const [, forceUpdate] = useState(0)
if (!hasActiveSshConnection && isPortsPanelActive && !graceActiveRef.current) {
graceActiveRef.current = true
} else if (graceActiveRef.current && (hasActiveSshConnection || !isPortsPanelActive)) {
// Why: clear grace when either (a) the SSH session reconnects, or (b) the
// user navigates away from the Ports tab — no reason to keep it visible
// once they've moved on.
graceActiveRef.current = false
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
}
const disconnectGraceActive = graceActiveRef.current
useEffect(() => {
if (disconnectGraceActive) {
graceTimerRef.current = setTimeout(() => {
graceActiveRef.current = false
graceTimerRef.current = null
// Why: only reset the tab if the user is still on Ports. If they
// already navigated to Search/Checks/etc during the grace period,
// forcing them back to Explorer would be disruptive.
if (useAppStore.getState().rightSidebarTab === 'ports') {
setRightSidebarTab('explorer')
}
forceUpdate((n) => n + 1)
}, 30_000)
return () => {
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
}
}
return undefined
}, [disconnectGraceActive, setRightSidebarTab])
const visibleItems = useMemo(
() =>
ACTIVITY_ITEMS.filter((item) => {
if (item.gitOnly && isFolder) {
return false
}
if (item.sshOnly) {
if (!isRemoteWorktree) {
return false
}
if (!hasActiveSshConnection && !disconnectGraceActive) {
return false
}
}
return true
}),
[isFolder, isRemoteWorktree, hasActiveSshConnection, disconnectGraceActive]
[isFolder]
)
// If the active tab is hidden (e.g. switched from a git repo to a folder),
@@ -244,7 +172,7 @@ function RightSidebarInner(): React.JSX.Element {
{effectiveTab === 'search' && <SearchPanel />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'checks' && <ChecksPanel />}
{effectiveTab === 'ports' && <PortsPanel />}
{effectiveTab === 'ports' && <PortsPanel isVisible={rightSidebarOpen} />}
</div>
</div>
)
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { dirname } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
@@ -49,6 +50,7 @@ export function useFileDeletion({
isMac,
isWindows
}: UseFileDeletionParams): UseFileDeletionResult {
const confirm = useConfirmationDialog()
// Why: track in-flight deletes per-path so repeated Del presses on the same
// node don't issue duplicate IPC calls; the map is a ref to avoid re-renders.
const inFlightRef = useRef<Set<string>>(new Set())
@@ -82,7 +84,13 @@ export function useFileDeletion({
const message = node.isDirectory
? `Permanently delete '${node.name}' and all its contents? This cannot be undone.`
: `Permanently delete '${node.name}'? This cannot be undone.`
if (!window.confirm(message)) {
const confirmed = await confirm({
title: `Permanently delete '${node.name}'?`,
description: message,
confirmLabel: 'Delete',
confirmVariant: 'destructive'
})
if (!confirmed) {
inFlightRef.current.delete(node.path)
return
}
@@ -193,7 +201,16 @@ export function useFileDeletion({
inFlightRef.current.delete(node.path)
}
},
[activeWorktreeId, closeFile, isWindows, openFiles, refreshDir, selectedPath, setSelectedPath]
[
activeWorktreeId,
closeFile,
confirm,
isWindows,
openFiles,
refreshDir,
selectedPath,
setSelectedPath
]
)
const requestDelete = useCallback(
@@ -1,9 +1,17 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import type { CommitMessageAiSettings, GlobalSettings } from '../../../../shared/types'
import {
getCommitMessageModelDiscoveryHostKey,
getCommitMessageModelDiscoveryHostKeyForScope
} from '../../../../shared/commit-message-host-key'
import { useAppStore } from '../../store'
import { CommitMessageAiPane } from './CommitMessageAiPane'
import {
CommitMessageAiPane,
getCommitMessageSettingsPaneDiscoveryHostKey,
mergeDiscoveredModelsIntoCommitMessageConfig
} from './CommitMessageAiPane'
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
function renderPane(settings: GlobalSettings): string {
@@ -68,6 +76,24 @@ describe('CommitMessageAiPane', () => {
expect(markup).toContain('Saved')
})
it('keeps the agent and model selectors aligned for long labels', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
enabled: true,
agentId: 'copilot',
selectedModelByAgent: { copilot: 'gpt-5.5' },
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: ''
}
})
)
expect(markup.match(/w-\[260px\]/g)).toHaveLength(2)
expect(markup.match(/shrink-0/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
})
it('renders custom command settings for custom agents', () => {
const markup = renderPane(
buildSettings({
@@ -90,7 +116,7 @@ describe('CommitMessageAiPane', () => {
it('shows an unconfigured state when the default agent is unsupported', () => {
const markup = renderPane(
buildSettings({
defaultTuiAgent: 'gemini',
defaultTuiAgent: 'aider',
commitMessageAi: {
enabled: true,
agentId: null,
@@ -103,12 +129,31 @@ describe('CommitMessageAiPane', () => {
)
expect(markup).toContain('Not configured')
expect(markup).toContain('Your default agent is Gemini')
expect(markup).toContain('Choose Claude, Codex, or Custom')
expect(markup).toContain('Your default agent is Aider')
expect(markup).toContain('Choose a supported agent or Custom')
expect(markup).not.toContain('Which model the selected agent uses')
expect(markup).not.toContain('Thinking effort')
})
it('shows Gemini as coming soon instead of a selectable generator', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
enabled: true,
agentId: 'gemini',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: ''
}
})
)
expect(markup).toContain('Gemini')
expect(markup).toContain('Gemini commit message generation is coming soon')
expect(markup).not.toContain('Which model the selected agent uses')
})
it('keeps custom command discoverable in settings search metadata', () => {
const customCommandEntry = COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES.find(
(entry) => entry.title === 'Custom command'
@@ -118,4 +163,85 @@ describe('CommitMessageAiPane', () => {
expect.arrayContaining(['custom', 'command', 'ollama'])
)
})
it('merges discovered models without clobbering newer settings fields', () => {
const config: CommitMessageAiSettings = {
enabled: true,
agentId: 'cursor',
selectedModelByAgent: { cursor: 'stale-model', codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'low' },
customPrompt: 'Use Conventional Commits.',
customAgentCommand: '',
discoveredModelsByAgent: {}
}
const merged = mergeDiscoveredModelsIntoCommitMessageConfig(
config,
'cursor',
[{ id: 'auto', label: 'Auto' }],
'auto'
)
expect(merged.customPrompt).toBe('Use Conventional Commits.')
expect(merged.agentId).toBe('cursor')
expect(merged.selectedModelByAgent).toEqual({
cursor: 'auto',
codex: 'gpt-5.5'
})
expect(merged.discoveredModelsByAgent?.cursor).toEqual([{ id: 'auto', label: 'Auto' }])
expect(merged.discoveredModelsByAgentByHost?.local?.cursor).toEqual([
{ id: 'auto', label: 'Auto' }
])
})
it('keeps SSH discovered models out of the legacy local cache', () => {
const config: CommitMessageAiSettings = {
enabled: true,
agentId: 'cursor',
selectedModelByAgent: { cursor: 'auto' },
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: '',
discoveredModelsByAgent: { cursor: [{ id: 'auto', label: 'Auto' }] },
selectedModelByAgentByHost: {},
discoveredModelsByAgentByHost: {}
}
const merged = mergeDiscoveredModelsIntoCommitMessageConfig(
config,
'cursor',
[{ id: 'remote-only', label: 'Remote Only' }],
'remote-only',
'ssh:conn-1'
)
expect(merged.selectedModelByAgent.cursor).toBe('auto')
expect(merged.discoveredModelsByAgent?.cursor).toEqual([{ id: 'auto', label: 'Auto' }])
expect(merged.selectedModelByAgentByHost?.['ssh:conn-1']?.cursor).toBe('remote-only')
expect(merged.discoveredModelsByAgentByHost?.['ssh:conn-1']?.cursor).toEqual([
{ id: 'remote-only', label: 'Remote Only' }
])
})
it('keys model discovery cache by execution host', () => {
expect(getCommitMessageModelDiscoveryHostKey(null)).toBe('local')
expect(getCommitMessageModelDiscoveryHostKey('ssh-1')).toBe('ssh:ssh-1')
expect(getCommitMessageModelDiscoveryHostKey(undefined)).toBe('unknown')
expect(getCommitMessageModelDiscoveryHostKeyForScope('runtime:env-1')).toBe('runtime:env-1')
expect(getCommitMessageModelDiscoveryHostKeyForScope('ssh-1')).toBe('ssh:ssh-1')
})
it('keeps local active worktree discovery scoped to local, not unknown', () => {
expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), null, true)).toBe('local')
expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), undefined, true)).toBe(
'unknown'
)
expect(
getCommitMessageSettingsPaneDiscoveryHostKey(
buildSettings({ activeRuntimeEnvironmentId: 'env-1' }),
null,
true
)
).toBe('runtime:env-1')
})
})
@@ -3,7 +3,7 @@
a SearchableSetting block, and splitting the pane across files would scatter
the ~6 conditional render branches without making any of them clearer. */
import { useEffect, useMemo, useRef, useState } from 'react'
import { Terminal } from 'lucide-react'
import { RefreshCw, Terminal } from 'lucide-react'
import type { CommitMessageAiSettings, GlobalSettings, TuiAgent } from '../../../../shared/types'
import {
CUSTOM_AGENT_ID,
@@ -15,11 +15,21 @@ import {
type CommitMessageModelCapability
} from '../../../../shared/commit-message-agent-spec'
import { CUSTOM_PROMPT_PLACEHOLDER } from '../../../../shared/commit-message-prompt'
import {
getCommitMessageModelDiscoveryHostKeyForScope,
LOCAL_COMMIT_MESSAGE_HOST_KEY
} from '../../../../shared/commit-message-host-key'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { getConnectionId } from '@/lib/connection-context'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import {
discoverRuntimeCommitMessageModels,
getRuntimeGitScope
} from '../../runtime/runtime-git-client'
import { useAppStore } from '../../store'
import { useActiveWorktree } from '../../store/selectors'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
@@ -34,12 +44,24 @@ const EMPTY_SETTINGS: CommitMessageAiSettings = {
enabled: false,
agentId: null,
selectedModelByAgent: {},
discoveredModelsByAgent: {},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: ''
}
type ModelDiscoveryState = {
status: 'idle' | 'loading' | 'ready' | 'error'
hostKey: string
models: CommitMessageModelCapability[]
defaultModelId?: string
error?: string
}
const UNCONFIGURED_AGENT_SELECT_VALUE = ''
const COMING_SOON_COMMIT_MESSAGE_AGENTS: readonly { id: TuiAgent; label: string }[] = [
{ id: 'gemini', label: 'Gemini' }
]
function readSettings(settings: GlobalSettings): CommitMessageAiSettings {
return settings.commitMessageAi ?? EMPTY_SETTINGS
@@ -49,11 +71,23 @@ function agentLabel(agentId: TuiAgent, capability: CommitMessageAgentCapability)
return AGENT_CATALOG.find((a) => a.id === agentId)?.label ?? capability.label
}
function readSelectedModelId(
config: CommitMessageAiSettings,
hostKey: string,
agentId: TuiAgent
): string | undefined {
return (
config.selectedModelByAgentByHost?.[hostKey]?.[agentId] ??
(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY ? config.selectedModelByAgent[agentId] : undefined)
)
}
function resolveSelectedModel(
config: CommitMessageAiSettings,
capability: CommitMessageAgentCapability
capability: CommitMessageAgentCapability,
hostKey: string
): CommitMessageModelCapability {
const persisted = config.selectedModelByAgent[capability.id]
const persisted = readSelectedModelId(config, hostKey, capability.id)
if (persisted) {
const found = capability.models.find((m) => m.id === persisted)
if (found) {
@@ -78,6 +112,91 @@ function resolveSelectedThinking(
return model.defaultThinkingLevel
}
export function mergeDiscoveredModelsIntoCommitMessageConfig(
config: CommitMessageAiSettings,
agentId: TuiAgent,
models: CommitMessageModelCapability[],
defaultModelId: string,
hostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY
): CommitMessageAiSettings {
const hostSelectedModels = config.selectedModelByAgentByHost?.[hostKey] ?? {}
const persisted = readSelectedModelId(config, hostKey, agentId)
const nextModelId = models.some((model) => model.id === persisted) ? persisted : defaultModelId
const nextHostSelectedModels =
nextModelId && nextModelId !== persisted
? {
...hostSelectedModels,
[agentId]: nextModelId
}
: hostSelectedModels
const nextHostDiscoveredModels = {
...config.discoveredModelsByAgentByHost?.[hostKey],
[agentId]: models
}
return {
...config,
...(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? {
discoveredModelsByAgent: {
...config.discoveredModelsByAgent,
[agentId]: models
},
selectedModelByAgent:
nextModelId && nextModelId !== persisted
? {
...config.selectedModelByAgent,
[agentId]: nextModelId
}
: config.selectedModelByAgent
}
: {}),
discoveredModelsByAgentByHost: {
...config.discoveredModelsByAgentByHost,
[hostKey]: nextHostDiscoveredModels
},
selectedModelByAgentByHost: {
...config.selectedModelByAgentByHost,
[hostKey]: nextHostSelectedModels
}
}
}
function selectModelForHost(
config: CommitMessageAiSettings,
hostKey: string,
agentId: TuiAgent,
modelId: string
): Pick<CommitMessageAiSettings, 'selectedModelByAgent' | 'selectedModelByAgentByHost'> {
const hostSelectedModels = config.selectedModelByAgentByHost?.[hostKey] ?? {}
return {
selectedModelByAgent:
hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? {
...config.selectedModelByAgent,
[agentId]: modelId
}
: config.selectedModelByAgent,
selectedModelByAgentByHost: {
...config.selectedModelByAgentByHost,
[hostKey]: {
...hostSelectedModels,
[agentId]: modelId
}
}
}
}
export function getCommitMessageSettingsPaneDiscoveryHostKey(
settings: GlobalSettings,
activeConnectionId: string | null | undefined,
hasActiveWorktree: boolean
): string {
const runtimeScope = hasActiveWorktree
? getRuntimeGitScope(settings, activeConnectionId)
: activeConnectionId
return getCommitMessageModelDiscoveryHostKeyForScope(runtimeScope)
}
export function CommitMessageAiPane({
settings,
updateSettings,
@@ -85,7 +204,19 @@ export function CommitMessageAiPane({
customPromptDiscardSignal
}: CommitMessageAiPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const activeWorktree = useActiveWorktree()
const activeConnectionId = getConnectionId(activeWorktree?.id ?? null)
const discoveryHostKey = getCommitMessageSettingsPaneDiscoveryHostKey(
settings,
activeConnectionId,
Boolean(activeWorktree?.id)
)
const config = readSettings(settings)
const latestConfigRef = useRef(config)
latestConfigRef.current = config
const [modelDiscoveryByAgent, setModelDiscoveryByAgent] = useState<
Partial<Record<TuiAgent, ModelDiscoveryState>>
>({})
const persistedCustomPrompt = config.customPrompt
const [customPromptDraft, setCustomPromptDraft] = useState(persistedCustomPrompt)
const [isSavingCustomPrompt, setIsSavingCustomPrompt] = useState(false)
@@ -119,9 +250,36 @@ export function CommitMessageAiPane({
[onCustomPromptDirtyChange]
)
const agentCapabilities = useMemo(listCommitMessageAgentCapabilities, [])
const baseAgentCapabilities = useMemo(listCommitMessageAgentCapabilities, [])
const agentCapabilities = useMemo(
() =>
baseAgentCapabilities.map((capability) => {
const discovery = modelDiscoveryByAgent[capability.id]
if (
capability.modelSource !== 'dynamic' ||
discovery?.status !== 'ready' ||
discovery.hostKey !== discoveryHostKey
) {
return capability
}
return {
...capability,
models: discovery.models,
defaultModelId: discovery.defaultModelId ?? capability.defaultModelId
}
}),
[baseAgentCapabilities, discoveryHostKey, modelDiscoveryByAgent]
)
const resolvedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent)
const activeAgentSelectValue = resolvedAgentId ?? UNCONFIGURED_AGENT_SELECT_VALUE
const unsupportedSelectedAgent =
config.agentId &&
!isCustomAgentId(config.agentId) &&
!getCommitMessageAgentCapability(config.agentId)
? config.agentId
: null
const activeAgentSelectValue = unsupportedSelectedAgent
? UNCONFIGURED_AGENT_SELECT_VALUE
: (resolvedAgentId ?? UNCONFIGURED_AGENT_SELECT_VALUE)
const unsupportedDefaultAgent =
resolvedAgentId === null &&
!config.agentId &&
@@ -133,18 +291,139 @@ export function CommitMessageAiPane({
? (AGENT_CATALOG.find((a) => a.id === unsupportedDefaultAgent)?.label ??
unsupportedDefaultAgent)
: null
const unsupportedSelectedAgentIsComingSoon = COMING_SOON_COMMIT_MESSAGE_AGENTS.some(
(agent) => agent.id === unsupportedSelectedAgent
)
const unsupportedSelectedAgentLabel = unsupportedSelectedAgent
? (COMING_SOON_COMMIT_MESSAGE_AGENTS.find((a) => a.id === unsupportedSelectedAgent)?.label ??
AGENT_CATALOG.find((a) => a.id === unsupportedSelectedAgent)?.label ??
unsupportedSelectedAgent)
: null
const isCustom = isCustomAgentId(resolvedAgentId)
const activeCapability =
resolvedAgentId && !isCustomAgentId(resolvedAgentId)
? getCommitMessageAgentCapability(resolvedAgentId)
: undefined
const activeModel = activeCapability ? resolveSelectedModel(config, activeCapability) : null
const activeAgentId = resolvedAgentId && !isCustom ? resolvedAgentId : null
const activeCapability = activeAgentId
? (agentCapabilities.find((capability) => capability.id === activeAgentId) ??
getCommitMessageAgentCapability(activeAgentId))
: undefined
const activeModel = activeCapability
? resolveSelectedModel(config, activeCapability, discoveryHostKey)
: null
const activeThinking = activeModel ? resolveSelectedThinking(config, activeModel) : undefined
const rawActiveDiscovery = activeAgentId ? modelDiscoveryByAgent[activeAgentId] : undefined
const activeDiscovery =
rawActiveDiscovery?.hostKey === discoveryHostKey ? rawActiveDiscovery : undefined
const writeConfig = (patch: Partial<CommitMessageAiSettings>): void => {
updateSettings({ commitMessageAi: { ...config, ...patch } })
}
const refreshModels = async (agentId: TuiAgent): Promise<void> => {
const capability =
agentCapabilities.find((candidate) => candidate.id === agentId) ??
getCommitMessageAgentCapability(agentId)
if (!capability || capability.modelSource !== 'dynamic') {
return
}
setModelDiscoveryByAgent((prev) => ({
...prev,
[agentId]: {
status: 'loading',
hostKey: discoveryHostKey,
models:
prev[agentId]?.hostKey === discoveryHostKey
? (prev[agentId]?.models ?? capability.models)
: capability.models
}
}))
try {
const result = await discoverRuntimeCommitMessageModels(
{
settings,
worktreeId: activeWorktree?.id,
worktreePath: activeWorktree?.path ?? '',
connectionId: activeConnectionId ?? undefined
},
agentId
)
if (!result.success) {
setModelDiscoveryByAgent((prev) => ({
...prev,
[agentId]: {
status: 'error',
hostKey: discoveryHostKey,
models:
prev[agentId]?.hostKey === discoveryHostKey
? (prev[agentId]?.models ?? capability.models)
: capability.models,
error: result.error
}
}))
return
}
setModelDiscoveryByAgent((prev) => ({
...prev,
[agentId]: {
status: 'ready',
hostKey: discoveryHostKey,
models: result.models,
defaultModelId: result.defaultModelId
}
}))
const latestConfig = latestConfigRef.current
updateSettings({
commitMessageAi: mergeDiscoveredModelsIntoCommitMessageConfig(
latestConfig,
agentId,
result.models,
result.defaultModelId,
discoveryHostKey
)
})
} catch (error) {
setModelDiscoveryByAgent((prev) => ({
...prev,
[agentId]: {
status: 'error',
hostKey: discoveryHostKey,
models:
prev[agentId]?.hostKey === discoveryHostKey
? (prev[agentId]?.models ?? capability.models)
: capability.models,
error: error instanceof Error ? error.message : 'Failed to discover models'
}
}))
}
}
useEffect(() => {
if (
!config.enabled ||
isCustom ||
!activeCapability ||
activeCapability.modelSource !== 'dynamic'
) {
return
}
const discovery = modelDiscoveryByAgent[activeCapability.id]
if (
discovery?.hostKey === discoveryHostKey &&
(discovery.status === 'loading' || discovery.status === 'ready')
) {
return
}
void refreshModels(activeCapability.id)
// Why: auto-refresh should run once when a dynamic agent becomes active.
// Including the discovery map would retry immediately after an error and
// turn a visible CLI failure into a request loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeCapability?.id,
activeCapability?.modelSource,
config.enabled,
discoveryHostKey,
isCustom
])
const onToggleEnabled = (): void => {
const next = !config.enabled
if (!next) {
@@ -164,13 +443,23 @@ export function CommitMessageAiPane({
const seedCapability = isCustomAgentId(seedAgentId)
? undefined
: getCommitMessageAgentCapability(seedAgentId)
const seedModel = seedCapability ? resolveSelectedModel(config, seedCapability) : null
const seedModel = seedCapability
? resolveSelectedModel(config, seedCapability, discoveryHostKey)
: null
const seedThinking = seedModel ? resolveSelectedThinking(config, seedModel) : undefined
const nextSelectedModelByAgent = { ...config.selectedModelByAgent }
if (seedCapability && !nextSelectedModelByAgent[seedCapability.id]) {
nextSelectedModelByAgent[seedCapability.id] = seedCapability.defaultModelId
}
const selectedModelPatch = seedCapability
? selectModelForHost(
config,
discoveryHostKey,
seedCapability.id,
readSelectedModelId(config, discoveryHostKey, seedCapability.id) ??
seedCapability.defaultModelId
)
: {
selectedModelByAgent: config.selectedModelByAgent,
selectedModelByAgentByHost: config.selectedModelByAgentByHost
}
const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
if (seedModel && seedThinking && !nextSelectedThinkingByModel[seedModel.id]) {
nextSelectedThinkingByModel[seedModel.id] = seedThinking
@@ -178,7 +467,7 @@ export function CommitMessageAiPane({
writeConfig({
enabled: true,
agentId: seedAgentId,
selectedModelByAgent: nextSelectedModelByAgent,
...selectedModelPatch,
selectedThinkingByModel: nextSelectedThinkingByModel
})
}
@@ -195,11 +484,17 @@ export function CommitMessageAiPane({
if (!capability) {
return
}
const nextSelectedModelByAgent = { ...config.selectedModelByAgent }
if (!nextSelectedModelByAgent[capability.id]) {
nextSelectedModelByAgent[capability.id] = capability.defaultModelId
}
const newModel = resolveSelectedModel({ ...config, agentId: capability.id }, capability)
const selectedModelPatch = selectModelForHost(
config,
discoveryHostKey,
capability.id,
readSelectedModelId(config, discoveryHostKey, capability.id) ?? capability.defaultModelId
)
const newModel = resolveSelectedModel(
{ ...config, ...selectedModelPatch, agentId: capability.id },
capability,
discoveryHostKey
)
const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
if (
newModel.thinkingLevels &&
@@ -210,7 +505,7 @@ export function CommitMessageAiPane({
}
writeConfig({
agentId: capability.id,
selectedModelByAgent: nextSelectedModelByAgent,
...selectedModelPatch,
selectedThinkingByModel: nextSelectedThinkingByModel
})
}
@@ -227,10 +522,12 @@ export function CommitMessageAiPane({
if (!model) {
return
}
const nextSelectedModelByAgent = {
...config.selectedModelByAgent,
[activeCapability.id]: model.id
}
const selectedModelPatch = selectModelForHost(
config,
discoveryHostKey,
activeCapability.id,
model.id
)
const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
if (
model.thinkingLevels &&
@@ -240,7 +537,7 @@ export function CommitMessageAiPane({
nextSelectedThinkingByModel[model.id] = model.defaultThinkingLevel
}
writeConfig({
selectedModelByAgent: nextSelectedModelByAgent,
...selectedModelPatch,
selectedThinkingByModel: nextSelectedThinkingByModel
})
}
@@ -321,7 +618,7 @@ export function CommitMessageAiPane({
matchesSettingsSearch(searchQuery, {
title: 'Agent',
description: 'Which agent to invoke when generating a commit message.',
keywords: ['agent', 'claude', 'codex']
keywords: ['agent', 'claude', 'codex', 'opencode', 'gemini', 'cursor']
})
) {
sections.push(
@@ -329,7 +626,7 @@ export function CommitMessageAiPane({
key="agent"
title="Agent"
description="Which agent to invoke when generating a commit message."
keywords={['agent', 'claude', 'codex']}
keywords={['agent', 'claude', 'codex', 'opencode', 'gemini', 'cursor']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
@@ -342,7 +639,7 @@ export function CommitMessageAiPane({
</div>
<div className="flex flex-col items-end gap-1">
<Select value={activeAgentSelectValue} onValueChange={onAgentChange}>
<SelectTrigger size="sm" className="h-8 text-xs w-[180px]">
<SelectTrigger size="sm" className="h-8 w-[260px] shrink-0 text-xs">
<SelectValue placeholder="Not configured" />
</SelectTrigger>
<SelectContent>
@@ -357,6 +654,17 @@ export function CommitMessageAiPane({
</SelectItem>
)
})}
{COMING_SOON_COMMIT_MESSAGE_AGENTS.filter(
(agent) => !agentCapabilities.some((capability) => capability.id === agent.id)
).map((agent) => (
<SelectItem key={agent.id} value={agent.id} disabled className="cursor-not-allowed">
<span className="flex items-center gap-2">
<AgentIcon agent={agent.id} size={14} />
<span>{agent.label}</span>
<span className="text-[11px] text-muted-foreground">Coming soon</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_AGENT_ID} className="cursor-pointer">
<span className="flex items-center gap-2">
<Terminal className="size-3.5" />
@@ -368,7 +676,15 @@ export function CommitMessageAiPane({
{unsupportedDefaultAgentLabel ? (
<p className="max-w-[260px] text-right text-[11px] text-muted-foreground">
Your default agent is {unsupportedDefaultAgentLabel}, which does not support commit
message generation yet. Choose Claude, Codex, or Custom.
message generation yet. Choose a supported agent or Custom.
</p>
) : null}
{unsupportedSelectedAgentLabel ? (
<p className="max-w-[260px] text-right text-[11px] text-muted-foreground">
{unsupportedSelectedAgentIsComingSoon
? `${unsupportedSelectedAgentLabel} commit message generation is coming soon.`
: `${unsupportedSelectedAgentLabel} does not support commit message generation yet.`}{' '}
Choose a supported agent or Custom.
</p>
) : null}
</div>
@@ -444,22 +760,42 @@ export function CommitMessageAiPane({
<div className="space-y-0.5">
<Label>Model</Label>
<p className="text-xs text-muted-foreground">
Defaults to the strongest available model for the selected agent. Pick a smaller one if
you prefer lower latency or cost.
{activeCapability.modelSource === 'dynamic'
? 'Refreshes from the selected CLI when the CLI exposes model discovery.'
: 'This agent does not expose model discovery, so Orca uses a manual catalog.'}
</p>
{activeDiscovery?.status === 'error' && (
<p className="text-xs text-destructive">{activeDiscovery.error}</p>
)}
</div>
<div className="flex items-center gap-2">
{activeCapability.modelSource === 'dynamic' && (
<button
type="button"
onClick={() => void refreshModels(activeCapability.id)}
disabled={activeDiscovery?.status === 'loading'}
title="Refresh models"
aria-label="Refresh models"
className="inline-flex size-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw
className={`size-3.5 ${activeDiscovery?.status === 'loading' ? 'animate-spin' : ''}`}
/>
</button>
)}
<Select value={activeModel.id} onValueChange={onModelChange}>
<SelectTrigger size="sm" className="h-8 w-[260px] shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{activeCapability.models.map((m) => (
<SelectItem key={m.id} value={m.id} className="cursor-pointer">
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={activeModel.id} onValueChange={onModelChange}>
<SelectTrigger size="sm" className="h-8 text-xs w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{activeCapability.models.map((m) => (
<SelectItem key={m.id} value={m.id} className="cursor-pointer">
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SearchableSetting>
)
}
@@ -278,16 +278,16 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
breaks that toast action even though this pane still renders fine. */}
<div id="general-skip-delete-worktree-confirm" className="scroll-mt-6">
<SearchableSetting
title="Ask Before Deleting Worktrees"
description="Show a confirmation dialog before deleting a worktree."
title="Ask Before Deleting Workspaces"
description="Show a confirmation dialog before deleting a workspace."
keywords={['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Ask Before Deleting Worktrees</Label>
<Label>Ask Before Deleting Workspaces</Label>
<p className="text-xs text-muted-foreground">
Show a confirmation before deleting a worktree from the context menu. Failed deletes
still surface a Force Delete fallback.
Show a confirmation before deleting a workspace from the context menu. Failed
deletes still surface a Force Delete fallback.
</p>
</div>
<button
@@ -31,6 +31,7 @@ import { useAppStore } from '../../store'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { applyDocumentTheme } from '@/lib/document-theme'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane'
@@ -277,6 +278,7 @@ function Settings(): React.JSX.Element {
const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0)
const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false)
const [commitPromptDiscardSignal, setCommitPromptDiscardSignal] = useState(0)
const confirm = useConfirmationDialog()
// Why: the hidden-experimental group is an unlock — Shift-clicking the
// Experimental sidebar entry reveals it for the remainder of the session.
// Not persisted on purpose: it's a power-user affordance we don't want to
@@ -291,22 +293,25 @@ function Settings(): React.JSX.Element {
const repoHooksRequestSeqRef = useRef(0)
const repoHooksRuntimeIdentityRef = useRef<string>('local')
const confirmDiscardCommitPromptChanges = useCallback((): boolean => {
const confirmDiscardCommitPromptChanges = useCallback(async (): Promise<boolean> => {
if (!hasUnsavedCommitPromptChanges) {
return true
}
const shouldDiscard = window.confirm(
'You have unsaved AI commit prompt changes. Leave without saving?'
)
const shouldDiscard = await confirm({
title: 'Discard unsaved commit prompt changes?',
description: 'You have unsaved AI commit prompt changes. Leaving will discard them.',
confirmLabel: 'Discard',
confirmVariant: 'destructive'
})
if (shouldDiscard) {
setCommitPromptDiscardSignal((signal) => signal + 1)
setHasUnsavedCommitPromptChanges(false)
}
return shouldDiscard
}, [hasUnsavedCommitPromptChanges])
}, [confirm, hasUnsavedCommitPromptChanges])
const closeSettingsPageWithPromptGuard = useCallback((): void => {
if (!confirmDiscardCommitPromptChanges()) {
const closeSettingsPageWithPromptGuard = useCallback(async (): Promise<void> => {
if (!(await confirmDiscardCommitPromptChanges())) {
return
}
closeSettingsPage()
@@ -319,10 +324,33 @@ function Settings(): React.JSX.Element {
const runtimeTargetIdentity = getRuntimeTargetIdentity(settings)
useEffect(() => {
const hasVisibleOverlay = (): boolean =>
Array.from(
document.querySelectorAll('[role="dialog"], [role="listbox"], [role="menu"]')
).some((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
if (element.closest('[aria-hidden="true"]')) {
return false
}
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.getClientRects().length > 0
)
})
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' || event.defaultPrevented) {
return
}
// Why: nested dialogs and menus own Escape before Settings page-level
// navigation, including the unsaved commit prompt confirmation dialog.
if (hasVisibleOverlay()) {
return
}
// Why: Escape in an editable control usually means "cancel this edit",
// not "close Settings". Closing the entire page would discard the user's
// in-progress typing. Defer to the field's own handler when focus is on
@@ -331,7 +359,7 @@ function Settings(): React.JSX.Element {
if (isEditableTarget(event.target)) {
return
}
closeSettingsPageWithPromptGuard()
void closeSettingsPageWithPromptGuard()
}
document.addEventListener('keydown', handleKeyDown)
@@ -949,11 +977,11 @@ function Settings(): React.JSX.Element {
}, [visibleNavSections])
const scrollToSection = useCallback(
(
async (
sectionId: string,
modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
) => {
if (sectionId !== activeSectionId && !confirmDiscardCommitPromptChanges()) {
): Promise<void> => {
if (sectionId !== activeSectionId && !(await confirmDiscardCommitPromptChanges())) {
return
}
// Why: Shift-clicking the Experimental sidebar entry unlocks a hidden
@@ -971,8 +999,8 @@ function Settings(): React.JSX.Element {
[activeSectionId, confirmDiscardCommitPromptChanges]
)
const openComputerUseFromBrowser = useCallback(() => {
if (!confirmDiscardCommitPromptChanges()) {
const openComputerUseFromBrowser = useCallback(async () => {
if (!(await confirmDiscardCommitPromptChanges())) {
return
}
pendingNavSectionRef.current = 'computer-use'
@@ -12,8 +12,8 @@ export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
keywords: ['nested', 'subfolder', 'directory']
},
{
title: 'Ask Before Deleting Worktrees',
description: 'Show a confirmation dialog before deleting a worktree.',
title: 'Ask Before Deleting Workspaces',
description: 'Show a confirmation dialog before deleting a workspace.',
keywords: ['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']
},
{
@@ -3,6 +3,45 @@ import { describe, expect, it } from 'vitest'
import CommentMarkdown from './CommentMarkdown'
describe('CommentMarkdown', () => {
it('autolinks same-repo GitHub issue references when repo context is provided', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
variant="document"
githubRepo={{ owner: 'stablyai', repo: 'orca' }}
content="Automated fix-PR from pr-bug-scan for parent **#2316**."
/>
)
expect(markup).toContain('href="https://github.com/stablyai/orca/issues/2316"')
expect(markup).toContain('<strong><a')
})
it('autolinks cross-repo GitHub issue references', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
variant="document"
githubRepo={{ owner: 'stablyai', repo: 'orca' }}
content="See another-org/other-repo#42."
/>
)
expect(markup).toContain('href="https://github.com/another-org/other-repo/issues/42"')
})
it('does not autolink GitHub issue references inside existing links or code', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
variant="document"
githubRepo={{ owner: 'stablyai', repo: 'orca' }}
content="[`#2316`](https://example.com/already-linked) and `#2317`"
/>
)
expect(markup).toContain('href="https://example.com/already-linked"')
expect(markup).not.toContain('href="https://github.com/stablyai/orca/issues/2316"')
expect(markup).not.toContain('href="https://github.com/stablyai/orca/issues/2317"')
})
it('contains long PR body markdown inside its available width', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
@@ -9,6 +9,29 @@ import { cn } from '@/lib/utils'
type MarkdownPlugins = NonNullable<React.ComponentProps<typeof Markdown>['rehypePlugins']>
type GitHubRepoReference = {
owner: string
repo: string
}
type MarkdownTextNode = {
type: 'text'
value: string
}
type MarkdownLinkNode = {
type: 'link'
url: string
title: null
children: MarkdownTextNode[]
}
type MarkdownNode = {
type: string
value?: string
children?: MarkdownNode[]
}
// Why: sidebar comments are rendered at 11px in a narrow card, so we strip
// block-level wrappers that add unwanted margins and only keep inline
// formatting (bold, italic, code, links) plus compact lists and line breaks.
@@ -107,7 +130,7 @@ const documentComponents: Components = {
href={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => e.stopPropagation()}
>
{children}
@@ -170,6 +193,94 @@ const documentComponents: Components = {
// with existing plain-text comments that rely on newline formatting.
const remarkPlugins = [remarkGfm, remarkBreaks]
const GITHUB_REFERENCE_PATTERN = /(?:\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))?#([1-9][0-9]*)\b/g
function createGitHubIssueUrl(owner: string, repo: string, number: string): string {
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`
}
function isEmbeddedGitHubReference(value: string, index: number): boolean {
if (index === 0) {
return false
}
return /[A-Za-z0-9_./-]/.test(value[index - 1] ?? '')
}
function createGitHubReferenceLinkNode(
label: string,
owner: string,
repo: string,
number: string
): MarkdownLinkNode {
return {
type: 'link',
url: createGitHubIssueUrl(owner, repo, number),
title: null,
children: [{ type: 'text', value: label }]
}
}
function splitGitHubReferenceText(value: string, defaultRepo: GitHubRepoReference): MarkdownNode[] {
const parts: MarkdownNode[] = []
let cursor = 0
for (const match of value.matchAll(GITHUB_REFERENCE_PATTERN)) {
const label = match[0]
const index = match.index ?? 0
if (isEmbeddedGitHubReference(value, index)) {
continue
}
const owner = match[1] ?? defaultRepo.owner
const repo = match[2] ?? defaultRepo.repo
const number = match[3]
if (!number) {
continue
}
if (index > cursor) {
parts.push({ type: 'text', value: value.slice(cursor, index) })
}
parts.push(createGitHubReferenceLinkNode(label, owner, repo, number))
cursor = index + label.length
}
if (cursor === 0) {
return [{ type: 'text', value }]
}
if (cursor < value.length) {
parts.push({ type: 'text', value: value.slice(cursor) })
}
return parts
}
function transformGitHubReferenceChildren(
node: MarkdownNode,
defaultRepo: GitHubRepoReference
): void {
if (!node.children || node.type === 'link' || node.type === 'image') {
return
}
const nextChildren: MarkdownNode[] = []
for (const child of node.children) {
if (child.type === 'text' && child.value !== undefined) {
nextChildren.push(...splitGitHubReferenceText(child.value, defaultRepo))
} else {
transformGitHubReferenceChildren(child, defaultRepo)
nextChildren.push(child)
}
}
node.children = nextChildren
}
export function remarkGitHubReferences(
defaultRepo: GitHubRepoReference
): () => (tree: MarkdownNode) => void {
return () => (tree) => transformGitHubReferenceChildren(tree, defaultRepo)
}
const commentMarkdownSanitizeSchema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), 'details', 'summary', 'sub', 'sup', 'ins', 'kbd'],
@@ -191,6 +302,7 @@ const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkd
type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
content: string
variant?: 'compact' | 'document'
githubRepo?: GitHubRepoReference | null
}
// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref
@@ -198,10 +310,14 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
// the child. Without forwarding both, the hover card cannot open or position.
const CommentMarkdown = React.memo(
React.forwardRef<HTMLDivElement, CommentMarkdownProps>(function CommentMarkdown(
{ content, className, variant = 'compact', ...rest },
{ content, className, variant = 'compact', githubRepo, ...rest },
ref
) {
const components = variant === 'document' ? documentComponents : compactComponents
const activeRemarkPlugins = React.useMemo(
() => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins),
[githubRepo]
)
return (
<div
@@ -217,7 +333,7 @@ const CommentMarkdown = React.memo(
{...rest}
>
<Markdown
remarkPlugins={remarkPlugins}
remarkPlugins={activeRemarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
@@ -174,7 +174,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
onDeleted?.([worktreeId])
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
toast.error('Failed to delete workspace', {
description: err instanceof Error ? err.message : String(err)
})
})
@@ -222,14 +222,14 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
>
<DialogHeader>
<DialogTitle className="text-sm">
{isBatchDelete ? 'Delete Worktrees' : 'Delete Worktree'}
{isBatchDelete ? 'Delete Workspaces' : 'Delete Workspace'}
</DialogTitle>
<DialogDescription className="text-xs">
{isBatchDelete ? (
<>
Remove{' '}
<span className="font-medium text-foreground">{worktrees.length} worktrees</span>{' '}
from git and delete their working tree folders.
<span className="font-medium text-foreground">{worktrees.length} workspaces</span>{' '}
from git and delete their workspace folders.
</>
) : (
<>
@@ -237,7 +237,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
<span className="break-all font-medium text-foreground">
{worktree?.displayName}
</span>{' '}
from git and delete its working tree folder.
from git and delete its workspace folder.
</>
)}
</DialogDescription>
@@ -130,14 +130,14 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
// Why: swallow bubbling so clicks on the gutter around the agent rows
// don't reach WorktreeCard's activate / edit-meta handlers.
<div
className={cn('flex flex-col mt-1 divide-y divide-border/30', className)}
className={cn('flex flex-col mt-1 mb-1 divide-y divide-border/30', className)}
onClick={stopBubble}
onDoubleClick={stopBubble}
role="group"
aria-label="Agents"
>
{agents.map((agent) => (
<div key={agent.paneKey} className="py-0.5">
<div key={agent.paneKey}>
<DashboardAgentRow
agent={agent}
onDismiss={handleDismissAgent}
@@ -109,7 +109,7 @@ export function runWorktreeDeleteWithToast(
}
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
toast.error('Failed to delete workspace', {
description: err instanceof Error ? err.message : String(err),
action: {
label: 'View',
@@ -124,7 +124,7 @@ export function runWorktreeDeleteWithToast(
return false
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
toast.error('Failed to delete workspace', {
description: err instanceof Error ? err.message : String(err)
})
return false
@@ -4,7 +4,7 @@ import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
describe('getDeleteWorktreeToastCopy', () => {
it('uses direct guidance when force delete is available', () => {
expect(getDeleteWorktreeToastCopy('feature/foo', true, 'branch has changes')).toEqual({
title: 'Failed to delete worktree feature/foo',
title: 'Failed to delete workspace feature/foo',
description: 'It has changed files. Use Force Delete to delete it anyway.',
isDestructive: false
})
@@ -12,7 +12,7 @@ describe('getDeleteWorktreeToastCopy', () => {
it('preserves the raw error when force delete is unavailable', () => {
expect(getDeleteWorktreeToastCopy('feature/foo', false, 'permission denied')).toEqual({
title: 'Failed to delete worktree feature/foo',
title: 'Failed to delete workspace feature/foo',
description: 'permission denied',
isDestructive: true
})
@@ -11,7 +11,7 @@ export function getDeleteWorktreeToastCopy(
): DeleteWorktreeToastCopy {
if (canForceDelete) {
return {
title: `Failed to delete worktree ${worktreeName}`,
title: `Failed to delete workspace ${worktreeName}`,
description: 'It has changed files. Use Force Delete to delete it anyway.',
// Why: git commonly refuses the first delete when the worktree still has
// modified or untracked files. Showing raw stderr in a destructive toast
@@ -22,7 +22,7 @@ export function getDeleteWorktreeToastCopy(
}
return {
title: `Failed to delete worktree ${worktreeName}`,
title: `Failed to delete workspace ${worktreeName}`,
description: error,
isDestructive: true
}
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
ALL_GROUP_META,
buildRows,
getGroupKeyForWorktree,
getLineageGroupKey,
@@ -55,8 +56,8 @@ describe('getPRGroupKey', () => {
})
describe('getGroupKeyForWorktree', () => {
it('returns no group key for the ungrouped mode', () => {
expect(getGroupKeyForWorktree('none', worktree, repoMap, null)).toBeNull()
it('returns the all group key for the ungrouped mode', () => {
expect(getGroupKeyForWorktree('none', worktree, repoMap, null)).toBe('all')
})
it('returns a workspace-status key only in status grouping mode', () => {
@@ -71,32 +72,46 @@ describe('buildRows with pinned worktrees', () => {
const unpinned1 = { ...worktree, id: 'wt-1', displayName: 'alpha' }
const unpinned2 = { ...worktree, id: 'wt-2', displayName: 'beta' }
it('emits a Pinned header followed by pinned items in groupBy none', () => {
it('emits Pinned and All headers in groupBy none', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', label: 'Pinned', count: 1 })
expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 })
expect(rows[2]).toMatchObject({ type: 'header', icon: ALL_GROUP_META.icon })
})
it('renders a flat list without status headers in groupBy none', () => {
it('groups all worktrees under All in groupBy none', () => {
const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set())
expect(rows).toMatchObject([
{ type: 'header', key: 'all', label: 'All', count: 2 },
{ type: 'item', worktree: { id: 'wt-1' } },
{ type: 'item', worktree: { id: 'wt-2' } }
])
})
it('keeps pinned worktrees above the flat list', () => {
it('keeps pinned worktrees above the All group', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows).toMatchObject([
{ type: 'header', key: 'pinned', count: 1 },
{ type: 'item', worktree: { id: 'wt-pinned' } },
{ type: 'header', key: 'all', count: 2 },
{ type: 'item', worktree: { id: 'wt-1' } },
{ type: 'item', worktree: { id: 'wt-2' } }
])
})
it('collapses the All group in groupBy none', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set(['all']))
expect(rows).toMatchObject([
{ type: 'header', key: 'pinned', count: 1 },
{ type: 'item', worktree: { id: 'wt-pinned' } },
{ type: 'header', key: 'all', count: 2 }
])
})
it('emits status headers for unpinned worktrees in groupBy workspace-status', () => {
const rows = buildRows(
'workspace-status',
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
import { CircleX, Folder, Pin } from 'lucide-react'
import { CircleX, Folder, List, Pin } from 'lucide-react'
import type React from 'react'
import type {
Repo,
@@ -105,6 +105,14 @@ export const PINNED_GROUP_META = {
icon: Pin
} as const
export const ALL_GROUP_KEY = 'all'
export const ALL_GROUP_META = {
label: 'All',
tone: 'text-foreground',
icon: List
} as const
export const MISSING_PARENT_GROUP_META = {
label: 'Missing parent'
} as const
@@ -373,11 +381,23 @@ export function buildRows(
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
if (groupBy === 'none') {
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
nestLineage,
showLineageContext: nestLineage,
collapsedGroups
})
if (unpinned.length > 0) {
result.push({
type: 'header',
key: ALL_GROUP_KEY,
label: ALL_GROUP_META.label,
count: unpinned.length,
tone: ALL_GROUP_META.tone,
icon: ALL_GROUP_META.icon
})
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
nestLineage,
showLineageContext: nestLineage,
collapsedGroups
})
}
}
return result
}
@@ -513,7 +533,7 @@ export function getGroupKeyForWorktree(
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses()
): string | null {
if (groupBy === 'none') {
return null
return ALL_GROUP_KEY
}
if (groupBy === 'workspace-status') {
return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses))
@@ -269,6 +269,11 @@ export default function TerminalPane({
// again. Similarly, pressing Escape runs handleRenameCancel but blur would
// then call handleRenameSubmit, saving the title the user wanted to discard.
const renameSubmittedRef = useRef(false)
const renameSessionIdRef = useRef(0)
const renameBlurCommitEnabledRef = useRef(true)
const renameFocusFrameRef = useRef<number | null>(null)
const renameEnableBlurFrameRef = useRef<number | null>(null)
const renameRefocusFrameRef = useRef<number | null>(null)
const onPtyErrorRef = useRef((_paneId: number, message: string) => {
if (isTerminalSessionStateSaveFailure(message)) {
setTerminalError(null)
@@ -1207,11 +1212,41 @@ export default function TerminalPane({
}
}, [tabId, worktreeId, setTabLayout])
const handleStartRename = useCallback((paneId: number) => {
setRenameValue(paneTitlesRef.current[paneId] ?? '')
setRenamingPaneId(paneId)
const cancelPendingRenameFrames = useCallback(() => {
const frameRefs = [renameFocusFrameRef, renameEnableBlurFrameRef, renameRefocusFrameRef]
for (const frameRef of frameRefs) {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
}
}, [])
const closeRenameSession = useCallback(() => {
renameSessionIdRef.current += 1
renameBlurCommitEnabledRef.current = true
cancelPendingRenameFrames()
}, [cancelPendingRenameFrames])
useEffect(
() => () => {
closeRenameSession()
},
[closeRenameSession]
)
const handleStartRename = useCallback(
(paneId: number) => {
cancelPendingRenameFrames()
renameSessionIdRef.current += 1
renameBlurCommitEnabledRef.current = false
renameSubmittedRef.current = false
setRenameValue(paneTitlesRef.current[paneId] ?? '')
setRenamingPaneId(paneId)
},
[cancelPendingRenameFrames]
)
const removePaneTitle = useCallback(
(paneId: number) => {
setPaneTitles((prev) => {
@@ -1247,6 +1282,7 @@ export default function TerminalPane({
if (paneTitlesRef.current[renamingPaneId]) {
removePaneTitle(renamingPaneId)
}
closeRenameSession()
setRenamingPaneId(null)
return
}
@@ -1260,15 +1296,52 @@ export default function TerminalPane({
if (leafId) {
removedTitleLeafIdsRef.current.delete(leafId)
}
closeRenameSession()
setRenamingPaneId(null)
// Persist immediately so the title survives restarts.
persistLayoutSnapshot()
}, [renamingPaneId, renameValue, removePaneTitle, persistLayoutSnapshot])
}, [closeRenameSession, renamingPaneId, renameValue, removePaneTitle, persistLayoutSnapshot])
const handleRenameCancel = useCallback(() => {
renameSubmittedRef.current = true
closeRenameSession()
setRenamingPaneId(null)
}, [])
}, [closeRenameSession])
const handleRenameBlur = useCallback(() => {
if (renameBlurCommitEnabledRef.current) {
handleRenameSubmit()
return
}
if (renamingPaneId === null || renameRefocusFrameRef.current !== null) {
return
}
const sessionId = renameSessionIdRef.current
const paneId = renamingPaneId
// Why: the context-menu selection can be followed by a delayed Radix/xterm
// focus handoff. That synthetic early blur is not a title submission.
renameRefocusFrameRef.current = requestAnimationFrame(() => {
renameRefocusFrameRef.current = null
if (renameSessionIdRef.current !== sessionId || renamingPaneId !== paneId) {
return
}
const input = renameInputRef.current
if (!input) {
renameBlurCommitEnabledRef.current = true
handleRenameSubmit()
return
}
input.focus()
input.select()
if (document.activeElement === input) {
renameBlurCommitEnabledRef.current = true
return
}
renameBlurCommitEnabledRef.current = true
handleRenameSubmit()
})
}, [handleRenameSubmit, renamingPaneId])
const handleRemoveTitle = useCallback(
(paneId: number) => removePaneTitle(paneId),
@@ -1281,13 +1354,34 @@ export default function TerminalPane({
if (renamingPaneId === null) {
return
}
const sessionId = renameSessionIdRef.current
const paneId = renamingPaneId
renameSubmittedRef.current = false
const frame = requestAnimationFrame(() => {
renameInputRef.current?.focus()
renameInputRef.current?.select()
renameFocusFrameRef.current = requestAnimationFrame(() => {
renameFocusFrameRef.current = null
if (renameSessionIdRef.current !== sessionId || renamingPaneId !== paneId) {
return
}
const input = renameInputRef.current
if (!input) {
return
}
input.focus()
input.select()
renameEnableBlurFrameRef.current = requestAnimationFrame(() => {
renameEnableBlurFrameRef.current = null
if (
renameSessionIdRef.current === sessionId &&
renamingPaneId === paneId &&
renameInputRef.current === input &&
document.activeElement === input
) {
renameBlurCommitEnabledRef.current = true
}
})
})
return () => cancelAnimationFrame(frame)
}, [renamingPaneId])
return () => cancelPendingRenameFrames()
}, [cancelPendingRenameFrames, renamingPaneId])
const contextMenu = useTerminalPaneContextMenu({
managerRef,
@@ -1553,7 +1647,7 @@ export default function TerminalPane({
handleRenameCancel()
}
}}
onBlur={handleRenameSubmit}
onBlur={handleRenameBlur}
/>
) : (
<>
@@ -39,7 +39,22 @@ function getPtyIdForPaneKey(paneKey: string): string | null {
if (!parsed) {
return null
}
return useAppStore.getState().ptyIdsByTabId?.[parsed.tabId]?.[0] ?? null
const state = useAppStore.getState()
const tabPtyIds = state.ptyIdsByTabId?.[parsed.tabId]
if (!tabPtyIds || tabPtyIds.length === 0) {
return null
}
// Why: split-pane leaves share one tab-level pty list, so a tab-level lookup
// would return a sibling's pty for an already-closed leaf and let a late
// 'done' hook event fire a spurious notification. Resolve liveness through
// the leaf-keyed binding maintained by syncPanePtyLayoutBinding, which
// deletes the entry when the leaf closes.
const ptyIdsByLeafId = state.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId
if (ptyIdsByLeafId) {
const leafPtyId = ptyIdsByLeafId[parsed.leafId]
return leafPtyId && tabPtyIds.includes(leafPtyId) ? leafPtyId : null
}
return tabPtyIds[0] ?? null
}
function paneHasLivePty(paneKey: string): boolean {
@@ -52,7 +52,7 @@ describe('startup UI hydration fallback', () => {
expect(hydratePersistedUI).toHaveBeenCalledTimes(1)
expect(hydratePersistedUI.mock.calls[0][0].sidebarWidth).toBe(280)
expect(hydratePersistedUI.mock.calls[0][0].groupBy).toBe('repo')
expect(hydratePersistedUI.mock.calls[0][0].groupBy).toBe('workspace-status')
expect(hydratePersistedUI.mock.calls[0][0].sortBy).toBe('name')
})
+1 -1
View File
@@ -34,7 +34,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
lastActiveWorktreeId: null,
sidebarWidth: 280,
rightSidebarWidth: 350,
groupBy: 'repo',
groupBy: 'workspace-status',
sortBy: 'name',
showActiveOnly: false,
hideDefaultBranchWorkspace: false,
@@ -5,6 +5,7 @@ import {
bulkStageRuntimeGitPaths,
cancelRuntimeGenerateCommitMessage,
commitRuntimeGit,
discoverRuntimeCommitMessageModels,
generateRuntimeCommitMessage,
getRuntimeGitDiff,
getRuntimeGitHistory,
@@ -27,6 +28,7 @@ const gitBulkDiscard = vi.fn()
const gitCommit = vi.fn()
const gitPush = vi.fn()
const gitGenerateCommitMessage = vi.fn()
const gitDiscoverCommitMessageModels = vi.fn()
const gitCancelGenerateCommitMessage = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
@@ -43,6 +45,7 @@ beforeEach(() => {
gitCommit.mockReset()
gitPush.mockReset()
gitGenerateCommitMessage.mockReset()
gitDiscoverCommitMessageModels.mockReset()
gitCancelGenerateCommitMessage.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
@@ -62,6 +65,7 @@ beforeEach(() => {
commit: gitCommit,
push: gitPush,
generateCommitMessage: gitGenerateCommitMessage,
discoverCommitMessageModels: gitDiscoverCommitMessageModels,
cancelGenerateCommitMessage: gitCancelGenerateCommitMessage
},
runtime: { call: runtimeCall },
@@ -313,7 +317,7 @@ describe('runtime git client', () => {
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
selector: 'env-1',
method: 'git.generateCommitMessage',
params: { worktree: 'wt-1' },
params: { worktree: 'wt-1', commitMessageDiscoveryHostKey: 'runtime:env-1' },
timeoutMs: 75_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
@@ -365,9 +369,37 @@ describe('runtime git client', () => {
worktree: 'wt-1',
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true
enableGitHubAttribution: true,
commitMessageDiscoveryHostKey: 'runtime:env-1'
},
timeoutMs: 75_000
})
})
it('discovers commit-message models through the active runtime', async () => {
const agentCmdOverrides = { cursor: 'cursor-agent' }
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { success: true, models: [{ id: 'auto', label: 'Auto' }], defaultModelId: 'auto' },
_meta: { runtimeId: 'remote-runtime' }
})
await discoverRuntimeCommitMessageModels(
{
settings: { activeRuntimeEnvironmentId: 'env-1', agentCmdOverrides },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
'cursor'
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.discoverCommitMessageModels',
params: { worktree: 'wt-1', agentId: 'cursor', agentCmdOverrides },
timeoutMs: 75_000
})
expect(gitDiscoverCommitMessageModels).not.toHaveBeenCalled()
})
})
+52 -7
View File
@@ -11,6 +11,11 @@ import type {
GitUpstreamStatus,
GlobalSettings
} from '../../../shared/types'
import type {
CommitMessageAgentCapability,
CommitMessageModelCapability
} from '../../../shared/commit-message-agent-spec'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key'
import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
@@ -29,6 +34,15 @@ export type RuntimeGeneratePullRequestFieldsResult =
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
Partial<Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>>
type RuntimeDiscoverCommitMessageModelsResult =
| {
success: true
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
}
| { success: false; error: string }
export type RuntimeGitContext = {
settings: RuntimeGitSettings | null | undefined
worktreeId: string | null | undefined
@@ -37,13 +51,17 @@ export type RuntimeGitContext = {
}
function getRuntimeCommitMessageSettings(
settings: RuntimeGitSettings | null | undefined
settings: RuntimeGitSettings | null | undefined,
connectionId?: string
): Partial<
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
> {
> & {
commitMessageDiscoveryHostKey?: string
} {
if (!settings) {
return {}
}
const scope = getRuntimeGitScope(settings, connectionId)
return {
...(settings.commitMessageAi !== undefined
? { commitMessageAi: settings.commitMessageAi }
@@ -53,14 +71,15 @@ function getRuntimeCommitMessageSettings(
: {}),
...(settings.enableGitHubAttribution !== undefined
? { enableGitHubAttribution: settings.enableGitHubAttribution }
: {})
: {}),
commitMessageDiscoveryHostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope)
}
}
export function getRuntimeGitScope(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
connectionId: string | undefined
): string | undefined {
connectionId: string | null | undefined
): string | null | undefined {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId
}
@@ -362,7 +381,33 @@ export async function generateRuntimeCommitMessage(
'git.generateCommitMessage',
{
worktree: context.worktreeId,
...getRuntimeCommitMessageSettings(context.settings)
...getRuntimeCommitMessageSettings(context.settings, context.connectionId)
},
{ timeoutMs: 75_000 }
)
}
export async function discoverRuntimeCommitMessageModels(
context: RuntimeGitContext,
agentId: string
): Promise<RuntimeDiscoverCommitMessageModelsResult> {
const target = getActiveRuntimeTarget(context.settings)
if (target.kind === 'local' || !context.worktreeId) {
return window.api.git.discoverCommitMessageModels({
agentId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
}) as Promise<RuntimeDiscoverCommitMessageModelsResult>
}
return callRuntimeRpc<RuntimeDiscoverCommitMessageModelsResult>(
target,
'git.discoverCommitMessageModels',
{
worktree: context.worktreeId,
agentId,
...(context.settings?.agentCmdOverrides
? { agentCmdOverrides: context.settings.agentCmdOverrides }
: {})
},
{ timeoutMs: 75_000 }
)
@@ -405,7 +450,7 @@ export async function generateRuntimePullRequestFields(
{
worktree: context.worktreeId,
...input,
...getRuntimeCommitMessageSettings(context.settings)
...getRuntimeCommitMessageSettings(context.settings, context.connectionId)
},
{ timeoutMs: 75_000 }
)
+6 -1
View File
@@ -5,6 +5,7 @@ import { findPrevLiveWorktreeHistoryIndex } from './worktree-nav-history'
import type {
ChangelogData,
CustomPet,
GitHubWorkItem,
PersistedTrustedOrcaHooks,
PersistedUIState,
StatusBarItem,
@@ -270,6 +271,8 @@ export type UISlice = {
}
taskResumeState: TaskResumeState | undefined
setTaskResumeState: (updates: Partial<TaskResumeState>) => void
githubTaskDrawerWorkItem: GitHubWorkItem | null
setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void
newWorkspaceDraft: {
repoId: string | null
name: string
@@ -514,6 +517,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
setActiveView: (view) => set({ activeView: view }),
taskPageData: {},
taskResumeState: undefined,
githubTaskDrawerWorkItem: null,
newWorkspaceDraft: null,
openTaskPage: (data = {}) => {
// Why: record a Tasks visit in the shared back/forward history so the
@@ -594,6 +598,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
window.api.ui.set({ taskResumeState: next }).catch(console.error)
return { taskResumeState: next }
}),
setGithubTaskDrawerWorkItem: (item) => set({ githubTaskDrawerWorkItem: item }),
closeTaskPage: () =>
set((state) => {
// Why: Esc-close from Tasks must rewind the history index if we're
@@ -773,7 +778,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
return { trustedOrcaHooks: next }
}),
groupBy: 'repo',
groupBy: 'workspace-status',
// Why: group keys are mode-specific (e.g. repo id vs PR status), so
// collapsed state from one mode is meaningless in another. Clearing
// also prevents unbounded accumulation of stale keys across mode switches.
+6
View File
@@ -586,6 +586,10 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
success: false,
error: 'Commit message generation is unavailable in the web client.'
}),
discoverCommitMessageModels: async () => ({
success: false,
error: 'Commit message model discovery is unavailable in the web client.'
}),
cancelGenerateCommitMessage: () => Promise.resolve(),
generatePullRequestFields: async () => ({
success: false,
@@ -692,6 +696,7 @@ function createGitHubApi(): NonNullable<Partial<PreloadApi>['gh']> {
countWorkItems: direct('github.countWorkItems'),
listWorkItems: direct('github.listWorkItems'),
prChecks: direct('github.prChecks'),
prCheckDetails: direct('github.prCheckDetails'),
rerunPRChecks: direct('github.rerunPRChecks'),
prComments: direct('github.prComments'),
resolveReviewThread: direct('github.resolveReviewThread'),
@@ -700,6 +705,7 @@ function createGitHubApi(): NonNullable<Partial<PreloadApi>['gh']> {
mergePR: direct('github.mergePR'),
updatePRState: direct('github.updatePRState'),
requestPRReviewers: direct('github.requestPRReviewers'),
removePRReviewers: direct('github.removePRReviewers'),
updateIssue: direct('github.updateIssue'),
addIssueComment: direct('github.addIssueComment'),
addPRReviewCommentReply: direct('github.addPRReviewCommentReply'),
+141 -16
View File
@@ -9,33 +9,67 @@ import {
getCommitMessageModel,
isCustomAgentId,
listCommitMessageAgentCapabilities,
listCommitMessageAgentIds
listCommitMessageAgentIds,
parseCodexModels,
parseCursorModels,
parseLineModels,
parsePiModels
} from './commit-message-agent-spec'
describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
it('exposes Claude and Codex as the v1 agents', () => {
it('exposes the installed local agents as commit-message agents', () => {
const ids = listCommitMessageAgentIds().sort()
expect(ids).toEqual(['claude', 'codex'])
expect(ids).toEqual(['amp', 'claude', 'codex', 'copilot', 'cursor', 'kimi', 'opencode', 'pi'])
})
it('uses the smartest model as the default for each agent', () => {
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('claude-opus-4-7')
it('uses the strongest available defaults for core agents', () => {
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('sonnet')
expect(COMMIT_MESSAGE_AGENT_SPECS.codex?.defaultModelId).toBe('gpt-5.5')
expect(COMMIT_MESSAGE_AGENT_SPECS.pi?.defaultModelId).toBe('github-copilot/gpt-5.4-mini')
})
it('uses the provider-qualified Kimi model id accepted by the CLI', () => {
expect(COMMIT_MESSAGE_AGENT_SPECS.kimi?.models.map((m) => m.id)).toEqual([
'default',
'kimi-code/kimi-for-coding'
])
})
it('lists Copilot hosted CLI models even when account policy filters the picker', () => {
expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.defaultModelId).toBe('gpt-5.4')
expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.models.map((m) => m.id)).toEqual([
'auto',
'claude-haiku-4.5',
'claude-sonnet-4.5',
'claude-sonnet-4.6',
'claude-opus-4.5',
'claude-opus-4.6',
'claude-opus-4.6-fast',
'claude-opus-4.7',
'gpt-4.1',
'gpt-5-mini',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.3-codex',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.5'
])
})
it('defaults the agent picker to Claude', () => {
expect(DEFAULT_COMMIT_MESSAGE_AGENT_ID).toBe('claude')
})
it('defaults every model with thinking levels to "low"', () => {
it('gives every model with thinking levels a valid default', () => {
for (const spec of Object.values(COMMIT_MESSAGE_AGENT_SPECS)) {
if (!spec) {
continue
}
for (const model of spec.models) {
if (model.thinkingLevels) {
expect(model.defaultThinkingLevel).toBe('low')
expect(model.thinkingLevels.some((l) => l.id === 'low')).toBe(true)
expect(model.defaultThinkingLevel).toBeDefined()
expect(model.thinkingLevels.some((l) => l.id === model.defaultThinkingLevel)).toBe(true)
}
}
}
@@ -48,8 +82,8 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
expect(spark?.defaultThinkingLevel).toBe('low')
})
it('omits thinking levels on Claude Haiku 4.5 (non-reasoning model)', () => {
const haiku = getCommitMessageModel('claude', 'claude-haiku-4-5')
it('omits thinking levels on Claude Haiku (non-reasoning model)', () => {
const haiku = getCommitMessageModel('claude', 'haiku')
expect(haiku).toBeDefined()
expect(haiku?.thinkingLevels).toBeUndefined()
expect(haiku?.defaultThinkingLevel).toBeUndefined()
@@ -81,11 +115,12 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
it('exposes UI capabilities without spawn details', () => {
const capabilities = listCommitMessageAgentCapabilities()
expect(capabilities.map((capability) => capability.id).sort()).toEqual(['claude', 'codex'])
expect(capabilities.map((capability) => capability.id)).toContain('opencode')
const codex = getCommitMessageAgentCapability('codex')
expect(codex).toMatchObject({
id: 'codex',
label: 'Codex',
modelSource: 'dynamic',
defaultModelId: 'gpt-5.5'
})
expect(codex).not.toHaveProperty('binary')
@@ -98,14 +133,22 @@ describe('buildArgs (Claude)', () => {
const spec = getCommitMessageAgentSpec('claude')!
it('passes -p, output format, and model on every call', () => {
const args = spec.buildArgs({ prompt: '', model: 'claude-haiku-4-5' })
expect(args).toEqual(['-p', '--output-format', 'text', '--model', 'claude-haiku-4-5'])
const args = spec.buildArgs({ prompt: '', model: 'haiku' })
expect(args).toEqual([
'-p',
'--output-format',
'text',
'--model',
'haiku',
'--permission-mode',
'plan'
])
})
it('appends --effort when a thinking level is supplied', () => {
const args = spec.buildArgs({
prompt: '',
model: 'claude-sonnet-4-6',
model: 'sonnet',
thinkingLevel: 'high'
})
expect(args).toEqual([
@@ -113,18 +156,100 @@ describe('buildArgs (Claude)', () => {
'--output-format',
'text',
'--model',
'claude-sonnet-4-6',
'sonnet',
'--permission-mode',
'plan',
'--effort',
'high'
])
})
it('omits --effort when thinkingLevel is not provided', () => {
const args = spec.buildArgs({ prompt: '', model: 'claude-opus-4-7' })
const args = spec.buildArgs({ prompt: '', model: 'opus' })
expect(args).not.toContain('--effort')
})
})
describe('model discovery parsers', () => {
it('parses Codex model JSON', () => {
expect(
parseCodexModels(
JSON.stringify({
models: [
{
slug: 'gpt-5.5',
display_name: 'GPT-5.5',
default_reasoning_level: 'low',
supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }]
}
]
})
)
).toEqual([
{
id: 'gpt-5.5',
label: 'GPT-5.5',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' }
],
defaultThinkingLevel: 'low'
}
])
})
it('parses one-model-per-line output', () => {
expect(parseLineModels('opencode/gpt-5.4-mini\n\nopenai/gpt-5.5\n').map((m) => m.id)).toEqual([
'opencode/gpt-5.4-mini',
'openai/gpt-5.5'
])
})
it('parses Pi model table output with provider-qualified ids', () => {
const output = [
'provider model context max-out thinking images',
'github-copilot gpt-5.4-mini 400K 128K yes yes',
'github-copilot gpt-4o 128K 4.1K no yes'
].join('\n')
expect(parsePiModels(output)).toEqual([
{
id: 'github-copilot/gpt-5.4-mini',
label: 'Github Copilot GPT 5.4 Mini',
thinkingLevels: [
{ id: 'off', label: 'Off' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
defaultThinkingLevel: 'low'
},
{
id: 'github-copilot/gpt-4o',
label: 'Github Copilot GPT 4O'
}
])
})
it('parses Cursor model output', () => {
expect(parseCursorModels('auto - Auto\ngpt-5.2 - GPT-5.2\n')).toEqual([
{ id: 'auto', label: 'Auto' },
{
id: 'gpt-5.2',
label: 'GPT-5.2',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
defaultThinkingLevel: 'low'
}
])
})
})
describe('buildArgs (Codex)', () => {
const spec = getCommitMessageAgentSpec('codex')!
+446 -61
View File
@@ -1,5 +1,7 @@
import type { TuiAgent } from './types'
/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */
// Why: this file is the source of truth for non-interactive agent invocation
// (commit-message generation). It is intentionally separate from
// `tui-agent-config.ts`, which describes interactive PTY launching — mixing
@@ -27,6 +29,14 @@ export type CommitMessageAgentSpec = {
/** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */
promptDelivery: 'argv' | 'stdin'
buildArgs: (params: { prompt: string; model: string; thinkingLevel?: string }) => string[]
/** Whether the model list is static or discovered from the agent CLI. */
modelSource: 'static' | 'dynamic'
/** Command used by the main process to discover models when modelSource is dynamic. */
modelDiscovery?: {
binary: string
args: string[]
parse: (stdout: string) => CommitMessageModel[]
}
models: CommitMessageModel[]
defaultModelId: string
}
@@ -41,10 +51,159 @@ export type CommitMessageModelCapability = {
export type CommitMessageAgentCapability = {
id: TuiAgent
label: string
modelSource: 'static' | 'dynamic'
models: CommitMessageModelCapability[]
defaultModelId: string
}
const BASIC_THINKING_LEVELS: ThinkingLevel[] = [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' }
]
const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
]
const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
{ id: 'max', label: 'Max' }
]
function labelFromModelId(id: string): string {
return id
.split(/[/-]/)
.filter(Boolean)
.map((part) => {
if (/^gpt$/i.test(part)) {
return 'GPT'
}
return part.length <= 3 && /^\d/.test(part)
? part.toUpperCase()
: part.charAt(0).toUpperCase() + part.slice(1)
})
.join(' ')
}
function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] {
const seen = new Set<string>()
return models.filter((model) => {
if (!model.id || seen.has(model.id)) {
return false
}
seen.add(model.id)
return true
})
}
function withOpenAiThinking(
id: string
): Pick<CommitMessageModel, 'thinkingLevels' | 'defaultThinkingLevel'> {
return /(?:gpt-5|codex)/i.test(id)
? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' }
: {}
}
export function parseCodexModels(stdout: string): CommitMessageModel[] {
try {
const parsed = JSON.parse(stdout) as {
models?: {
slug?: string
display_name?: string
supported_reasoning_levels?: { effort?: string }[]
default_reasoning_level?: string
}[]
}
return uniqueModels(
(parsed.models ?? [])
.filter((model) => model.slug && model.display_name)
.map((model) => ({
id: model.slug!,
label: model.display_name!,
...(model.supported_reasoning_levels?.length
? {
thinkingLevels: model.supported_reasoning_levels
.map((level) => level.effort)
.filter((effort): effort is string => Boolean(effort))
.map((effort) => ({
id: effort,
label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort)
})),
defaultThinkingLevel: model.default_reasoning_level ?? 'low'
}
: {})
}))
)
} catch {
return []
}
}
export function parseLineModels(stdout: string): CommitMessageModel[] {
return uniqueModels(
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.includes(' '))
.map((id) => ({
id,
label: labelFromModelId(id),
...withOpenAiThinking(id)
}))
)
}
export function parsePiModels(stdout: string): CommitMessageModel[] {
return uniqueModels(
stdout
.split(/\r?\n/)
.map((line) => line.trim().split(/\s+/))
.filter((parts) => parts.length >= 6 && parts[0] !== 'provider')
.map((parts) => {
const [provider, model, , , thinking] = parts
const id = `${provider}/${model}`
return {
id,
label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`,
...(thinking === 'yes'
? {
thinkingLevels: [
{ id: 'off', label: 'Off' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
defaultThinkingLevel: 'low'
}
: {})
}
})
)
}
export function parseCursorModels(stdout: string): CommitMessageModel[] {
return uniqueModels(
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.map((line) => /^([^\s]+)\s+-\s+(.+)$/.exec(line))
.filter((match): match is RegExpExecArray => Boolean(match))
.map((match) => ({
id: match[1],
label: match[2].replace(/\s+\((?:default|current)\)$/i, ''),
...withOpenAiThinking(match[1])
}))
)
}
export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageAgentSpec>> = {
claude: {
id: 'claude',
@@ -59,42 +218,32 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
'text',
'--model',
model,
'--permission-mode',
'plan',
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
],
modelSource: 'static',
models: [
{
// Why: Haiku 4.5 is a non-reasoning model — `claude --effort` rejects
// any value for it. Omit thinkingLevels so the UI hides the dropdown
// and the buildArgs path skips passing --effort entirely.
id: 'claude-haiku-4-5',
label: 'Haiku 4.5'
// Why: Claude Code aliases track the account/provider's supported
// model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex.
id: 'haiku',
label: 'Haiku'
},
{
id: 'claude-sonnet-4-6',
label: 'Sonnet 4.6',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
{ id: 'max', label: 'Max' }
],
id: 'sonnet',
label: 'Sonnet',
thinkingLevels: CLAUDE_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'claude-opus-4-7',
label: 'Opus 4.7',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
{ id: 'max', label: 'Max' }
],
id: 'opus',
label: 'Opus',
thinkingLevels: CLAUDE_THINKING_LEVELS,
defaultThinkingLevel: 'low'
}
],
defaultModelId: 'claude-opus-4-7'
defaultModelId: 'sonnet'
},
codex: {
id: 'codex',
@@ -116,51 +265,37 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
model,
...(thinkingLevel ? ['-c', `model_reasoning_effort=${thinkingLevel}`] : [])
],
modelSource: 'dynamic',
modelDiscovery: {
binary: 'codex',
args: ['debug', 'models'],
parse: parseCodexModels
},
// Why: ordered to match the official `codex` model picker — descending
// by version so the frontier model lands on top and legacy models trail.
models: [
{
id: 'gpt-5.5',
label: 'GPT-5.5',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.4',
label: 'GPT-5.4',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.4-mini',
label: 'GPT-5.4 Mini',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.3-codex',
label: 'GPT-5.3 Codex',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
@@ -170,27 +305,267 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
// tier, not the effort flag.
id: 'gpt-5.3-codex-spark',
label: 'GPT-5.3 Codex Spark',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.2',
label: 'GPT-5.2',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
}
],
defaultModelId: 'gpt-5.5'
},
opencode: {
id: 'opencode',
label: 'OpenCode',
binary: 'opencode',
promptDelivery: 'argv',
buildArgs: ({ prompt, model, thinkingLevel }) => [
'run',
'--model',
model,
'--agent',
'build',
'--format',
'default',
...(thinkingLevel ? ['--variant', thinkingLevel] : []),
prompt
],
modelSource: 'dynamic',
modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels },
models: [
{
// Why: OpenCode's hosted GPT models can require workspace billing even
// when `opencode models` lists them. This free model is available in
// discovery and works as a usable out-of-the-box default.
id: 'opencode/deepseek-v4-flash-free',
label: 'OpenCode DeepSeek V4 Flash Free'
},
{
id: 'opencode/gpt-5.4-mini',
label: 'OpenCode GPT 5.4 Mini',
...withOpenAiThinking('gpt-5.4-mini')
}
],
defaultModelId: 'opencode/deepseek-v4-flash-free'
},
pi: {
id: 'pi',
label: 'Pi',
binary: 'pi',
promptDelivery: 'stdin',
buildArgs: ({ model, thinkingLevel }) => [
'--print',
'--no-session',
'--no-tools',
'--no-extensions',
'--no-skills',
'--no-context-files',
'--mode',
'text',
'--model',
model,
...(thinkingLevel ? ['--thinking', thinkingLevel] : [])
],
modelSource: 'dynamic',
modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels },
models: [
{
// Why: Pi commonly authenticates through GitHub Copilot locally; using
// that provider avoids selecting a raw OpenAI model when no key exists.
id: 'github-copilot/gpt-5.4-mini',
label: 'Github Copilot GPT 5.4 Mini',
...withOpenAiThinking('gpt-5.4-mini')
}
],
defaultModelId: 'github-copilot/gpt-5.4-mini'
},
amp: {
id: 'amp',
label: 'Amp',
binary: 'amp',
promptDelivery: 'stdin',
buildArgs: ({ model, thinkingLevel }) => [
'--execute',
'--archive',
'--no-notifications',
'--no-ide',
'--no-jetbrains',
'--mode',
model,
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
],
modelSource: 'static',
models: [
{ id: 'smart', label: 'Smart' },
{ id: 'rush', label: 'Rush' },
{
id: 'large',
label: 'Large',
thinkingLevels: BASIC_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'deep',
label: 'Deep',
thinkingLevels: BASIC_THINKING_LEVELS,
defaultThinkingLevel: 'low'
}
],
defaultModelId: 'smart'
},
cursor: {
id: 'cursor',
label: 'Cursor',
binary: 'cursor-agent',
promptDelivery: 'argv',
buildArgs: ({ prompt, model }) => [
'--print',
'--mode',
'ask',
'--trust',
'--output-format',
'text',
'--model',
model,
prompt
],
modelSource: 'dynamic',
modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels },
models: [{ id: 'auto', label: 'Auto' }],
defaultModelId: 'auto'
},
kimi: {
id: 'kimi',
label: 'Kimi',
binary: 'kimi',
promptDelivery: 'stdin',
buildArgs: ({ model, thinkingLevel }) => [
'--print',
'--quiet',
...(model && model !== 'default' ? ['--model', model] : []),
...(thinkingLevel === 'on'
? ['--thinking']
: thinkingLevel === 'off'
? ['--no-thinking']
: [])
],
modelSource: 'static',
models: [
{ id: 'default', label: 'Config default' },
{
// Why: Kimi resolves its managed model by provider/model; bare model
// names are rejected by the CLI with "LLM not set".
id: 'kimi-code/kimi-for-coding',
label: 'Kimi K2.6',
thinkingLevels: [
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' }
],
defaultThinkingLevel: 'on'
}
],
defaultModelId: 'default'
},
copilot: {
id: 'copilot',
label: 'GitHub Copilot',
binary: 'copilot',
promptDelivery: 'argv',
buildArgs: ({ prompt, model, thinkingLevel }) => [
'--prompt',
prompt,
'--silent',
'--stream',
'off',
'--no-custom-instructions',
'--model',
model,
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
],
modelSource: 'static',
// Why: Copilot CLI's picker is policy-filtered per account/org. Keep the
// full hosted CLI catalog here so users can select models enabled for them.
models: [
{ id: 'auto', label: 'Auto' },
{
id: 'claude-haiku-4.5',
label: 'Claude Haiku 4.5'
},
{
id: 'claude-sonnet-4.5',
label: 'Claude Sonnet 4.5'
},
{
id: 'claude-sonnet-4.6',
label: 'Claude Sonnet 4.6'
},
{
id: 'claude-opus-4.5',
label: 'Claude Opus 4.5'
},
{
id: 'claude-opus-4.6',
label: 'Claude Opus 4.6'
},
{
id: 'claude-opus-4.6-fast',
label: 'Claude Opus 4.6 Fast'
},
{
id: 'claude-opus-4.7',
label: 'Claude Opus 4.7'
},
{
id: 'gpt-4.1',
label: 'GPT-4.1'
},
{
id: 'gpt-5-mini',
label: 'GPT-5 Mini',
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.2',
label: 'GPT-5.2',
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.2-codex',
label: 'GPT-5.2 Codex',
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.3-codex',
label: 'GPT-5.3 Codex',
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.4',
label: 'GPT-5.4',
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.4-mini',
label: 'GPT-5.4 Mini',
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.5',
label: 'GPT-5.5',
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
}
],
defaultModelId: 'gpt-5.4'
}
}
@@ -231,7 +606,16 @@ export function getCommitMessageModel(
agentId: TuiAgent,
modelId: string
): CommitMessageModel | undefined {
return getCommitMessageAgentSpec(agentId)?.models.find((m) => m.id === modelId)
const spec = getCommitMessageAgentSpec(agentId)
const model = spec?.models.find((m) => m.id === modelId)
if (model || !spec || spec.modelSource !== 'dynamic' || modelId.trim().length === 0) {
return model
}
return {
id: modelId,
label: labelFromModelId(modelId),
...withOpenAiThinking(modelId)
}
}
function toCommitMessageAgentCapability(
@@ -240,6 +624,7 @@ function toCommitMessageAgentCapability(
return {
id: spec.id,
label: spec.label,
modelSource: spec.modelSource,
defaultModelId: spec.defaultModelId,
// Why: renderer/settings should consume provider capabilities, not the
// spawn contract. Copy the model metadata so future dynamic probes can
+27
View File
@@ -0,0 +1,27 @@
export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local'
export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown'
export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:'
export function getCommitMessageModelDiscoveryHostKey(
connectionId: string | null | undefined
): string {
if (connectionId === undefined) {
return UNKNOWN_COMMIT_MESSAGE_HOST_KEY
}
return connectionId ? `ssh:${connectionId}` : LOCAL_COMMIT_MESSAGE_HOST_KEY
}
export function getCommitMessageModelDiscoveryHostKeyForScope(
scope: string | null | undefined
): string {
if (scope === undefined) {
return UNKNOWN_COMMIT_MESSAGE_HOST_KEY
}
if (!scope) {
return LOCAL_COMMIT_MESSAGE_HOST_KEY
}
if (scope.startsWith(RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX)) {
return scope
}
return getCommitMessageModelDiscoveryHostKey(scope)
}
+76 -3
View File
@@ -6,7 +6,7 @@ describe('planCommitMessageGeneration', () => {
const result = planCommitMessageGeneration(
{
agentId: 'claude',
model: 'claude-sonnet-4-6',
model: 'sonnet',
thinkingLevel: 'high'
},
'PROMPT'
@@ -16,13 +16,86 @@ describe('planCommitMessageGeneration', () => {
ok: true,
plan: {
binary: 'claude',
args: ['-p', '--output-format', 'text', '--model', 'claude-sonnet-4-6', '--effort', 'high'],
args: [
'-p',
'--output-format',
'text',
'--model',
'sonnet',
'--permission-mode',
'plan',
'--effort',
'high'
],
stdinPayload: 'PROMPT',
label: 'Claude'
}
})
})
it('plans OpenCode run with prompt in argv and model variant', () => {
const result = planCommitMessageGeneration(
{
agentId: 'opencode',
model: 'opencode/gpt-5.4-mini',
thinkingLevel: 'high'
},
'PROMPT'
)
expect(result).toEqual({
ok: true,
plan: {
binary: 'opencode',
args: [
'run',
'--model',
'opencode/gpt-5.4-mini',
'--agent',
'build',
'--format',
'default',
'--variant',
'high',
'PROMPT'
],
stdinPayload: null,
label: 'OpenCode'
}
})
})
it('allows discovered dynamic models that are not in the seed catalog', () => {
const result = planCommitMessageGeneration(
{
agentId: 'cursor',
model: 'gpt-5.2',
thinkingLevel: 'xhigh'
},
'PROMPT'
)
expect(result).toEqual({
ok: true,
plan: {
binary: 'cursor-agent',
args: [
'--print',
'--mode',
'ask',
'--trust',
'--output-format',
'text',
'--model',
'gpt-5.2',
'PROMPT'
],
stdinPayload: null,
label: 'Cursor'
}
})
})
it('plans Codex exec as non-interactive read-only generation with the prompt on stdin only', () => {
const result = planCommitMessageGeneration(
{
@@ -87,7 +160,7 @@ describe('planCommitMessageGeneration', () => {
const result = planCommitMessageGeneration(
{
agentId: 'claude',
model: 'claude-haiku-4-5',
model: 'haiku',
agentCommandOverride: 'claude "unterminated'
},
'PROMPT'
+3 -3
View File
@@ -33,7 +33,7 @@ export type CommitMessagePlanResult =
| { ok: true; plan: CommitMessagePlan }
| { ok: false; error: string }
function planAgentBinary(
export function planAgentBinary(
defaultBinary: string,
commandOverride: string | undefined
): { ok: true; binary: string; prefixArgs: string[] } | { ok: false; error: string } {
@@ -91,13 +91,13 @@ export function planCommitMessageGeneration(
return { ok: false, error: `Model "${input.model}" is not available for ${spec.label}.` }
}
if (input.thinkingLevel) {
if (!model.thinkingLevels) {
if (!model.thinkingLevels && spec.modelSource !== 'dynamic') {
return {
ok: false,
error: `Model "${model.label}" does not support a thinking effort level.`
}
}
if (!model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) {
if (model.thinkingLevels && !model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) {
return {
ok: false,
error: `Thinking level "${input.thinkingLevel}" is not valid for ${model.label}.`
+33
View File
@@ -73,6 +73,13 @@ describe('cleanGeneratedCommitMessage', () => {
expect(cleanGeneratedCommitMessage('feat: a\r\nbody line\r\n')).toBe('feat: a\nbody line')
})
it('strips a leading list marker from the commit subject', () => {
expect(cleanGeneratedCommitMessage('● Add Copilot entry to agent results')).toBe(
'Add Copilot entry to agent results'
)
expect(cleanGeneratedCommitMessage('1. Add numbered entry')).toBe('Add numbered entry')
})
it('returns empty string when input is whitespace', () => {
expect(cleanGeneratedCommitMessage(' \n\t')).toBe('')
})
@@ -111,6 +118,32 @@ describe('extractAgentErrorMessage', () => {
expect(extractAgentErrorMessage('Error: model unavailable\n', '')).toBe('model unavailable')
})
it('matches ANSI-colored `Error:` lines emitted by CLIs', () => {
expect(
extractAgentErrorMessage('', '\u001b[91m\u001b[1mError: \u001b[0mNo payment method\n')
).toBe('No payment method')
})
it('matches tool-specific `Error during ...:` lines', () => {
expect(
extractAgentErrorMessage(
'',
'Error during droid execution: Authentication failed. Please log into Factory.\n'
)
).toBe('Authentication failed. Please log into Factory.')
})
it('matches wrapped provider error-code payloads with quoted message fields', () => {
const stdout = [
"Error code: 401 - {'error': {'message': 'The API Key appears to be invalid or ma",
"y have expired. Please verify your credentials and try again.', 'type': 'invalid",
"_authentication_error'}}"
].join('\n')
expect(extractAgentErrorMessage(stdout, '')).toBe(
'The API Key appears to be invalid or may have expired. Please verify your credentials and try again.'
)
})
it('returns null when no ERROR line is present', () => {
expect(extractAgentErrorMessage('plain log\nmore log\n', '')).toBeNull()
})
+23 -2
View File
@@ -66,9 +66,17 @@ export function cleanGeneratedCommitMessage(raw: string): string {
text = fenced[1].trim()
}
// Why: some CLIs format a one-shot answer as a list item even when the
// prompt asks for raw text; a Git subject should not carry that marker.
text = text.replace(/^(\s*)(?:[-*•●]\s+|\d+[.)]\s+)/, '$1').trim()
return text
}
function stripAnsiControlSequences(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '')
}
export const CUSTOM_PROMPT_PLACEHOLDER = '{prompt}'
export type TokenizeCustomCommandResult =
@@ -195,7 +203,7 @@ export function planCustomCommand(template: string, prompt: string): CustomComma
// out the real message so the user sees something legible instead of a
// dump of the agent's runtime state.
export function extractAgentErrorMessage(stdout: string, stderr: string): string | null {
const combined = `${stdout}\n${stderr}`
const combined = stripAnsiControlSequences(`${stdout}\n${stderr}`)
const lines = combined.split(/\r?\n/)
// Pass 1: look for an `ERROR:`/`Error:` line carrying a JSON payload.
@@ -203,7 +211,7 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string
// error wins when an agent prints multiple.
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]
const match = /^\s*(?:ERROR|Error)\s*:\s*(.+)$/.exec(line)
const match = /^\s*(?:ERROR|Error(?:\s+during\s+[^:]+)?)\s*:\s*(.+)$/i.exec(line)
if (!match) {
continue
}
@@ -227,5 +235,18 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string
}
}
const compact = combined.replace(/([A-Za-z])\r?\n\s*([A-Za-z_])/g, '$1$2').replace(/\s+/g, ' ')
const errorCodeMatch = /\bError code:\s*\d+\s*-\s*(.+)$/i.exec(compact)
if (errorCodeMatch) {
const payload = errorCodeMatch[1].trim()
const messageMatch = /['"]message['"]\s*:\s*['"]([^'"]+)['"]/i.exec(payload)
if (messageMatch?.[1]?.trim()) {
return messageMatch[1].trim()
}
if (payload.length > 0) {
return payload
}
}
return null
}
+5 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: default persisted settings live in one schema-shaped object so migrations and tests compare against one source of truth. */
import type {
GlobalSettings,
NotificationSettings,
@@ -275,6 +276,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
enabled: true,
agentId: null,
selectedModelByAgent: {},
discoveredModelsByAgent: {},
selectedModelByAgentByHost: {},
discoveredModelsByAgentByHost: {},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: ''
@@ -334,7 +338,7 @@ export function getDefaultUIState(): PersistedUIState {
lastActiveWorktreeId: null,
sidebarWidth: 280,
rightSidebarWidth: 350,
groupBy: 'repo',
groupBy: 'workspace-status',
sortBy: 'recent',
showActiveOnly: false,
hideDefaultBranchWorkspace: false,
+2 -1
View File
@@ -26,7 +26,8 @@ export const RUNTIME_CAPABILITIES = [
'runtime.environments.v1',
'browser.screencast.v1',
'terminal.binary-stream.v1',
'terminal.multiplex.v1'
'terminal.multiplex.v1',
'workspace-ports.v1'
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
+61 -4
View File
@@ -625,6 +625,49 @@ export type PRCheckDetail = {
workflowRunId?: number
}
export type PRCheckAnnotation = {
path: string | null
startLine: number | null
endLine: number | null
annotationLevel: string | null
title: string | null
message: string
rawDetails: string | null
}
export type PRCheckStep = {
name: string
status: string | null
conclusion: string | null
startedAt: string | null
completedAt: string | null
}
export type PRCheckJob = {
name: string
status: string | null
conclusion: string | null
startedAt: string | null
completedAt: string | null
url: string | null
steps: PRCheckStep[]
}
export type PRCheckRunDetails = {
name: string
status: PRCheckDetail['status'] | string | null
conclusion: PRCheckDetail['conclusion'] | string | null
url: string | null
detailsUrl: string | null
startedAt: string | null
completedAt: string | null
title: string | null
summary: string | null
text: string | null
annotations: PRCheckAnnotation[]
jobs: PRCheckJob[]
}
export type GitHubRerunPRChecksResult = { ok: true; count: number } | { ok: false; error: string }
export type GitHubReactionContent =
@@ -873,10 +916,9 @@ export type LinearComment = {
export type GitHubIssueUpdate = {
state?: 'open' | 'closed'
title?: string
// Why: body writes are driven by the Project-mode slug-addressed path
// (`updateIssueBySlug`) because `gh issue edit` does not consistently
// cover every body-edit case the dialog needs; the repoPath-based
// `updateIssue` flow keeps ignoring `body` for backward compatibility.
// Why: body writes use the REST issue endpoint instead of `gh issue edit`
// because that command does not consistently cover every body-edit case the
// dialog needs.
body?: string
addLabels?: string[]
removeLabels?: string[]
@@ -1654,12 +1696,27 @@ export type GlobalSettings = {
voice?: VoiceSettings
}
export type CommitMessageAiModelCapability = {
id: string
label: string
thinkingLevels?: { id: string; label: string }[]
defaultThinkingLevel?: string
}
export type CommitMessageAiSettings = {
enabled: boolean
/** A TuiAgent id, the literal `'custom'` for a user-supplied command, or null. */
agentId: TuiAgent | 'custom' | null
/** Per-agent: switching agents preserves the previously-picked model. */
selectedModelByAgent: Partial<Record<TuiAgent, string>>
/** Host-scoped model selections; dynamic agents can expose different models per SSH target. */
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
/** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */
discoveredModelsByAgent?: Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>
/** Host-scoped dynamic model discovery cache. */
discoveredModelsByAgentByHost?: Partial<
Record<string, Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>>
>
/** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */
selectedThinkingByModel: Record<string, string>
/** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */
+64
View File
@@ -0,0 +1,64 @@
export type WorkspacePortProbe = {
id: string
repoId: string
displayName: string
path: string
}
export type WorkspacePortAttributionConfidence = 'cwd' | 'command' | 'none'
export type WorkspacePortOwner = {
worktreeId: string
repoId: string
displayName: string
path: string
confidence: WorkspacePortAttributionConfidence
}
type WorkspacePortBase = {
id: string
/** Address reported by the OS listener. May be a wildcard bind. */
bindHost: string
/** Address the renderer should copy/open. Wildcard binds are normalized to localhost. */
connectHost: string
port: number
pid?: number
processName?: string
protocol: 'http' | 'https' | 'unknown'
}
export type WorkspacePort =
| (WorkspacePortBase & {
kind: 'workspace'
owner: WorkspacePortOwner
})
| (WorkspacePortBase & {
kind: 'container'
})
| (WorkspacePortBase & {
kind: 'external'
})
export type WorkspacePortScanRequest = {
repoId?: string
}
export type WorkspacePortKillRequest = {
repoId?: string
pid: number
port: number
}
export type WorkspacePortKillResult =
| { ok: true }
| {
ok: false
reason: string
}
export type WorkspacePortScanResult = {
platform: NodeJS.Platform | 'unknown'
scannedAt: number
ports: WorkspacePort[]
unavailableReason?: string
}
+85 -6
View File
@@ -42,12 +42,7 @@ import {
import { pressShortcut } from './helpers/shortcuts'
async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise<void> {
const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] =
process.platform === 'win32' ? ['Control'] : []
await page
.locator('.xterm:visible')
.first()
.click({ button: 'right', position: { x: 40, y: 40 }, modifiers })
await openTerminalContextMenu(page)
await page.getByText('Set Title…', { exact: true }).click()
const titleInput = page.locator('.pane-title-input').first()
await expect(titleInput).toBeVisible()
@@ -63,6 +58,24 @@ async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise<
await expect(titleInput).toHaveCount(0)
}
async function openTerminalContextMenu(page: Page): Promise<void> {
const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = (await page.evaluate(() =>
navigator.userAgent.includes('Windows')
))
? ['Control']
: []
const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac'))
await page
.locator('.xterm:visible')
.first()
.click({
button: isMac ? 'left' : 'right',
position: { x: 40, y: 40 },
modifiers: isMac ? ['Control'] : modifiers
})
await expect(page.getByText('Set Title…', { exact: true })).toBeVisible()
}
async function getTabCustomTitle(
page: Page,
worktreeId: string,
@@ -211,6 +224,72 @@ test.describe('Terminal Panes', () => {
expect(activeLeafId).toMatch(UUID_RE)
})
test('first Set Title from terminal context menu stays open for typing', async ({ orcaPage }) => {
const title = `First menu title ${Date.now()}`
await openTerminalContextMenu(orcaPage)
await orcaPage.getByText('Set Title…', { exact: true }).click()
const titleInput = orcaPage.locator('.pane-title-input').first()
await expect(titleInput).toBeVisible()
await expect(titleInput).toBeFocused()
await orcaPage.waitForTimeout(250)
await expect(titleInput).toBeVisible()
await expect(titleInput).toBeFocused()
await titleInput.fill(title)
await titleInput.press('Enter')
await expect(titleInput).toHaveCount(0)
await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1)
})
test('Set Title survives an early blur during first focus handoff', async ({ orcaPage }) => {
await openTerminalContextMenu(orcaPage)
await orcaPage.evaluate(() => {
const blurOnFirstTitleFocus = (event: FocusEvent): void => {
const target = event.target
if (
!(target instanceof HTMLInputElement) ||
!target.classList.contains('pane-title-input')
) {
return
}
document.removeEventListener('focusin', blurOnFirstTitleFocus, true)
queueMicrotask(() => target.blur())
}
document.addEventListener('focusin', blurOnFirstTitleFocus, true)
})
await orcaPage.getByText('Set Title…', { exact: true }).click()
const titleInput = orcaPage.locator('.pane-title-input').first()
await expect(titleInput).toBeVisible()
await expect(titleInput).toBeFocused()
await orcaPage.waitForTimeout(250)
await expect(titleInput).toBeVisible()
await expect(titleInput).toBeFocused()
})
test('Set Title still commits by blur after focus settles', async ({ orcaPage }) => {
const title = `Blur commit title ${Date.now()}`
await openTerminalContextMenu(orcaPage)
await orcaPage.getByText('Set Title…', { exact: true }).click()
const titleInput = orcaPage.locator('.pane-title-input').first()
await expect(titleInput).toBeVisible()
await expect(titleInput).toBeFocused()
await orcaPage.waitForTimeout(100)
await titleInput.fill(title)
await orcaPage
.locator('.xterm:visible')
.first()
.click({ position: { x: 40, y: 60 } })
await expect(titleInput).toHaveCount(0)
await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1)
})
test('Set Title stays pane-local during agent title churn', async ({ orcaPage }) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
const tabId = (await getActiveTabId(orcaPage))!