mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/open-scroll
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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, ... })`.
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(':')
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')!
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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}.`
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.). */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))!
|
||||
|
||||
Reference in New Issue
Block a user