diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 4b88ac0a955..34ee93a6b12 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 = { connecting: 'Connecting', handshaking: 'Securing', @@ -2961,7 +2935,7 @@ export default function SessionScreen() { )} - {ACCESSORY_KEYS.map((key) => ( + {TERMINAL_ACCESSORY_KEYS.map((key) => ( [ diff --git a/mobile/src/terminal/terminal-accessory-keys.test.ts b/mobile/src/terminal/terminal-accessory-keys.test.ts new file mode 100644 index 00000000000..85a20122e69 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-keys.test.ts @@ -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)) + } + }) +}) diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts new file mode 100644 index 00000000000..e65b0b27fe0 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -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' } +] diff --git a/package.json b/package.json index 18f5967441a..76561417824 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 870c670a647..9d284513c9b 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -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, ... })`. diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 4c6e815c2a6..7501db48e64 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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([]) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 28ba7aabe95..699efd3217d 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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 { diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts new file mode 100644 index 00000000000..c9c666cc24a --- /dev/null +++ b/src/main/github/client-pr-check-details.test.ts @@ -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' } + ) + }) +}) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 588be53125d..4a4571aae8f 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 => 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 => 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 => 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 | 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 { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + if (!ownerRepo) { + return null + } + + await acquire() + try { + let checkRun: Record | 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 + 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) + : 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. */ diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index bc5a8e0a413..3c2c0a7109e 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -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' } + ) + }) }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index ca722e95245..272d61ea14d 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -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 diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index adfca251b45..6679e5cff26 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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 + 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', diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index cd818cb9e1c..1b4722d04a2 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 => { - 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 => { + 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 => { - 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 } } diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index c526a4c4938..d2e52730196 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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 ( diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 8b3c226a6d3..f8dbded70da 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7379623dfd9..6578c354660 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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 { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index a216eff558d..43e40ffd586 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -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) } } diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts new file mode 100644 index 00000000000..31896ed710d --- /dev/null +++ b/src/main/ipc/workspace-ports.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspacePort, WorkspacePortScanResult } from '../../shared/workspace-ports' + +const handlers = new Map Promise | 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((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' + } + } +} diff --git a/src/main/ipc/workspace-ports.ts b/src/main/ipc/workspace-ports.ts new file mode 100644 index 00000000000..2da362bc2dc --- /dev/null +++ b/src/main/ipc/workspace-ports.ts @@ -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>() + + ipcMain.handle( + 'workspacePorts:scan', + (_event, rawArgs?: unknown): Promise => { + 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 => { + 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 + } +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index fac25b82a91..b3f83758067 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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() }) diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts new file mode 100644 index 00000000000..be21fa7bc86 --- /dev/null +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -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) + }) +}) diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts new file mode 100644 index 00000000000..6c30c05211b --- /dev/null +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -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 { + 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, + 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 { + 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 { + 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 { + 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 { + 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() + 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): Promise> { + const result = new Map() + 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 { + 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): Promise> { + const result = new Map() + 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 +): Promise> { + const result = new Map() + 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 { + 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 +): 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(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() + 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(':') +} diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts new file mode 100644 index 00000000000..e0aae18218c --- /dev/null +++ b/src/main/ports/workspace-port-ownership.ts @@ -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, + 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 { + 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 { + return scanWorkspacePorts([...worktrees]) +} diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index c05c67676fa..b7263884b2d 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -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 + }) + ) + }) }) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index a82a2edc84e..4d92be7b6bc 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -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 -> +> & { + commitMessageDiscoveryHostKey?: string +} function normalizeRuntimeGitRelativePath(filePath: string): string { const relativePath = normalizeRuntimeRelativePath(filePath) @@ -333,15 +340,21 @@ export class RuntimeGitCommands { worktreeSelector: string, settingsOverride?: RuntimeCommitMessageSettingsOverride ): Promise { - 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 { - 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 + ): Promise { + 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) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0d42b34fd8d..bf6d999a01b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 82510a2b884..871990493c8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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>> { + 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>> { + 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 { + return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId)) + } + + async killWorkspacePort(args: WorkspacePortKillRequest): Promise { + 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 { + 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 diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 41f1207bcdb..db6b5cbce00 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -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({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 5a23d49f08b..60d2c9542d3 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -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' }) }) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 64e7c63c895..2fdc80f0141 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -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 } : {}) }) } diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 77fbe27da09..b33d4e66ee8 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index de831057f04..b53c220fd38 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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 ] diff --git a/src/main/runtime/rpc/methods/workspace-ports.test.ts b/src/main/runtime/rpc/methods/workspace-ports.test.ts new file mode 100644 index 00000000000..78465ada16b --- /dev/null +++ b/src/main/runtime/rpc/methods/workspace-ports.test.ts @@ -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 } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts new file mode 100644 index 00000000000..9a6ff82764b --- /dev/null +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -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 + }) + }) +] diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 34b37065754..6b69fd0e891 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -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 { } async function hasUncommittedChanges(repoPath: string): Promise { - 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 } diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts new file mode 100644 index 00000000000..96f50bcd044 --- /dev/null +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -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 + }) + }) +}) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index f4ba624b72f..4581168b562 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -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 { 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 } } diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index c18f91bad87..46f93831798 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -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 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 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 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 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( { diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 09949e28181..06817ab0b12 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -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>, + models = spec.models, + defaultModelId = spec.defaultModelId +): Extract { + return { + success: true, + capability: { + id: spec.id, + label: spec.label, + modelSource: spec.modelSource, + defaultModelId, + models + }, + models, + defaultModelId + } +} + +function finalizeModelDiscoveryOutput( + spec: NonNullable>, + 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>, + 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 { + 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, + agentCommandOverride?: string +): Promise { + 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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 919cf16104f..89cfa630318 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void } + workspacePorts: { + scan: (args: WorkspacePortScanRequest) => Promise + kill: (args: WorkspacePortKillRequest) => Promise + } pty: { spawn: (opts: { cols: number @@ -811,6 +826,15 @@ export type PreloadApi = { prRepo?: GitHubOwnerRepo | null noCache?: boolean }) => Promise + prCheckDetails: (args: { + repoPath: string + repoId?: string + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }) => Promise 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 0cca2e3040a..faf763b4197 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => + ipcRenderer.invoke('workspacePorts:scan', args), + kill: (args: WorkspacePortKillRequest): Promise => + ipcRenderer.invoke('workspacePorts:kill', args) + }, + pty: { spawn: (opts: { cols: number @@ -826,6 +839,16 @@ const api = { noCache?: boolean }): Promise => 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 => 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 => ipcRenderer.invoke('git:generateCommitMessage', args), + discoverCommitMessageModels: (args: { + agentId: string + worktreePath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), cancelGenerateCommitMessage: (args: { worktreePath: string connectionId?: string diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 9b7d28aab5d..7c7b1746d7d 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -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 diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index f9a89973992..6aa721c5428 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -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 diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index c710504e86b..d5bc1f071e2 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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 }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eee045cc52a..d6a2698a325 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { } > - {/* Why: leaf-mounted retention sync keeps agent-status retention + + {/* Why: leaf-mounted retention sync keeps agent-status retention subscriptions from re-rendering the App tree. */} - -
- {/* Why: the non-workspace titlebar lives inside this left+center + +
+ {/* 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. */} -
- {/* Why: in workspace view (split groups always enabled), the +
+ {/* 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 ? ( -
-
- {titlebarLeftControls} -
- {activeView === 'activity' ? ( - - ) : ( + {!workspaceActive ? ( +
- )} - {showTitlebarExpandButton && ( - - - - - - Collapse pane - - - )} - {/* 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} +
+ {activeView === 'activity' ? ( + + ) : ( +
+ )} + {showTitlebarExpandButton && ( + + + + + + Collapse pane + + + )} + {/* 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 &&
} -
- ) : null} -
- {showSidebar ? ( - workspaceActive ? ( - /* Why: left column wraps the sidebar with a titlebar-height + {isWindows &&
} +
+ ) : null} +
+ {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. */ -
- {titlebarLeftControls} -
-
- {/* Why: the workspace-view wrapper adds a fixed 36px header +
+ {titlebarLeftControls} +
+
+ {/* 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. */} - + +
-
- ) : ( - - ) - ) : null} -
- {/* Why: right sidebar toggle floats at the top-right of the center + ) : ( + + ) + ) : null} +
+ {/* 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 && ( -
- {rightSidebarToggle} + {workspaceActive && !rightSidebarOpen && ( +
+ {rightSidebarToggle} +
+ )} +
+
+ +
+ + {activeView === 'settings' ? : null} + {activeView === 'skills' ? : null} + {activeView === 'tasks' ? : null} + {activeView === 'automations' ? : null} + {activeView === 'activity' ? : null} + {activeView === 'space' ? : null} + {activeView === 'terminal' && !activeWorktreeId ? : null} +
- )} -
-
- -
- - {activeView === 'settings' ? : null} - {activeView === 'skills' ? : null} - {activeView === 'tasks' ? : null} - {activeView === 'automations' ? : null} - {activeView === 'activity' ? : null} - {activeView === 'space' ? : null} - {activeView === 'terminal' && !activeWorktreeId ? : null} - + {showFloatingTerminalButton ? ( + setFloatingTerminalOpenWithFocus((open) => !open)} + /> + ) : null}
- {showFloatingTerminalButton ? ( - setFloatingTerminalOpenWithFocus((open) => !open)} - /> - ) : null}
-
- {/* 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 ? : null} -
- {floatingTerminalEnabled ? ( - - ) : null} - - {/* Why: root overlays can render Radix s; keep them inside + {showRightSidebarControls ? : null} +
+ {floatingTerminalEnabled ? ( + + ) : null} + + {/* Why: root overlays can render Radix s; keep them inside the shared provider so lazy surfaces mount safely from any entry point. */} - - {mountedLazyModalIds.has('new-workspace-composer') ? : null} - {mountedLazyModalIds.has('workspace-cleanup') ? : null} - - - {mountedLazyModalIds.has('quick-open') ? : null} - {mountedLazyModalIds.has('worktree-palette') ? : null} - {mountedLazyModalIds.has('feature-wall') ? : null} - {mountedLazyModalIds.has('feature-tips') ? : null} - - {/* Why: mount PetOverlay only when the experimental flag is on AND + + {mountedLazyModalIds.has('new-workspace-composer') ? ( + + ) : null} + {mountedLazyModalIds.has('workspace-cleanup') ? : null} + + + {mountedLazyModalIds.has('quick-open') ? : null} + {mountedLazyModalIds.has('worktree-palette') ? : null} + {mountedLazyModalIds.has('feature-wall') ? : null} + {mountedLazyModalIds.has('feature-tips') ? : null} + + {/* 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 ? ( - - - - ) : null} - - - - {/* Why: the existing-user opt-in banner mounts at App root so it + {petEnabled && petVisible ? ( + + + + ) : null} + + + + {/* 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. */} - - - - - - {onboarding && shouldShowOnboarding(onboarding) && !onboardingSettingsDetour ? ( - - - - ) : null} - - + + + + + + {onboarding && shouldShowOnboarding(onboarding) && !onboardingSettingsDetour ? ( + + + + ) : null} + + + {/* Why: rendered last so it sits after all -webkit-app-region:drag elements diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index a0635b7fa6c..05805390b96 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -27,20 +27,25 @@ import { FolderOpen, GitMerge, GitPullRequest, + GitPullRequestClosed, LayoutList, + ListChecks, LoaderCircle, MessageSquare, MessageSquarePlus, + Pencil, RefreshCw, Send, UndoDot, Users, + Wrench, X } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' +import { useConfirmationDialog } from '@/components/confirmation-dialog' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import { VisuallyHidden } from 'radix-ui' import { @@ -51,7 +56,7 @@ import { } from '@/components/ui/accordion' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { DropdownMenu, DropdownMenuContent, @@ -87,10 +92,15 @@ import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/u import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { - appendGitHubPRRequestedReviewers, getGitHubPRReviewerRows, normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' +import { AGENT_CATALOG } from '@/lib/agent-catalog' +import { getConnectionId } from '@/lib/connection-context' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { GitHubOwnerRepo, GitHubPRFile, @@ -101,10 +111,15 @@ import type { GitHubAssignableUser, GitHubReaction, PRCheckDetail, - PRComment + PRCheckRunDetails, + PRComment, + TuiAgent, + Worktree } from '../../../shared/types' import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' +const IS_MAC = navigator.userAgent.includes('Mac') + // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. // Parsing the canonical `https://github.com/{owner}/{repo}/...` URL is the @@ -132,7 +147,7 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { const DiffViewer = lazy(() => import('@/components/editor/DiffViewer')) const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) -type ItemDialogTab = 'conversation' | 'files' +export type ItemDialogTab = 'conversation' | 'checks' | 'files' type MentionOption = { login: string @@ -161,6 +176,16 @@ const REACTION_EMOJI: Record = { eyes: '👀' } +function normalizeItemDialogTab( + item: GitHubWorkItem | null, + tab: ItemDialogTab | undefined +): ItemDialogTab { + if (item?.type !== 'pr') { + return 'conversation' + } + return tab ?? 'conversation' +} + /** Why: Project-origin rows don't always belong to the active local repo. * When set, GHEditSection routes label/assignee/state mutations through * slug-addressed IPCs against `owner`/`repo` instead of through `repoPath`, @@ -182,6 +207,7 @@ type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null repoId?: string | null + initialTab?: ItemDialogTab /** Called when the user clicks the primary CTA to start work from this item. */ onUse: (item: GitHubWorkItem) => void onReviewRequestsChange?: ( @@ -399,6 +425,45 @@ function ReviewerAvatar({ ) } +function mergeReviewerSuggestions( + users: GitHubAssignableUser[], + seedUsers: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of [...seedUsers, ...users]) { + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (!existing) { + byLogin.set(key, user) + continue + } + if (!existing.avatarUrl && user.avatarUrl) { + byLogin.set(key, { ...existing, avatarUrl: user.avatarUrl }) + } + } + return Array.from(byLogin.values()).sort((a, b) => a.login.localeCompare(b.login)) +} + +function buildRequestedReviewUsers( + logins: string[], + candidates: GitHubAssignableUser[], + existingRequests: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of existingRequests) { + byLogin.set(user.login.toLowerCase(), user) + } + const candidatesByLogin = new Map(candidates.map((user) => [user.login.toLowerCase(), user])) + for (const login of logins) { + const key = login.toLowerCase() + if (byLogin.has(key)) { + continue + } + byLogin.set(key, candidatesByLogin.get(key) ?? { login, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) +} + function PRReviewersPanel({ item, loading, @@ -410,19 +475,73 @@ function PRReviewersPanel({ repoPath: string | null onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { + const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) + const [activeReviewerIndex, setActiveReviewerIndex] = useState(0) const [submitting, setSubmitting] = useState(false) const [localReviewRequests, setLocalReviewRequests] = useState( () => item.reviewRequests ?? [] ) const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const settings = useAppStore((s) => s.settings) + const reviewerInputRef = useRef(null) useEffect(() => { setLocalReviewRequests(item.reviewRequests ?? []) }, [item.id, item.reviewRequests]) + const reviewerSeedUsers = useMemo(() => { + const byLogin = new Map() + const add = (user: GitHubAssignableUser): void => { + if (!user.login) { + return + } + byLogin.set(user.login.toLowerCase(), user) + } + for (const user of localReviewRequests) { + add(user) + } + for (const review of item.latestReviews ?? []) { + add({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? '' + }) + } + if (item.author) { + add({ login: item.author, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) + }, [item.author, item.latestReviews, localReviewRequests]) + + const reviewSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const reviewerMetadataBySlug = useRepoAssigneesBySlug( + open && reviewSlug ? reviewSlug.owner : null, + open && reviewSlug ? reviewSlug.repo : null, + reviewerSeedUsers.map((user) => user.login), + settings + ) + const reviewerMetadataByPath = useRepoAssignees( + open && !reviewSlug ? repoPath : null, + open && !reviewSlug ? item.repoId : null + ) + const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } const reviewers = getGitHubPRReviewerRows(displayItem) + const authorLogin = item.author?.toLowerCase() ?? null + const reviewerCandidates = useMemo( + () => + mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( + (user) => user.login.toLowerCase() !== authorLogin + ), + [authorLogin, reviewerMetadata.data, reviewerSeedUsers] + ) + const reviewerCandidatesByLogin = useMemo( + () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), + [reviewerCandidates] + ) const selectedReviewerLogins = useMemo( () => new Set( @@ -430,21 +549,94 @@ function PRReviewersPanel({ ), [localReviewRequests] ) + const reviewerQuery = reviewerInput.trim().replace(/^@/, '').toLowerCase() + const filteredReviewerCandidates = useMemo(() => { + const query = reviewerQuery + return reviewerCandidates + .filter((user) => { + const login = user.login.toLowerCase() + return ( + query.length === 0 || + login.includes(query) || + (user.name ?? '').toLowerCase().includes(query) + ) + }) + .sort((a, b) => { + const aLogin = a.login.toLowerCase() + const bLogin = b.login.toLowerCase() + const aStarts = aLogin.startsWith(query) + const bStarts = bLogin.startsWith(query) + if (aStarts !== bStarts) { + return aStarts ? -1 : 1 + } + return a.login.localeCompare(b.login) + }) + }, [reviewerCandidates, reviewerQuery]) + const suggestedReviewerRows = useMemo( + () => + reviewerQuery.length === 0 + ? reviewerSeedUsers + .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) + .filter((user) => user.login.toLowerCase() !== authorLogin) + .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) + .slice(0, 1) + : [], + [ + authorLogin, + reviewerCandidatesByLogin, + reviewerQuery.length, + reviewerSeedUsers, + selectedReviewerLogins + ] + ) + const everyoneElseReviewerRows = useMemo(() => { + const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) + return filteredReviewerCandidates.filter( + (user) => !suggestedLogins.has(user.login.toLowerCase()) + ) + }, [filteredReviewerCandidates, suggestedReviewerRows]) + const actionableReviewerRows = useMemo( + () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], + [everyoneElseReviewerRows, suggestedReviewerRows] + ) + + useEffect(() => { + setActiveReviewerIndex(0) + }, [reviewerQuery, actionableReviewerRows.length]) + const hasReviewerMetadata = item.reviewDecision !== undefined || localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(useAppStore.getState().settings).kind === 'environment' + const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment' - const handleRequestReview = async (event: React.FormEvent): Promise => { - event.preventDefault() + const measureReviewerPickerPlacement = useCallback(() => { + const rect = reviewerInputRef.current?.getBoundingClientRect() + if (!rect) { + setReviewerPickerSide('bottom') + setReviewerPickerMaxHeight(null) + return + } + + const gap = 8 + const minUsefulHeight = 180 + const availableBelow = window.innerHeight - rect.bottom - gap + const availableAbove = rect.top - gap + const nextSide = + availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' + const available = nextSide === 'top' ? availableAbove : availableBelow + + setReviewerPickerSide(nextSide) + setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) + }, []) + + const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return } const logins = normalizeGitHubReviewerLogins( - reviewerInput.split(/[\s,]+/), + requestedLogins ?? reviewerInput.split(/[\s,]+/), selectedReviewerLogins ) if (logins.length === 0) { @@ -455,7 +647,7 @@ function PRReviewersPanel({ toast.error('You can request up to 15 reviewers') return } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment' && !repoPath) { toast.error('No repo context available for this pull request.') return @@ -480,7 +672,11 @@ function PRReviewersPanel({ toast.error(result.error ?? 'Failed to request reviewer') return } - const nextReviewRequests = appendGitHubPRRequestedReviewers(localReviewRequests, logins) + const nextReviewRequests = buildRequestedReviewUsers( + logins, + reviewerCandidates, + localReviewRequests + ) setLocalReviewRequests(nextReviewRequests) patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) onReviewersRequested(nextReviewRequests) @@ -493,6 +689,132 @@ function PRReviewersPanel({ } } + const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { + if (submitting) { + return + } + const selected = new Set(localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())) + const logins = reviewersToRemove + .map((reviewer) => reviewer.trim().replace(/^@/, '')) + .filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase())) + if (logins.length === 0) { + return + } + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment' && !repoPath) { + toast.error('No repo context available for this pull request.') + return + } + setSubmitting(true) + try { + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.removePRReviewers', + { repo: item.repoId, prNumber: item.number, reviewers: logins }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.removePRReviewers({ + repoPath: repoPath ?? '', + repoId: item.repoId, + prNumber: item.number, + reviewers: logins + }) + if (!result.ok) { + toast.error(result.error ?? 'Failed to remove reviewer') + return + } + const removed = new Set(logins.map((login) => login.toLowerCase())) + const nextReviewRequests = localReviewRequests.filter( + (reviewer) => !removed.has(reviewer.login.toLowerCase()) + ) + setLocalReviewRequests(nextReviewRequests) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + onReviewersRequested(nextReviewRequests) + setReviewerInput('') + toast.success(logins.length === 1 ? 'Reviewer removed' : 'Reviewers removed') + } catch { + toast.error('Failed to remove reviewer') + } finally { + setSubmitting(false) + } + } + + const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { + await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) + ? handleRemoveReviewers([reviewer.login]) + : handleRequestReview([reviewer.login])) + requestAnimationFrame(() => reviewerInputRef.current?.focus()) + } + + const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + measureReviewerPickerPlacement() + } + setOpen(nextOpen) + if (nextOpen) { + requestAnimationFrame(() => reviewerInputRef.current?.focus()) + return + } + setReviewerInput('') + } + + const renderReviewerPickerRow = ( + reviewer: GitHubAssignableUser, + options: { suggested: boolean; activeIndex: number } + ): React.JSX.Element => { + const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login + return ( + + ) + } + return ( ) @@ -960,6 +1407,19 @@ function patchCachedPRReviewRequests( }) } +function patchCachedWorkItemBody(cacheKey: string, body: string): void { + const prev = workItemDetailsCache.get(cacheKey) + if (!prev?.details) { + return + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { ...prev.details, body }, + fetchedAt: Date.now(), + error: undefined + }) +} + // Why: install once at module load — every dialog instance shares the cache, // so a single subscription is enough. The preload bridge re-emits the // main-process broadcast for every window, so each renderer invalidates its @@ -1693,14 +2153,15 @@ function ConversationTab({ headSha, baseSha, loading, + detailsLoaded, checks, participants: detailsParticipants, localState, onStateChange, projectOrigin, - onUse, onMutated, onChecksUpdated, + onBodyUpdated, onCommentAdded, onReviewersRequested }: { @@ -1713,20 +2174,25 @@ function ConversationTab({ headSha: string | undefined baseSha: string | undefined loading: boolean + detailsLoaded: boolean checks: GitHubWorkItemDetails['checks'] participants: GitHubAssignableUser[] localState: GitHubWorkItem['state'] onStateChange: (state: GitHubWorkItem['state']) => void projectOrigin: GitHubItemDialogProjectOrigin | undefined - onUse: (item: GitHubWorkItem) => void onMutated: () => void onChecksUpdated: (checks: PRCheckDetail[]) => void + onBodyUpdated: (body: string) => void onCommentAdded: (comment: PRComment) => void onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const authorLabel = item.author ?? 'unknown' const [replyingTo, setReplyingTo] = useState(null) const [commentFilter, setCommentFilter] = useState('all') + const [bodyDraft, setBodyDraft] = useState(body) + const [bodyEditing, setBodyEditing] = useState(false) + const [bodySaving, setBodySaving] = useState(false) + const bodyTextareaRef = useRef(null) const repoAssignees = useRepoAssignees(repoPath, item.repoId) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( @@ -1751,6 +2217,51 @@ function ConversationTab({ } }, [replyingTo, visibleComments]) + useEffect(() => { + if (!bodyEditing) { + setBodyDraft(body) + } + }, [body, bodyEditing, item.id]) + + useEffect(() => { + if (bodyEditing) { + requestAnimationFrame(() => bodyTextareaRef.current?.focus()) + } + }, [bodyEditing]) + + const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const markdownGitHubRepo = useMemo( + () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), + [bodySlug, projectOrigin] + ) + const canEditBody = + item.type === 'pr' ? Boolean(projectOrigin || bodySlug) : Boolean(projectOrigin || repoPath) + const bodyChanged = bodyDraft !== body + + const handleSaveBody = useCallback(async (): Promise => { + if (bodySaving || !bodyChanged) { + setBodyEditing(false) + return + } + setBodySaving(true) + try { + await runWorkItemBodyUpdate({ + item, + repoPath, + projectOrigin, + body: bodyDraft, + parsedSlug: bodySlug + }) + onBodyUpdated(bodyDraft) + setBodyEditing(false) + toast.success('Description updated.') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update description.') + } finally { + setBodySaving(false) + } + }, [bodyChanged, bodyDraft, bodySaving, bodySlug, item, onBodyUpdated, projectOrigin, repoPath]) + const handleReply = useCallback( async (comment: PRComment, replyBody: string): Promise => { if (!repoPath) { @@ -1789,21 +2300,9 @@ function ConversationTab({ [item.number, item.repoId, item.type, onCommentAdded, repoPath] ) - const startWorkspaceButton = ( - - ) - const rightPanel = item.type === 'pr' ? (
- {startWorkspaceButton} - ) @@ -2365,6 +2965,176 @@ function CommentReplyForm({ ) } +const CHECK_SORT_ORDER: Record = { + failure: 0, + timed_out: 0, + cancelled: 1, + pending: 2, + neutral: 3, + skipped: 4, + success: 5 +} + +function getCheckConclusion(check: PRCheckDetail): NonNullable { + return check.conclusion ?? 'pending' +} + +function getCheckStatusLabel(check: PRCheckDetail): string { + const conclusion = getCheckConclusion(check) + if (conclusion === 'success') { + return 'Successful' + } + if (conclusion === 'failure') { + return 'Failed' + } + if (conclusion === 'cancelled') { + return 'Cancelled' + } + if (conclusion === 'timed_out') { + return 'Timed out' + } + if (conclusion === 'neutral') { + return 'Neutral' + } + if (conclusion === 'skipped') { + return 'Skipped' + } + if (check.status === 'queued') { + return 'Queued' + } + if (check.status === 'in_progress') { + return 'In progress' + } + return 'Pending' +} + +function getCheckCounts(checks: PRCheckDetail[]): { + passing: number + failing: number + pending: number + skipped: number + neutral: number +} { + return checks.reduce( + (counts, check) => { + const conclusion = getCheckConclusion(check) + if (conclusion === 'success') { + counts.passing += 1 + } else if (['failure', 'cancelled', 'timed_out'].includes(conclusion)) { + counts.failing += 1 + } else if (conclusion === 'skipped') { + counts.skipped += 1 + } else if (conclusion === 'neutral') { + counts.neutral += 1 + } else { + counts.pending += 1 + } + return counts + }, + { passing: 0, failing: 0, pending: 0, skipped: 0, neutral: 0 } + ) +} + +function getChecksSummaryLabel(checks: PRCheckDetail[]): string { + const counts = getCheckCounts(checks) + if (checks.length === 0) { + return 'No checks found' + } + if (counts.failing > 0) { + return `${counts.failing} ${counts.failing === 1 ? 'check' : 'checks'} failing` + } + if (counts.pending > 0) { + return `${counts.pending} ${counts.pending === 1 ? 'check' : 'checks'} pending` + } + if (counts.passing === checks.length) { + return 'All checks passing' + } + return `${counts.passing} of ${checks.length} checks passing` +} + +function getBrokenChecks(checks: PRCheckDetail[]): PRCheckDetail[] { + return checks.filter((check) => + ['failure', 'cancelled', 'timed_out'].includes(getCheckConclusion(check)) + ) +} + +function buildFixBrokenChecksPrompt(item: GitHubWorkItem, checks: PRCheckDetail[]): string { + const brokenChecks = getBrokenChecks(checks) + const checkLines = + brokenChecks.length > 0 + ? brokenChecks.map((check) => { + const details = [ + getCheckStatusLabel(check), + check.checkRunId ? `check run ${check.checkRunId}` : null, + check.workflowRunId ? `workflow run ${check.workflowRunId}` : null, + check.url ? `details: ${check.url}` : null + ] + .filter(Boolean) + .join(', ') + return `- ${check.name}${details ? ` (${details})` : ''}` + }) + : ['- No failing check is currently listed; refresh PR checks first, then inspect CI.'] + + return [ + `Fix the broken checks for PR #${item.number}: ${item.title}`, + `PR: ${item.url}`, + '', + 'Broken checks:', + ...checkLines, + '', + 'Focus only on making the failing checks pass. Inspect the CI output first, make the smallest correct code or test changes, and do not work on unrelated cleanup.' + ].join('\n') +} + +function findWorkspaceAttachedToPR( + worktrees: Worktree[], + repoId: string, + prNumber: number +): Worktree | null { + return ( + worktrees.find( + (worktree) => + worktree.repoId === repoId && worktree.linkedPR === prNumber && !worktree.isArchived + ) ?? null + ) +} + +function pickDefaultAgent( + defaultAgent: TuiAgent | 'blank' | null | undefined, + detectedAgents: TuiAgent[] +): TuiAgent | null { + if (defaultAgent && defaultAgent !== 'blank' && detectedAgents.includes(defaultAgent)) { + return defaultAgent + } + return AGENT_CATALOG.find((entry) => detectedAgents.includes(entry.id))?.id ?? null +} + +type CheckDetailsLoadState = { + loading: boolean + details: PRCheckRunDetails | null + error: string | null +} + +function getCheckDetailsKey(check: PRCheckDetail): string { + return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name) +} + +function formatCheckTimestamp(input: string | null | undefined): string | null { + if (!input) { + return null + } + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return null + } + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + function ChecksTab({ item, repoPath, @@ -2372,6 +3142,7 @@ function ChecksTab({ headSha, checks, loading, + variant = 'compact', onChecksUpdated }: { item: GitHubWorkItem @@ -2380,18 +3151,49 @@ function ChecksTab({ headSha: string | undefined checks: GitHubWorkItemDetails['checks'] loading: boolean + variant?: 'compact' | 'page' onChecksUpdated: (checks: PRCheckDetail[]) => void }): React.JSX.Element { const [localChecks, setLocalChecks] = useState(null) const [refreshing, setRefreshing] = useState(false) const [rerunning, setRerunning] = useState(false) - const list = localChecks ?? checks ?? [] - const failedChecks = list.filter((check) => - ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '') + const [fixingChecks, setFixingChecks] = useState(false) + const [expandedCheckKey, setExpandedCheckKey] = useState(null) + const [detailsByCheckKey, setDetailsByCheckKey] = useState>( + {} ) + const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) + const prRepo = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const sorted = [...list].sort( + (a, b) => + (CHECK_SORT_ORDER[getCheckConclusion(a)] ?? 3) - + (CHECK_SORT_ORDER[getCheckConclusion(b)] ?? 3) + ) + const failedChecks = getBrokenChecks(list) + const counts = getCheckCounts(list) + const summaryLabel = getChecksSummaryLabel(list) + const SummaryIcon = + counts.failing > 0 + ? CHECK_ICON.failure + : counts.pending > 0 + ? CHECK_ICON.pending + : list.length > 0 + ? CHECK_ICON.success + : CircleDashed + const summaryColor = + counts.failing > 0 + ? CHECK_COLOR.failure + : counts.pending > 0 + ? CHECK_COLOR.pending + : list.length > 0 + ? CHECK_COLOR.success + : 'text-muted-foreground' + const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) useEffect(() => { setLocalChecks(null) + setExpandedCheckKey(null) + setDetailsByCheckKey({}) }, [checks]) const handleRefresh = useCallback(async (): Promise => { @@ -2448,36 +3250,189 @@ function ChecksTab({ [handleRefresh, headSha, item.number, rerunning, repoId, repoPath] ) - const toolbar = ( -
+ const handleFixBrokenChecks = useCallback(async (): Promise => { + const targetRepoId = repoId ?? item.repoId + if (!targetRepoId || fixingChecks) { + return + } + if (failedChecks.length === 0) { + toast.message('No broken checks to fix.') + return + } + + setFixingChecks(true) + try { + const prompt = buildFixBrokenChecksPrompt(item, list) + const store = useAppStore.getState() + const attachedWorkspace = findWorkspaceAttachedToPR( + store.allWorktrees(), + targetRepoId, + item.number + ) + + if (!attachedWorkspace) { + await launchWorkItemDirect({ + item: { ...item, pasteContent: prompt }, + repoId: targetRepoId, + launchSource: 'task_page', + telemetrySource: 'sidebar', + openModalFallback: () => { + toast.error('Unable to create a fix workspace automatically.') + } + }) + return + } + + if (!activateAndRevealWorktree(attachedWorkspace.id)) { + toast.error('Unable to open the workspace attached to this pull request.') + return + } + + const connectionId = getConnectionId(attachedWorkspace.id) + if (connectionId === undefined) { + toast.error('Unable to resolve the workspace connection.') + return + } + + const activeStore = useAppStore.getState() + const detectedAgents = + typeof connectionId === 'string' + ? await activeStore.ensureRemoteDetectedAgents(connectionId) + : await activeStore.ensureDetectedAgents() + const agent = pickDefaultAgent(activeStore.settings?.defaultTuiAgent, detectedAgents) + if (!agent) { + toast.error('No AI agents detected. Configure a default agent in Settings.') + return + } + + const result = launchAgentInNewTab({ + agent, + worktreeId: attachedWorkspace.id, + prompt, + promptDelivery: 'draft', + launchSource: 'task_page' + }) + if (!result) { + toast.error('Could not build the agent launch command.') + return + } + focusTerminalTabSurface(result.tabId) + toast.success('Started an AI agent for the broken checks.') + } finally { + setFixingChecks(false) + } + }, [failedChecks.length, fixingChecks, item, list, repoId]) + + const handleToggleCheckDetails = useCallback( + (check: PRCheckDetail): void => { + const key = getCheckDetailsKey(check) + setExpandedCheckKey((current) => (current === key ? null : key)) + if ( + !repoPath || + detailsByCheckKey[key] || + (!check.checkRunId && !check.workflowRunId && !check.url) + ) { + return + } + setDetailsByCheckKey((current) => ({ + ...current, + [key]: { loading: true, details: null, error: null } + })) + void window.api.gh + .prCheckDetails({ + repoPath, + repoId: repoId ?? undefined, + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo + }) + .then((details) => { + setDetailsByCheckKey((current) => ({ + ...current, + [key]: { + loading: false, + details, + error: details ? null : 'No inline details are available for this check.' + } + })) + }) + .catch((err) => { + setDetailsByCheckKey((current) => ({ + ...current, + [key]: { + loading: false, + details: null, + error: err instanceof Error ? err.message : 'Failed to load check details.' + } + })) + }) + }, + [detailsByCheckKey, prRepo, repoId, repoPath] + ) + + const refreshAction = ( + + + + + + Refresh checks + + + ) + const fixBrokenChecksAction = + failedChecks.length > 0 || fixingChecks ? ( - Refresh checks + Start the default AI agent on these checks + ) : null + const rerunAction = + list.length > 0 || rerunning ? ( @@ -2496,13 +3451,271 @@ function ChecksTab({ + ) : null + const secondaryActions = + variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction || + rerunAction ? ( +
+ {fixBrokenChecksAction} + {variant === 'page' ? rerunAction : null} +
+ ) : null + const actions = ( +
+ {refreshAction} + {fixBrokenChecksAction} + {rerunAction}
) + const compactHeader = ( +
+
+
+ 0 && counts.failing === 0 && 'animate-spin' + )} + /> +
+
Checks
+ {list.length > 0 && ( +
+ {summaryLabel} +
+ )} +
+
+
+ {refreshAction} + {list.length > 0 && ( +
+ {rerunAction} +
+ )} +
+
+ {secondaryActions ? ( +
{secondaryActions}
+ ) : null} +
+ ) + + const renderCheckRow = (check: PRCheckDetail): React.JSX.Element => { + const conclusion = getCheckConclusion(check) + const Icon = CHECK_ICON[conclusion] ?? CircleDashed + const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' + const statusLabel = getCheckStatusLabel(check) + const key = getCheckDetailsKey(check) + const expanded = expandedCheckKey === key + const detailsState = detailsByCheckKey[key] + return ( +
+ + {expanded && renderCheckDetails(check, detailsState)} +
+ ) + } + + const renderCheckDetails = ( + check: PRCheckDetail, + state: CheckDetailsLoadState | undefined + ): React.JSX.Element => { + const details = state?.details + const openUrl = details?.detailsUrl ?? details?.url ?? check.url + const startedAt = formatCheckTimestamp(details?.startedAt) + const completedAt = formatCheckTimestamp(details?.completedAt) + const detailsStatusCheck: PRCheckDetail = { + ...check, + status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status, + conclusion: + (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion + } + const hasOutput = Boolean(details?.title || details?.summary || details?.text) + const hasAnnotations = (details?.annotations.length ?? 0) > 0 + const hasJobs = (details?.jobs.length ?? 0) > 0 + + return ( +
+ {state?.loading ? ( +
+ + Loading check details… +
+ ) : ( +
+
+ + Status:{' '} + {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} + + {startedAt && Started {startedAt}} + {completedAt && Completed {completedAt}} + {check.checkRunId && check #{check.checkRunId}} +
+ + {state?.error &&
{state.error}
} + + {hasOutput && ( +
+ {details?.title && ( +
+ {details.title} +
+ )} + {details?.summary && ( + + )} + {details?.text && ( + + )} +
+ )} + + {hasAnnotations && ( +
+
+ Annotations +
+
+ {details!.annotations.map((annotation, index) => ( +
0 && 'border-t border-border/30' + )} + > +
+ + {annotation.path ?? 'Annotation'} + {annotation.startLine ? `:${annotation.startLine}` : ''} + + {annotation.annotationLevel && ( + + {annotation.annotationLevel} + + )} +
+ {annotation.title && ( +
+ {annotation.title} +
+ )} +
+ {annotation.message} +
+ {annotation.rawDetails && ( +
+                          {annotation.rawDetails}
+                        
+ )} +
+ ))} +
+
+ )} + + {hasJobs && ( +
+
+ Jobs +
+
+ {details!.jobs.map((job, index) => ( +
0 && 'border-t border-border/30' + )} + > +
+ + {job.name} + + + {job.conclusion ?? job.status ?? 'unknown'} + +
+ {job.steps.length > 0 && ( +
+ {job.steps.map((step) => ( +
+ {step.name} + {step.conclusion ?? step.status} +
+ ))} +
+ )} +
+ ))} +
+
+ )} + + {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( +
+ No inline output is available for this check. +
+ )} + + {openUrl && ( +
+ +
+ )} +
+ )} +
+ ) + } if (loading && list.length === 0) { return ( <> - {toolbar} + {variant === 'compact' ? compactHeader : null}
@@ -2510,50 +3723,93 @@ function ChecksTab({ ) } if (list.length === 0) { + if (variant === 'page') { + return ( +
+
+ +
+ + No checks found + + + This pull request has no reported checks yet. + +
+ {actions} +
+
+ ) + } return ( <> - {toolbar} -
- No checks found. + {compactHeader} +
+ +
No checks reported yet
) } + if (variant === 'page') { + const countChips: { label: string; className: string }[] = [] + if (counts.passing > 0) { + countChips.push({ label: `${counts.passing} passing`, className: CHECK_COLOR.success }) + } + if (counts.failing > 0) { + countChips.push({ label: `${counts.failing} failing`, className: CHECK_COLOR.failure }) + } + if (counts.pending > 0) { + countChips.push({ label: `${counts.pending} pending`, className: CHECK_COLOR.pending }) + } + if (counts.skipped + counts.neutral > 0) { + countChips.push({ + label: `${counts.skipped + counts.neutral} skipped`, + className: 'text-muted-foreground' + }) + } + return ( +
+
+ 0 && counts.failing === 0 && 'animate-spin' + )} + /> +
+ {summaryLabel} + {countChips.length > 1 && ( + + {countChips.map((chip, i) => ( + + {i > 0 && ·} + {chip.label} + + ))} + + )} +
+ {actions} +
+
+ {sorted.map((check, index) => ( +
0 && 'border-t border-border/40')} + > + {renderCheckRow(check)} +
+ ))} +
+
+ ) + } return ( <> - {toolbar} -
- {list.map((check) => { - const conclusion = check.conclusion ?? 'pending' - const Icon = CHECK_ICON[conclusion] ?? CircleDashed - const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' - return ( - - ) - })} -
+ {compactHeader} +
{sorted.map(renderCheckRow)}
) } @@ -2750,6 +4006,51 @@ async function runIssueUpdate(args: { } } +async function runWorkItemBodyUpdate(args: { + item: GitHubWorkItem + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + body: string + parsedSlug: GitHubOwnerRepo | null +}): Promise { + if (args.item.type === 'pr') { + const targetSlug = args.projectOrigin + ? { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo } + : args.parsedSlug + if (!targetSlug) { + throw new Error('No GitHub repository context available for this pull request.') + } + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updateArgs = { + owner: targetSlug.owner, + repo: targetSlug.repo, + number: args.item.number, + updates: { body: args.body } + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updatePullRequestBySlug', + updateArgs, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePullRequestBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + return + } + + await runIssueUpdate({ + repoPath: args.repoPath, + repoId: args.item.repoId, + projectOrigin: args.projectOrigin, + number: args.item.number, + updates: { body: args.body } + }) +} + async function runPullRequestStateUpdate(args: { repoPath: string | null repoId?: string | null @@ -3425,12 +4726,13 @@ export default function GitHubItemDialog({ workItem, repoPath, repoId, + initialTab, projectOrigin, onUse, onReviewRequestsChange, onClose }: GitHubItemDialogProps): React.JSX.Element { - const [tab, setTab] = useState('conversation') + const [tab, setTab] = useState(() => normalizeItemDialogTab(workItem, initialTab)) const [localState, setLocalState] = useState(workItem?.state ?? 'open') const [localLabels, setLocalLabels] = useState(workItem?.labels ?? []) const [diffViewMode, setDiffViewMode] = useState('flat') @@ -3565,6 +4867,9 @@ export default function GitHubItemDialog({ const loading = !!cachedEntry?.pending && !cachedEntry?.details const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null + const detailsLoaded = + Boolean(cachedEntry?.details) || + Boolean(cachedEntry && !cachedEntry.pending && !cachedEntry.error && cachedEntry.fetchedAt > 0) // Why: if a cross-window mutation invalidates the open drawer's entry // (cachedEntry becomes undefined while workItem is still set), the main @@ -3590,7 +4895,7 @@ export default function GitHubItemDialog({ optimisticCommentsRef.current = [] } prevItemIdRef.current = workItem.id - setTab('conversation') + setTab(normalizeItemDialogTab(workItem, initialTab)) const cached = workItemDetailsCache.get(detailsCacheKey) const now = Date.now() @@ -3668,7 +4973,7 @@ export default function GitHubItemDialog({ error: message }) }) - }, [repoPath, effectiveRepoId, workItem, detailsCacheKey, refetchTick]) + }, [repoPath, effectiveRepoId, workItem, detailsCacheKey, initialTab, refetchTick]) const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot const displayWorkItem = useMemo(() => { @@ -3805,7 +5110,15 @@ export default function GitHubItemDialog({ { // Why: focusing the first actionable element inside the drawer // causes the "Start workspace" action to receive focus and @@ -3822,7 +5135,7 @@ export default function GitHubItemDialog({ - Read-only preview of the selected GitHub issue or pull request. + Preview and edit the selected GitHub issue or pull request. @@ -3855,7 +5168,19 @@ export default function GitHubItemDialog({ )}
-
+
+ {workItem.type === 'pr' && ( + + )} - - - Flat view - - - - - - - - Tree view - - -
+ <> + + { + if (detailsCacheKey) { + patchCachedPRChecks(detailsCacheKey, nextChecks) + } + }} + /> + + + + {loading && files.length === 0 ? ( +
+
- {diffViewMode === 'flat' ? ( - files.map((file) => ( - + No files changed. +
+ ) : ( +
+ {/* Files-tab toolbar: view-mode toggle */} +
+ + {viewedFileCount} / {files.length} files viewed + +
+ + + + + + Flat view + + + + + + + + Tree view + + +
+
+ {diffViewMode === 'flat' ? ( + files.map((file) => ( + + )) + ) : ( + - )) - ) : ( - - )} -
- )} - + )} +
+ )} + + )}
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 5008dcb7c03..99bcf9cd96e 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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({ - Open PR conversation and checks + Open PR checks
) @@ -1411,6 +1412,7 @@ function PRMergeCell({ onRefresh: () => void }): React.JSX.Element { const [merging, setMerging] = useState(false) + const confirm = useConfirmationDialog() if (item.type !== 'pr') { return Issue } @@ -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(null) + const githubTaskDrawerWorkItem = useAppStore((s) => s.githubTaskDrawerWorkItem) + const setGithubTaskDrawerWorkItem = useAppStore((s) => s.setGithubTaskDrawerWorkItem) + const [dialogInitialTab, setDialogInitialTab] = useState('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): void => { @@ -4314,7 +4324,10 @@ export default function TaskPage(): React.JSX.Element {
- setDialogWorkItem(item)} /> + setDialogWorkItem(item, 'checks')} + />
@@ -5399,6 +5412,7 @@ export default function TaskPage(): React.JSX.Element { { + it('keeps enough trigger width for GitHub Copilot when callers pass min-w-0', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('GitHub Copilot') + expect(markup).toContain('!min-w-[260px]') + expect(markup).toContain('flex-1') + }) +}) diff --git a/src/renderer/src/components/agent/AgentCombobox.tsx b/src/renderer/src/components/agent/AgentCombobox.tsx index 9ee35bcf626..9c22089a0c9 100644 --- a/src/renderer/src/components/agent/AgentCombobox.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.tsx @@ -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 ? ( - + {selectedAgent.label} ) : ( - + Blank Terminal diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx new file mode 100644 index 00000000000..833ad7e76ea --- /dev/null +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -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 + +const ConfirmationDialogContext = createContext(null) + +export function ConfirmationDialogProvider({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + const nextIdRef = useRef(0) + const [queue, setQueue] = useState([]) + const activeRequest = queue[0] ?? null + const [renderedRequest, setRenderedRequest] = useState(null) + const activeRequestRef = useRef(activeRequest) + const displayedRequest = activeRequest ?? renderedRequest + + useEffect(() => { + activeRequestRef.current = activeRequest + if (activeRequest) { + setRenderedRequest(activeRequest) + } + }, [activeRequest]) + + const confirm = useCallback((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 ( + + {children} + !open && settleActiveRequest(false)} + > + + + {displayedRequest?.options.title} + {displayedRequest?.options.description ? ( + {displayedRequest.options.description} + ) : null} + + + + + + + + + ) +} + +export function useConfirmationDialog(): ConfirmationDialogContextValue { + const confirm = useContext(ConfirmationDialogContext) + if (!confirm) { + throw new Error('useConfirmationDialog must be used inside ConfirmationDialogProvider') + } + return confirm +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index e8c6cbcea2f..65dc01baca9 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -202,7 +202,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
)} - {/* Merge / Delete Worktree actions */} + {/* Merge / Delete Workspace actions */} {activeWorktree && repo && ( )} diff --git a/src/renderer/src/components/right-sidebar/PRActions.tsx b/src/renderer/src/components/right-sidebar/PRActions.tsx index 474d59d8869..3103aeaaa50 100644 --- a/src/renderer/src/components/right-sidebar/PRActions.tsx +++ b/src/renderer/src/components/right-sidebar/PRActions.tsx @@ -187,7 +187,7 @@ export default function PRActions({ ) : ( )} - {isDeletingWorktree ? 'Deleting…' : 'Delete Worktree'} + {isDeletingWorktree ? 'Deleting…' : 'Delete Workspace'} ) } diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx new file mode 100644 index 00000000000..c8474d74b68 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx @@ -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') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.tsx index de11a38ea43..967e5ba7dfa 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.tsx @@ -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['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 { + const params = { repoId } + if (target.kind === 'local') { + return window.api.workspacePorts.scan(params) + } + try { + return await callRuntimeRpc(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 { + if (target.kind === 'local') { + return window.api.workspacePorts.kill(args) + } + try { + return await callRuntimeRpc(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 + } + + return +} + +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(null) + const [collapsedSections, setCollapsedSections] = useState>({ + other: true, + external: true + }) + const inFlightScanRef = useRef | null>(null) + const inFlightScanKeyRef = useRef(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 { + 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 | 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 ( +
+ +

No workspace selected

+
+ ) + } + + return ( +
+
+ + Ports + + + + + + + Refresh Ports + + +
+ + {displayScan?.unavailableReason && ( +
+ Port scan unavailable on {displayScan.platform}: {displayScan.unavailableReason} +
+ )} + + {!displayScan?.unavailableReason && ( + <> + toggleSection('active')} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + onOpenInBrowser={handleOpenPortInBrowser} + /> + toggleSection('other')} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + onOpenInBrowser={handleOpenPortInBrowser} + /> + toggleSection('external')} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + onOpenInBrowser={handleOpenPortInBrowser} + /> + + )} + + {!displayScan?.unavailableReason && + displayScan && + activePorts.length === 0 && + otherWorkspacePorts.length === 0 && + externalPorts.length === 0 && ( +
+ +

No local ports detected

+
+ )} + + setDetailsPort(null)} /> +
+ ) +} + +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 ( +
+ + {!collapsed && ( +
+ {ports.length > 0 + ? ports.map((port) => ( + + )) + : emptyText &&
{emptyText}
} +
+ )} +
+ ) +} + +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) => { + handleCopy() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleCopy] + ) + + const handleOpenBrowserButtonClick = useCallback( + (event: React.MouseEvent) => { + handleOpenBrowser() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleOpenBrowser] + ) + + const handleStopButtonClick = useCallback( + (event: React.MouseEvent) => { + 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 ( + +
+ +
+
+ {port.kind === 'container' ? : } +
+
+
+ :{port.port} + {processLabel} +
+
+ {ownerLabel} + {confidenceLabel && ( + {confidenceLabel} + )} +
+
+
+
+ +
+ + + + + + Open in Orca Browser + + + + + + + + Copy Address + + + {canStopProcess && ( + + + + + + Stop Process + + + )} +
+
+
+ + {`:${port.port}`} + + + Open in Orca Browser + + + + Copy Address + + { + void window.api.ui.writeClipboardText(JSON.stringify(port, null, 2)) + }} + > + + Copy Details + + onShowDetails(port)} + > + + Show Details + + + onStop(port)} + > + + Stop Process + + +
+ ) +} + +function LocalPortDetailsDialog({ + port, + onClose +}: { + port: WorkspacePort | null + onClose: () => void +}): React.JSX.Element { + return ( + !open && onClose()}> + + + {port ? `Port :${port.port}` : 'Port'} + + {port ? `${port.processName ?? 'Unknown process'} · ${addressForPort(port)}` : ''} + + + {port && ( +
+
Address
+
{addressForPort(port)}
+
Bind
+
{`${port.bindHost}:${port.port}`}
+
Kind
+
{port.kind}
+
Protocol
+
{port.protocol}
+
Process
+
{port.processName ?? 'Unknown'}
+
PID
+
{port.pid ?? 'Unknown'}
+ {port.kind === 'workspace' && ( + <> +
Workspace
+
{port.owner.displayName}
+
Evidence
+
{port.owner.confidence}
+ + )} +
+ )} +
+
+ ) +} + +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 { {!forwardedCollapsed && allForwards.map((entry) => ( - handleEdit(entry)} /> + handleEdit(entry)} + onOpenInBrowser={() => handleOpenForwardInBrowser(entry)} + /> ))}
)} @@ -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) => { + handleCopy() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleCopy] + ) + + const handleOpenBrowserButtonClick = useCallback( + (event: React.MouseEvent) => { + handleOpenBrowser() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleOpenBrowser] + ) + + const handleEditButtonClick = useCallback( + (event: React.MouseEvent) => { + onEdit() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [onEdit] + ) + + const handleRemoveButtonClick = useCallback( + (event: React.MouseEvent) => { + void handleRemove() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleRemove] + ) return (
@@ -281,21 +1044,19 @@ function ForwardedPortRow({
-
- {isHttpPort && ( - - )} +
+
) diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 6aeb95dd24b..49c15f462fc 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -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>(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( diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx index dd1c16b2292..4ab4a38b48e 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx @@ -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') + }) }) diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 58887223bf9..45ee2fff6a5 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -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 { + 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> + >({}) 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): void => { updateSettings({ commitMessageAi: { ...config, ...patch } }) } + const refreshModels = async (agentId: TuiAgent): Promise => { + 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" >
@@ -342,7 +639,7 @@ export function CommitMessageAiPane({
+ + + + + {activeCapability.models.map((m) => ( + + {m.label} + + ))} + +
- ) } diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 58d9b72ad01..9760b366caa 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -278,16 +278,16 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea breaks that toast action even though this pane still renders fine. */}
- +

- 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.