From 03b889512ba1238c0adde1f8dba2a9a09bbf95fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Tue, 19 May 2026 03:32:53 -0600 Subject: [PATCH 01/17] feat: expand ai commit agent support (#1928) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: brennanb2025 --- src/main/ipc/filesystem.test.ts | 93 +++- src/main/ipc/filesystem.ts | 51 +- src/main/runtime/orca-runtime-git.test.ts | 66 ++- src/main/runtime/orca-runtime-git.ts | 76 ++- src/main/runtime/orca-runtime.ts | 2 + src/main/runtime/rpc/methods/git-params.ts | 20 +- src/main/runtime/rpc/methods/git.test.ts | 30 +- src/main/runtime/rpc/methods/git.ts | 27 +- .../commit-message-agent-environment.test.ts | 107 ++++ .../commit-message-agent-environment.ts | 37 ++ .../commit-message-text-generation.test.ts | 318 ++++++++++- .../commit-message-text-generation.ts | 289 +++++++++- src/preload/api-types.ts | 17 + src/preload/index.ts | 5 + .../components/agent/AgentCombobox.test.tsx | 21 + .../src/components/agent/AgentCombobox.tsx | 12 +- .../editor/useEditorPanelContentState.ts | 2 +- .../settings/CommitMessageAiPane.test.tsx | 136 ++++- .../settings/CommitMessageAiPane.tsx | 426 +++++++++++++-- .../src/runtime/runtime-git-client.test.ts | 36 +- .../src/runtime/runtime-git-client.ts | 59 +- src/renderer/src/web/web-preload-api.ts | 4 + src/shared/commit-message-agent-spec.test.ts | 157 +++++- src/shared/commit-message-agent-spec.ts | 507 +++++++++++++++--- src/shared/commit-message-host-key.ts | 27 + src/shared/commit-message-plan.test.ts | 79 ++- src/shared/commit-message-plan.ts | 6 +- src/shared/commit-message-prompt.test.ts | 33 ++ src/shared/commit-message-prompt.ts | 25 +- src/shared/constants.ts | 4 + src/shared/types.ts | 15 + 31 files changed, 2512 insertions(+), 175 deletions(-) create mode 100644 src/main/text-generation/commit-message-agent-environment.test.ts create mode 100644 src/renderer/src/components/agent/AgentCombobox.test.tsx create mode 100644 src/shared/commit-message-host-key.ts 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/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.ts b/src/main/runtime/orca-runtime.ts index 82510a2b884..9aaaef30fef 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1694,6 +1694,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'] = 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/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..46266c22972 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -167,6 +167,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 { @@ -1481,6 +1485,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..a24e5867cf1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2029,6 +2029,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/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx new file mode 100644 index 00000000000..d0051587dbe --- /dev/null +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -0,0 +1,21 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { AGENT_CATALOG } from '@/lib/agent-catalog' +import AgentCombobox from './AgentCombobox' + +describe('AgentCombobox', () => { + it('keeps enough trigger width for GitHub Copilot when callers pass min-w-0', () => { + const markup = renderToStaticMarkup( + + ) + + 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/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 76045a630d1..3da0717b3da 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -162,7 +162,7 @@ export function useEditorPanelContentState({ const compareAgainstHead = file.mode === 'edit' const key = inFlightDiffKey( { ...file, diffSource: effectiveDiffSource }, - gitScope, + gitScope ?? undefined, compareAgainstHead ) let pending = inFlightDiffReads.get(key) 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/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index d58c2431cc2..a6f9c75c681 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -5,6 +5,7 @@ import { bulkStageRuntimeGitPaths, cancelRuntimeGenerateCommitMessage, commitRuntimeGit, + discoverRuntimeCommitMessageModels, generateRuntimeCommitMessage, getRuntimeGitDiff, getRuntimeGitHistory, @@ -27,6 +28,7 @@ const gitBulkDiscard = vi.fn() const gitCommit = vi.fn() const gitPush = vi.fn() const gitGenerateCommitMessage = vi.fn() +const gitDiscoverCommitMessageModels = vi.fn() const gitCancelGenerateCommitMessage = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -43,6 +45,7 @@ beforeEach(() => { gitCommit.mockReset() gitPush.mockReset() gitGenerateCommitMessage.mockReset() + gitDiscoverCommitMessageModels.mockReset() gitCancelGenerateCommitMessage.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() @@ -62,6 +65,7 @@ beforeEach(() => { commit: gitCommit, push: gitPush, generateCommitMessage: gitGenerateCommitMessage, + discoverCommitMessageModels: gitDiscoverCommitMessageModels, cancelGenerateCommitMessage: gitCancelGenerateCommitMessage }, runtime: { call: runtimeCall }, @@ -313,7 +317,7 @@ describe('runtime git client', () => { expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { selector: 'env-1', method: 'git.generateCommitMessage', - params: { worktree: 'wt-1' }, + params: { worktree: 'wt-1', commitMessageDiscoveryHostKey: 'runtime:env-1' }, timeoutMs: 75_000 }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { @@ -365,9 +369,37 @@ describe('runtime git client', () => { worktree: 'wt-1', commitMessageAi, agentCmdOverrides, - enableGitHubAttribution: true + enableGitHubAttribution: true, + commitMessageDiscoveryHostKey: 'runtime:env-1' }, timeoutMs: 75_000 }) }) + + it('discovers commit-message models through the active runtime', async () => { + const agentCmdOverrides = { cursor: 'cursor-agent' } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { success: true, models: [{ id: 'auto', label: 'Auto' }], defaultModelId: 'auto' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await discoverRuntimeCommitMessageModels( + { + settings: { activeRuntimeEnvironmentId: 'env-1', agentCmdOverrides }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + 'cursor' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.discoverCommitMessageModels', + params: { worktree: 'wt-1', agentId: 'cursor', agentCmdOverrides }, + timeoutMs: 75_000 + }) + expect(gitDiscoverCommitMessageModels).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 31c1fb0386d..c804149b79a 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -11,6 +11,11 @@ import type { GitUpstreamStatus, GlobalSettings } from '../../../shared/types' +import type { + CommitMessageAgentCapability, + CommitMessageModelCapability +} from '../../../shared/commit-message-agent-spec' +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' @@ -29,6 +34,15 @@ export type RuntimeGeneratePullRequestFieldsResult = type RuntimeGitSettings = Pick & Partial> +type RuntimeDiscoverCommitMessageModelsResult = + | { + success: true + capability: CommitMessageAgentCapability + models: CommitMessageModelCapability[] + defaultModelId: string + } + | { success: false; error: string } + export type RuntimeGitContext = { settings: RuntimeGitSettings | null | undefined worktreeId: string | null | undefined @@ -37,13 +51,17 @@ export type RuntimeGitContext = { } function getRuntimeCommitMessageSettings( - settings: RuntimeGitSettings | null | undefined + settings: RuntimeGitSettings | null | undefined, + connectionId?: string ): Partial< Pick -> { +> & { + commitMessageDiscoveryHostKey?: string +} { if (!settings) { return {} } + const scope = getRuntimeGitScope(settings, connectionId) return { ...(settings.commitMessageAi !== undefined ? { commitMessageAi: settings.commitMessageAi } @@ -53,14 +71,15 @@ function getRuntimeCommitMessageSettings( : {}), ...(settings.enableGitHubAttribution !== undefined ? { enableGitHubAttribution: settings.enableGitHubAttribution } - : {}) + : {}), + commitMessageDiscoveryHostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope) } } export function getRuntimeGitScope( settings: Pick | null | undefined, - connectionId: string | undefined -): string | undefined { + connectionId: string | null | undefined +): string | null | undefined { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId } @@ -362,7 +381,33 @@ export async function generateRuntimeCommitMessage( 'git.generateCommitMessage', { worktree: context.worktreeId, - ...getRuntimeCommitMessageSettings(context.settings) + ...getRuntimeCommitMessageSettings(context.settings, context.connectionId) + }, + { timeoutMs: 75_000 } + ) +} + +export async function discoverRuntimeCommitMessageModels( + context: RuntimeGitContext, + agentId: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.discoverCommitMessageModels({ + agentId, + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) as Promise + } + return callRuntimeRpc( + target, + 'git.discoverCommitMessageModels', + { + worktree: context.worktreeId, + agentId, + ...(context.settings?.agentCmdOverrides + ? { agentCmdOverrides: context.settings.agentCmdOverrides } + : {}) }, { timeoutMs: 75_000 } ) @@ -405,7 +450,7 @@ export async function generateRuntimePullRequestFields( { worktree: context.worktreeId, ...input, - ...getRuntimeCommitMessageSettings(context.settings) + ...getRuntimeCommitMessageSettings(context.settings, context.connectionId) }, { timeoutMs: 75_000 } ) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index fa7034c913f..fa421019de0 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -586,6 +586,10 @@ function createGitApi(): NonNullable['git']> { success: false, error: 'Commit message generation is unavailable in the web client.' }), + discoverCommitMessageModels: async () => ({ + success: false, + error: 'Commit message model discovery is unavailable in the web client.' + }), cancelGenerateCommitMessage: () => Promise.resolve(), generatePullRequestFields: async () => ({ success: false, diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index e08f591fe18..f5e44b6390a 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -9,33 +9,67 @@ import { getCommitMessageModel, isCustomAgentId, listCommitMessageAgentCapabilities, - listCommitMessageAgentIds + listCommitMessageAgentIds, + parseCodexModels, + parseCursorModels, + parseLineModels, + parsePiModels } from './commit-message-agent-spec' describe('COMMIT_MESSAGE_AGENT_SPECS', () => { - it('exposes Claude and Codex as the v1 agents', () => { + it('exposes the installed local agents as commit-message agents', () => { const ids = listCommitMessageAgentIds().sort() - expect(ids).toEqual(['claude', 'codex']) + expect(ids).toEqual(['amp', 'claude', 'codex', 'copilot', 'cursor', 'kimi', 'opencode', 'pi']) }) - it('uses the smartest model as the default for each agent', () => { - expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('claude-opus-4-7') + it('uses the strongest available defaults for core agents', () => { + expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('sonnet') expect(COMMIT_MESSAGE_AGENT_SPECS.codex?.defaultModelId).toBe('gpt-5.5') + expect(COMMIT_MESSAGE_AGENT_SPECS.pi?.defaultModelId).toBe('github-copilot/gpt-5.4-mini') + }) + + it('uses the provider-qualified Kimi model id accepted by the CLI', () => { + expect(COMMIT_MESSAGE_AGENT_SPECS.kimi?.models.map((m) => m.id)).toEqual([ + 'default', + 'kimi-code/kimi-for-coding' + ]) + }) + + it('lists Copilot hosted CLI models even when account policy filters the picker', () => { + expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.defaultModelId).toBe('gpt-5.4') + expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.models.map((m) => m.id)).toEqual([ + 'auto', + 'claude-haiku-4.5', + 'claude-sonnet-4.5', + 'claude-sonnet-4.6', + 'claude-opus-4.5', + 'claude-opus-4.6', + 'claude-opus-4.6-fast', + 'claude-opus-4.7', + 'gpt-4.1', + 'gpt-5-mini', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.3-codex', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.5' + ]) }) it('defaults the agent picker to Claude', () => { expect(DEFAULT_COMMIT_MESSAGE_AGENT_ID).toBe('claude') }) - it('defaults every model with thinking levels to "low"', () => { + it('gives every model with thinking levels a valid default', () => { for (const spec of Object.values(COMMIT_MESSAGE_AGENT_SPECS)) { if (!spec) { continue } for (const model of spec.models) { if (model.thinkingLevels) { - expect(model.defaultThinkingLevel).toBe('low') - expect(model.thinkingLevels.some((l) => l.id === 'low')).toBe(true) + expect(model.defaultThinkingLevel).toBeDefined() + expect(model.thinkingLevels.some((l) => l.id === model.defaultThinkingLevel)).toBe(true) } } } @@ -48,8 +82,8 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { expect(spark?.defaultThinkingLevel).toBe('low') }) - it('omits thinking levels on Claude Haiku 4.5 (non-reasoning model)', () => { - const haiku = getCommitMessageModel('claude', 'claude-haiku-4-5') + it('omits thinking levels on Claude Haiku (non-reasoning model)', () => { + const haiku = getCommitMessageModel('claude', 'haiku') expect(haiku).toBeDefined() expect(haiku?.thinkingLevels).toBeUndefined() expect(haiku?.defaultThinkingLevel).toBeUndefined() @@ -81,11 +115,12 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { it('exposes UI capabilities without spawn details', () => { const capabilities = listCommitMessageAgentCapabilities() - expect(capabilities.map((capability) => capability.id).sort()).toEqual(['claude', 'codex']) + expect(capabilities.map((capability) => capability.id)).toContain('opencode') const codex = getCommitMessageAgentCapability('codex') expect(codex).toMatchObject({ id: 'codex', label: 'Codex', + modelSource: 'dynamic', defaultModelId: 'gpt-5.5' }) expect(codex).not.toHaveProperty('binary') @@ -98,14 +133,22 @@ describe('buildArgs (Claude)', () => { const spec = getCommitMessageAgentSpec('claude')! it('passes -p, output format, and model on every call', () => { - const args = spec.buildArgs({ prompt: '', model: 'claude-haiku-4-5' }) - expect(args).toEqual(['-p', '--output-format', 'text', '--model', 'claude-haiku-4-5']) + const args = spec.buildArgs({ prompt: '', model: 'haiku' }) + expect(args).toEqual([ + '-p', + '--output-format', + 'text', + '--model', + 'haiku', + '--permission-mode', + 'plan' + ]) }) it('appends --effort when a thinking level is supplied', () => { const args = spec.buildArgs({ prompt: '', - model: 'claude-sonnet-4-6', + model: 'sonnet', thinkingLevel: 'high' }) expect(args).toEqual([ @@ -113,18 +156,100 @@ describe('buildArgs (Claude)', () => { '--output-format', 'text', '--model', - 'claude-sonnet-4-6', + 'sonnet', + '--permission-mode', + 'plan', '--effort', 'high' ]) }) it('omits --effort when thinkingLevel is not provided', () => { - const args = spec.buildArgs({ prompt: '', model: 'claude-opus-4-7' }) + const args = spec.buildArgs({ prompt: '', model: 'opus' }) expect(args).not.toContain('--effort') }) }) +describe('model discovery parsers', () => { + it('parses Codex model JSON', () => { + expect( + parseCodexModels( + JSON.stringify({ + models: [ + { + slug: 'gpt-5.5', + display_name: 'GPT-5.5', + default_reasoning_level: 'low', + supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }] + } + ] + }) + ) + ).toEqual([ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' } + ], + defaultThinkingLevel: 'low' + } + ]) + }) + + it('parses one-model-per-line output', () => { + expect(parseLineModels('opencode/gpt-5.4-mini\n\nopenai/gpt-5.5\n').map((m) => m.id)).toEqual([ + 'opencode/gpt-5.4-mini', + 'openai/gpt-5.5' + ]) + }) + + it('parses Pi model table output with provider-qualified ids', () => { + const output = [ + 'provider model context max-out thinking images', + 'github-copilot gpt-5.4-mini 400K 128K yes yes', + 'github-copilot gpt-4o 128K 4.1K no yes' + ].join('\n') + + expect(parsePiModels(output)).toEqual([ + { + id: 'github-copilot/gpt-5.4-mini', + label: 'Github Copilot GPT 5.4 Mini', + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + }, + { + id: 'github-copilot/gpt-4o', + label: 'Github Copilot GPT 4O' + } + ]) + }) + + it('parses Cursor model output', () => { + expect(parseCursorModels('auto - Auto\ngpt-5.2 - GPT-5.2\n')).toEqual([ + { id: 'auto', label: 'Auto' }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + } + ]) + }) +}) + describe('buildArgs (Codex)', () => { const spec = getCommitMessageAgentSpec('codex')! diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 6556ae85468..2baadf79670 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -1,5 +1,7 @@ import type { TuiAgent } from './types' +/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ + // Why: this file is the source of truth for non-interactive agent invocation // (commit-message generation). It is intentionally separate from // `tui-agent-config.ts`, which describes interactive PTY launching — mixing @@ -27,6 +29,14 @@ export type CommitMessageAgentSpec = { /** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */ promptDelivery: 'argv' | 'stdin' buildArgs: (params: { prompt: string; model: string; thinkingLevel?: string }) => string[] + /** Whether the model list is static or discovered from the agent CLI. */ + modelSource: 'static' | 'dynamic' + /** Command used by the main process to discover models when modelSource is dynamic. */ + modelDiscovery?: { + binary: string + args: string[] + parse: (stdout: string) => CommitMessageModel[] + } models: CommitMessageModel[] defaultModelId: string } @@ -41,10 +51,159 @@ export type CommitMessageModelCapability = { export type CommitMessageAgentCapability = { id: TuiAgent label: string + modelSource: 'static' | 'dynamic' models: CommitMessageModelCapability[] defaultModelId: string } +const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' } +] + +const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } +] + +const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' }, + { id: 'max', label: 'Max' } +] + +function labelFromModelId(id: string): string { + return id + .split(/[/-]/) + .filter(Boolean) + .map((part) => { + if (/^gpt$/i.test(part)) { + return 'GPT' + } + return part.length <= 3 && /^\d/.test(part) + ? part.toUpperCase() + : part.charAt(0).toUpperCase() + part.slice(1) + }) + .join(' ') +} + +function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { + const seen = new Set() + return models.filter((model) => { + if (!model.id || seen.has(model.id)) { + return false + } + seen.add(model.id) + return true + }) +} + +function withOpenAiThinking( + id: string +): Pick { + return /(?:gpt-5|codex)/i.test(id) + ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } + : {} +} + +export function parseCodexModels(stdout: string): CommitMessageModel[] { + try { + const parsed = JSON.parse(stdout) as { + models?: { + slug?: string + display_name?: string + supported_reasoning_levels?: { effort?: string }[] + default_reasoning_level?: string + }[] + } + return uniqueModels( + (parsed.models ?? []) + .filter((model) => model.slug && model.display_name) + .map((model) => ({ + id: model.slug!, + label: model.display_name!, + ...(model.supported_reasoning_levels?.length + ? { + thinkingLevels: model.supported_reasoning_levels + .map((level) => level.effort) + .filter((effort): effort is string => Boolean(effort)) + .map((effort) => ({ + id: effort, + label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) + })), + defaultThinkingLevel: model.default_reasoning_level ?? 'low' + } + : {}) + })) + ) + } catch { + return [] + } +} + +export function parseLineModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.includes(' ')) + .map((id) => ({ + id, + label: labelFromModelId(id), + ...withOpenAiThinking(id) + })) + ) +} + +export function parsePiModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + stdout + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length >= 6 && parts[0] !== 'provider') + .map((parts) => { + const [provider, model, , , thinking] = parts + const id = `${provider}/${model}` + return { + id, + label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, + ...(thinking === 'yes' + ? { + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + } + : {}) + } + }) + ) +} + +export function parseCursorModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .map((line) => /^([^\s]+)\s+-\s+(.+)$/.exec(line)) + .filter((match): match is RegExpExecArray => Boolean(match)) + .map((match) => ({ + id: match[1], + label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), + ...withOpenAiThinking(match[1]) + })) + ) +} + export const COMMIT_MESSAGE_AGENT_SPECS: Partial> = { claude: { id: 'claude', @@ -59,42 +218,32 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial [ + 'run', + '--model', + model, + '--agent', + 'build', + '--format', + 'default', + ...(thinkingLevel ? ['--variant', thinkingLevel] : []), + prompt + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels }, + models: [ + { + // Why: OpenCode's hosted GPT models can require workspace billing even + // when `opencode models` lists them. This free model is available in + // discovery and works as a usable out-of-the-box default. + id: 'opencode/deepseek-v4-flash-free', + label: 'OpenCode DeepSeek V4 Flash Free' + }, + { + id: 'opencode/gpt-5.4-mini', + label: 'OpenCode GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'opencode/deepseek-v4-flash-free' + }, + pi: { + id: 'pi', + label: 'Pi', + binary: 'pi', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--print', + '--no-session', + '--no-tools', + '--no-extensions', + '--no-skills', + '--no-context-files', + '--mode', + 'text', + '--model', + model, + ...(thinkingLevel ? ['--thinking', thinkingLevel] : []) + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels }, + models: [ + { + // Why: Pi commonly authenticates through GitHub Copilot locally; using + // that provider avoids selecting a raw OpenAI model when no key exists. + id: 'github-copilot/gpt-5.4-mini', + label: 'Github Copilot GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'github-copilot/gpt-5.4-mini' + }, + amp: { + id: 'amp', + label: 'Amp', + binary: 'amp', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--execute', + '--archive', + '--no-notifications', + '--no-ide', + '--no-jetbrains', + '--mode', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'static', + models: [ + { id: 'smart', label: 'Smart' }, + { id: 'rush', label: 'Rush' }, + { + id: 'large', + label: 'Large', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'deep', + label: 'Deep', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'smart' + }, + cursor: { + id: 'cursor', + label: 'Cursor', + binary: 'cursor-agent', + promptDelivery: 'argv', + buildArgs: ({ prompt, model }) => [ + '--print', + '--mode', + 'ask', + '--trust', + '--output-format', + 'text', + '--model', + model, + prompt + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels }, + models: [{ id: 'auto', label: 'Auto' }], + defaultModelId: 'auto' + }, + kimi: { + id: 'kimi', + label: 'Kimi', + binary: 'kimi', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--print', + '--quiet', + ...(model && model !== 'default' ? ['--model', model] : []), + ...(thinkingLevel === 'on' + ? ['--thinking'] + : thinkingLevel === 'off' + ? ['--no-thinking'] + : []) + ], + modelSource: 'static', + models: [ + { id: 'default', label: 'Config default' }, + { + // Why: Kimi resolves its managed model by provider/model; bare model + // names are rejected by the CLI with "LLM not set". + id: 'kimi-code/kimi-for-coding', + label: 'Kimi K2.6', + thinkingLevels: [ + { id: 'on', label: 'On' }, + { id: 'off', label: 'Off' } + ], + defaultThinkingLevel: 'on' + } + ], + defaultModelId: 'default' + }, + copilot: { + id: 'copilot', + label: 'GitHub Copilot', + binary: 'copilot', + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + '--prompt', + prompt, + '--silent', + '--stream', + 'off', + '--no-custom-instructions', + '--model', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'static', + // Why: Copilot CLI's picker is policy-filtered per account/org. Keep the + // full hosted CLI catalog here so users can select models enabled for them. + models: [ + { id: 'auto', label: 'Auto' }, + { + id: 'claude-haiku-4.5', + label: 'Claude Haiku 4.5' + }, + { + id: 'claude-sonnet-4.5', + label: 'Claude Sonnet 4.5' + }, + { + id: 'claude-sonnet-4.6', + label: 'Claude Sonnet 4.6' + }, + { + id: 'claude-opus-4.5', + label: 'Claude Opus 4.5' + }, + { + id: 'claude-opus-4.6', + label: 'Claude Opus 4.6' + }, + { + id: 'claude-opus-4.6-fast', + label: 'Claude Opus 4.6 Fast' + }, + { + id: 'claude-opus-4.7', + label: 'Claude Opus 4.7' + }, + { + id: 'gpt-4.1', + label: 'GPT-4.1' + }, + { + id: 'gpt-5-mini', + label: 'GPT-5 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'gpt-5.4' } } @@ -231,7 +606,16 @@ export function getCommitMessageModel( agentId: TuiAgent, modelId: string ): CommitMessageModel | undefined { - return getCommitMessageAgentSpec(agentId)?.models.find((m) => m.id === modelId) + const spec = getCommitMessageAgentSpec(agentId) + const model = spec?.models.find((m) => m.id === modelId) + if (model || !spec || spec.modelSource !== 'dynamic' || modelId.trim().length === 0) { + return model + } + return { + id: modelId, + label: labelFromModelId(modelId), + ...withOpenAiThinking(modelId) + } } function toCommitMessageAgentCapability( @@ -240,6 +624,7 @@ function toCommitMessageAgentCapability( return { id: spec.id, label: spec.label, + modelSource: spec.modelSource, defaultModelId: spec.defaultModelId, // Why: renderer/settings should consume provider capabilities, not the // spawn contract. Copy the model metadata so future dynamic probes can diff --git a/src/shared/commit-message-host-key.ts b/src/shared/commit-message-host-key.ts new file mode 100644 index 00000000000..6edf51a9bc3 --- /dev/null +++ b/src/shared/commit-message-host-key.ts @@ -0,0 +1,27 @@ +export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local' +export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown' +export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:' + +export function getCommitMessageModelDiscoveryHostKey( + connectionId: string | null | undefined +): string { + if (connectionId === undefined) { + return UNKNOWN_COMMIT_MESSAGE_HOST_KEY + } + return connectionId ? `ssh:${connectionId}` : LOCAL_COMMIT_MESSAGE_HOST_KEY +} + +export function getCommitMessageModelDiscoveryHostKeyForScope( + scope: string | null | undefined +): string { + if (scope === undefined) { + return UNKNOWN_COMMIT_MESSAGE_HOST_KEY + } + if (!scope) { + return LOCAL_COMMIT_MESSAGE_HOST_KEY + } + if (scope.startsWith(RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX)) { + return scope + } + return getCommitMessageModelDiscoveryHostKey(scope) +} diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index d8294278ea3..3c891c067fc 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -6,7 +6,7 @@ describe('planCommitMessageGeneration', () => { const result = planCommitMessageGeneration( { agentId: 'claude', - model: 'claude-sonnet-4-6', + model: 'sonnet', thinkingLevel: 'high' }, 'PROMPT' @@ -16,13 +16,86 @@ describe('planCommitMessageGeneration', () => { ok: true, plan: { binary: 'claude', - args: ['-p', '--output-format', 'text', '--model', 'claude-sonnet-4-6', '--effort', 'high'], + args: [ + '-p', + '--output-format', + 'text', + '--model', + 'sonnet', + '--permission-mode', + 'plan', + '--effort', + 'high' + ], stdinPayload: 'PROMPT', label: 'Claude' } }) }) + it('plans OpenCode run with prompt in argv and model variant', () => { + const result = planCommitMessageGeneration( + { + agentId: 'opencode', + model: 'opencode/gpt-5.4-mini', + thinkingLevel: 'high' + }, + 'PROMPT' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'opencode', + args: [ + 'run', + '--model', + 'opencode/gpt-5.4-mini', + '--agent', + 'build', + '--format', + 'default', + '--variant', + 'high', + 'PROMPT' + ], + stdinPayload: null, + label: 'OpenCode' + } + }) + }) + + it('allows discovered dynamic models that are not in the seed catalog', () => { + const result = planCommitMessageGeneration( + { + agentId: 'cursor', + model: 'gpt-5.2', + thinkingLevel: 'xhigh' + }, + 'PROMPT' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'cursor-agent', + args: [ + '--print', + '--mode', + 'ask', + '--trust', + '--output-format', + 'text', + '--model', + 'gpt-5.2', + 'PROMPT' + ], + stdinPayload: null, + label: 'Cursor' + } + }) + }) + it('plans Codex exec as non-interactive read-only generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { @@ -87,7 +160,7 @@ describe('planCommitMessageGeneration', () => { const result = planCommitMessageGeneration( { agentId: 'claude', - model: 'claude-haiku-4-5', + model: 'haiku', agentCommandOverride: 'claude "unterminated' }, 'PROMPT' diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts index 7dd7ccf6107..b7c4c783e96 100644 --- a/src/shared/commit-message-plan.ts +++ b/src/shared/commit-message-plan.ts @@ -33,7 +33,7 @@ export type CommitMessagePlanResult = | { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } -function planAgentBinary( +export function planAgentBinary( defaultBinary: string, commandOverride: string | undefined ): { ok: true; binary: string; prefixArgs: string[] } | { ok: false; error: string } { @@ -91,13 +91,13 @@ export function planCommitMessageGeneration( return { ok: false, error: `Model "${input.model}" is not available for ${spec.label}.` } } if (input.thinkingLevel) { - if (!model.thinkingLevels) { + if (!model.thinkingLevels && spec.modelSource !== 'dynamic') { return { ok: false, error: `Model "${model.label}" does not support a thinking effort level.` } } - if (!model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) { + if (model.thinkingLevels && !model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) { return { ok: false, error: `Thinking level "${input.thinkingLevel}" is not valid for ${model.label}.` diff --git a/src/shared/commit-message-prompt.test.ts b/src/shared/commit-message-prompt.test.ts index dfaa39fa1bb..abd84fe64c7 100644 --- a/src/shared/commit-message-prompt.test.ts +++ b/src/shared/commit-message-prompt.test.ts @@ -73,6 +73,13 @@ describe('cleanGeneratedCommitMessage', () => { expect(cleanGeneratedCommitMessage('feat: a\r\nbody line\r\n')).toBe('feat: a\nbody line') }) + it('strips a leading list marker from the commit subject', () => { + expect(cleanGeneratedCommitMessage('● Add Copilot entry to agent results')).toBe( + 'Add Copilot entry to agent results' + ) + expect(cleanGeneratedCommitMessage('1. Add numbered entry')).toBe('Add numbered entry') + }) + it('returns empty string when input is whitespace', () => { expect(cleanGeneratedCommitMessage(' \n\t')).toBe('') }) @@ -111,6 +118,32 @@ describe('extractAgentErrorMessage', () => { expect(extractAgentErrorMessage('Error: model unavailable\n', '')).toBe('model unavailable') }) + it('matches ANSI-colored `Error:` lines emitted by CLIs', () => { + expect( + extractAgentErrorMessage('', '\u001b[91m\u001b[1mError: \u001b[0mNo payment method\n') + ).toBe('No payment method') + }) + + it('matches tool-specific `Error during ...:` lines', () => { + expect( + extractAgentErrorMessage( + '', + 'Error during droid execution: Authentication failed. Please log into Factory.\n' + ) + ).toBe('Authentication failed. Please log into Factory.') + }) + + it('matches wrapped provider error-code payloads with quoted message fields', () => { + const stdout = [ + "Error code: 401 - {'error': {'message': 'The API Key appears to be invalid or ma", + "y have expired. Please verify your credentials and try again.', 'type': 'invalid", + "_authentication_error'}}" + ].join('\n') + expect(extractAgentErrorMessage(stdout, '')).toBe( + 'The API Key appears to be invalid or may have expired. Please verify your credentials and try again.' + ) + }) + it('returns null when no ERROR line is present', () => { expect(extractAgentErrorMessage('plain log\nmore log\n', '')).toBeNull() }) diff --git a/src/shared/commit-message-prompt.ts b/src/shared/commit-message-prompt.ts index cb12b5aeba2..975dfbf39c4 100644 --- a/src/shared/commit-message-prompt.ts +++ b/src/shared/commit-message-prompt.ts @@ -66,9 +66,17 @@ export function cleanGeneratedCommitMessage(raw: string): string { text = fenced[1].trim() } + // Why: some CLIs format a one-shot answer as a list item even when the + // prompt asks for raw text; a Git subject should not carry that marker. + text = text.replace(/^(\s*)(?:[-*•●]\s+|\d+[.)]\s+)/, '$1').trim() + return text } +function stripAnsiControlSequences(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '') +} + export const CUSTOM_PROMPT_PLACEHOLDER = '{prompt}' export type TokenizeCustomCommandResult = @@ -195,7 +203,7 @@ export function planCustomCommand(template: string, prompt: string): CustomComma // out the real message so the user sees something legible instead of a // dump of the agent's runtime state. export function extractAgentErrorMessage(stdout: string, stderr: string): string | null { - const combined = `${stdout}\n${stderr}` + const combined = stripAnsiControlSequences(`${stdout}\n${stderr}`) const lines = combined.split(/\r?\n/) // Pass 1: look for an `ERROR:`/`Error:` line carrying a JSON payload. @@ -203,7 +211,7 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string // error wins when an agent prints multiple. for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i] - const match = /^\s*(?:ERROR|Error)\s*:\s*(.+)$/.exec(line) + const match = /^\s*(?:ERROR|Error(?:\s+during\s+[^:]+)?)\s*:\s*(.+)$/i.exec(line) if (!match) { continue } @@ -227,5 +235,18 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string } } + const compact = combined.replace(/([A-Za-z])\r?\n\s*([A-Za-z_])/g, '$1$2').replace(/\s+/g, ' ') + const errorCodeMatch = /\bError code:\s*\d+\s*-\s*(.+)$/i.exec(compact) + if (errorCodeMatch) { + const payload = errorCodeMatch[1].trim() + const messageMatch = /['"]message['"]\s*:\s*['"]([^'"]+)['"]/i.exec(payload) + if (messageMatch?.[1]?.trim()) { + return messageMatch[1].trim() + } + if (payload.length > 0) { + return payload + } + } + return null } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5e8de4e09c6..a39fb4bbb70 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: default persisted settings live in one schema-shaped object so migrations and tests compare against one source of truth. */ import type { GlobalSettings, NotificationSettings, @@ -275,6 +276,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { enabled: true, agentId: null, selectedModelByAgent: {}, + discoveredModelsByAgent: {}, + selectedModelByAgentByHost: {}, + discoveredModelsByAgentByHost: {}, selectedThinkingByModel: {}, customPrompt: '', customAgentCommand: '' diff --git a/src/shared/types.ts b/src/shared/types.ts index f8c1f9684e0..2b81097397f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1654,12 +1654,27 @@ export type GlobalSettings = { voice?: VoiceSettings } +export type CommitMessageAiModelCapability = { + id: string + label: string + thinkingLevels?: { id: string; label: string }[] + defaultThinkingLevel?: string +} + export type CommitMessageAiSettings = { enabled: boolean /** A TuiAgent id, the literal `'custom'` for a user-supplied command, or null. */ agentId: TuiAgent | 'custom' | null /** Per-agent: switching agents preserves the previously-picked model. */ selectedModelByAgent: Partial> + /** Host-scoped model selections; dynamic agents can expose different models per SSH target. */ + selectedModelByAgentByHost?: Partial>>> + /** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */ + discoveredModelsByAgent?: Partial> + /** Host-scoped dynamic model discovery cache. */ + discoveredModelsByAgentByHost?: Partial< + Record>> + > /** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */ selectedThinkingByModel: Record /** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */ From d5fb49b18f09b4e81d8113de22029de471c687e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 17:08:14 +0000 Subject: [PATCH 02/17] release: v1.4.9-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 18f5967441a..c501e1a6a7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.9-rc.0", + "version": "1.4.9-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 062725da76d89aaef7c50dbce2629b8c04c1ff87 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 19 May 2026 13:23:40 -0400 Subject: [PATCH 03/17] Add workspace port management (#2316) Co-authored-by: Orca --- src/main/ipc/register-core-handlers.test.ts | 12 +- src/main/ipc/register-core-handlers.ts | 2 + src/main/ipc/workspace-ports.test.ts | 241 ++++++ src/main/ipc/workspace-ports.ts | 79 ++ .../local-workspace-port-scanner.test.ts | 136 +++ .../ports/local-workspace-port-scanner.ts | 513 +++++++++++ src/main/ports/workspace-port-ownership.ts | 102 +++ src/main/runtime/orca-runtime.test.ts | 1 + src/main/runtime/orca-runtime.ts | 39 + src/main/runtime/rpc/methods/index.ts | 2 + .../rpc/methods/workspace-ports.test.ts | 55 ++ .../runtime/rpc/methods/workspace-ports.ts | 31 + src/preload/api-types.ts | 10 + src/preload/index.ts | 13 + .../right-sidebar/PortsPanel.test.tsx | 174 ++++ .../components/right-sidebar/PortsPanel.tsx | 809 +++++++++++++++++- .../src/components/right-sidebar/index.tsx | 78 +- src/shared/protocol-version.ts | 3 +- src/shared/workspace-ports.ts | 64 ++ 19 files changed, 2260 insertions(+), 104 deletions(-) create mode 100644 src/main/ipc/workspace-ports.test.ts create mode 100644 src/main/ipc/workspace-ports.ts create mode 100644 src/main/ports/local-workspace-port-scanner.test.ts create mode 100644 src/main/ports/local-workspace-port-scanner.ts create mode 100644 src/main/ports/workspace-port-ownership.ts create mode 100644 src/main/runtime/rpc/methods/workspace-ports.test.ts create mode 100644 src/main/runtime/rpc/methods/workspace-ports.ts create mode 100644 src/renderer/src/components/right-sidebar/PortsPanel.test.tsx create mode 100644 src/shared/workspace-ports.ts 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/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/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.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 9aaaef30fef..bf27f76c1d2 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, @@ -5653,6 +5664,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/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/preload/api-types.ts b/src/preload/api-types.ts index 46266c22972..c25932b35ae 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -221,6 +221,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, @@ -658,6 +664,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 diff --git a/src/preload/index.ts b/src/preload/index.ts index a24e5867cf1..d97bf466da8 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 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..6ed2e72c2d1 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,559 @@ 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 { + await refresh() + 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 +808,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 +879,12 @@ export default function PortsPanel(): React.JSX.Element { {!forwardedCollapsed && allForwards.map((entry) => ( - handleEdit(entry)} /> + handleEdit(entry)} + onOpenInBrowser={() => handleOpenForwardInBrowser(entry)} + /> ))} )} @@ -231,10 +953,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 +980,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 +1040,19 @@ function ForwardedPortRow({
-
- {isHttpPort && ( - - )} +
+
) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index f92cf0fd69b..4fef2fbd4a2 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -26,7 +26,8 @@ export const RUNTIME_CAPABILITIES = [ 'runtime.environments.v1', 'browser.screencast.v1', 'terminal.binary-stream.v1', - 'terminal.multiplex.v1' + 'terminal.multiplex.v1', + 'workspace-ports.v1' ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/workspace-ports.ts b/src/shared/workspace-ports.ts new file mode 100644 index 00000000000..2b9ff8a94a6 --- /dev/null +++ b/src/shared/workspace-ports.ts @@ -0,0 +1,64 @@ +export type WorkspacePortProbe = { + id: string + repoId: string + displayName: string + path: string +} + +export type WorkspacePortAttributionConfidence = 'cwd' | 'command' | 'none' + +export type WorkspacePortOwner = { + worktreeId: string + repoId: string + displayName: string + path: string + confidence: WorkspacePortAttributionConfidence +} + +type WorkspacePortBase = { + id: string + /** Address reported by the OS listener. May be a wildcard bind. */ + bindHost: string + /** Address the renderer should copy/open. Wildcard binds are normalized to localhost. */ + connectHost: string + port: number + pid?: number + processName?: string + protocol: 'http' | 'https' | 'unknown' +} + +export type WorkspacePort = + | (WorkspacePortBase & { + kind: 'workspace' + owner: WorkspacePortOwner + }) + | (WorkspacePortBase & { + kind: 'container' + }) + | (WorkspacePortBase & { + kind: 'external' + }) + +export type WorkspacePortScanRequest = { + repoId?: string +} + +export type WorkspacePortKillRequest = { + repoId?: string + pid: number + port: number +} + +export type WorkspacePortKillResult = + | { ok: true } + | { + ok: false + reason: string + } + +export type WorkspacePortScanResult = { + platform: NodeJS.Platform | 'unknown' + scannedAt: number + ports: WorkspacePort[] + unavailableReason?: string +} From 94f3d34a946366185354e3995ced676edd2f7da2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 19 May 2026 10:29:45 -0700 Subject: [PATCH 04/17] Fix terminal Set Title input disappearing (#2306) Co-authored-by: Orca --- .../components/terminal-pane/TerminalPane.tsx | 116 ++++++++++++++++-- tests/e2e/terminal-panes.spec.ts | 91 +++++++++++++- 2 files changed, 190 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index a737a5f349b..13cbd603369 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -269,6 +269,11 @@ export default function TerminalPane({ // again. Similarly, pressing Escape runs handleRenameCancel but blur would // then call handleRenameSubmit, saving the title the user wanted to discard. const renameSubmittedRef = useRef(false) + const renameSessionIdRef = useRef(0) + const renameBlurCommitEnabledRef = useRef(true) + const renameFocusFrameRef = useRef(null) + const renameEnableBlurFrameRef = useRef(null) + const renameRefocusFrameRef = useRef(null) const onPtyErrorRef = useRef((_paneId: number, message: string) => { if (isTerminalSessionStateSaveFailure(message)) { setTerminalError(null) @@ -1207,11 +1212,41 @@ export default function TerminalPane({ } }, [tabId, worktreeId, setTabLayout]) - const handleStartRename = useCallback((paneId: number) => { - setRenameValue(paneTitlesRef.current[paneId] ?? '') - setRenamingPaneId(paneId) + const cancelPendingRenameFrames = useCallback(() => { + const frameRefs = [renameFocusFrameRef, renameEnableBlurFrameRef, renameRefocusFrameRef] + for (const frameRef of frameRefs) { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + } }, []) + const closeRenameSession = useCallback(() => { + renameSessionIdRef.current += 1 + renameBlurCommitEnabledRef.current = true + cancelPendingRenameFrames() + }, [cancelPendingRenameFrames]) + + useEffect( + () => () => { + closeRenameSession() + }, + [closeRenameSession] + ) + + const handleStartRename = useCallback( + (paneId: number) => { + cancelPendingRenameFrames() + renameSessionIdRef.current += 1 + renameBlurCommitEnabledRef.current = false + renameSubmittedRef.current = false + setRenameValue(paneTitlesRef.current[paneId] ?? '') + setRenamingPaneId(paneId) + }, + [cancelPendingRenameFrames] + ) + const removePaneTitle = useCallback( (paneId: number) => { setPaneTitles((prev) => { @@ -1247,6 +1282,7 @@ export default function TerminalPane({ if (paneTitlesRef.current[renamingPaneId]) { removePaneTitle(renamingPaneId) } + closeRenameSession() setRenamingPaneId(null) return } @@ -1260,15 +1296,52 @@ export default function TerminalPane({ if (leafId) { removedTitleLeafIdsRef.current.delete(leafId) } + closeRenameSession() setRenamingPaneId(null) // Persist immediately so the title survives restarts. persistLayoutSnapshot() - }, [renamingPaneId, renameValue, removePaneTitle, persistLayoutSnapshot]) + }, [closeRenameSession, renamingPaneId, renameValue, removePaneTitle, persistLayoutSnapshot]) const handleRenameCancel = useCallback(() => { renameSubmittedRef.current = true + closeRenameSession() setRenamingPaneId(null) - }, []) + }, [closeRenameSession]) + + const handleRenameBlur = useCallback(() => { + if (renameBlurCommitEnabledRef.current) { + handleRenameSubmit() + return + } + if (renamingPaneId === null || renameRefocusFrameRef.current !== null) { + return + } + + const sessionId = renameSessionIdRef.current + const paneId = renamingPaneId + // Why: the context-menu selection can be followed by a delayed Radix/xterm + // focus handoff. That synthetic early blur is not a title submission. + renameRefocusFrameRef.current = requestAnimationFrame(() => { + renameRefocusFrameRef.current = null + if (renameSessionIdRef.current !== sessionId || renamingPaneId !== paneId) { + return + } + const input = renameInputRef.current + if (!input) { + renameBlurCommitEnabledRef.current = true + handleRenameSubmit() + return + } + input.focus() + input.select() + if (document.activeElement === input) { + renameBlurCommitEnabledRef.current = true + return + } + renameBlurCommitEnabledRef.current = true + handleRenameSubmit() + }) + }, [handleRenameSubmit, renamingPaneId]) const handleRemoveTitle = useCallback( (paneId: number) => removePaneTitle(paneId), @@ -1281,13 +1354,34 @@ export default function TerminalPane({ if (renamingPaneId === null) { return } + const sessionId = renameSessionIdRef.current + const paneId = renamingPaneId renameSubmittedRef.current = false - const frame = requestAnimationFrame(() => { - renameInputRef.current?.focus() - renameInputRef.current?.select() + renameFocusFrameRef.current = requestAnimationFrame(() => { + renameFocusFrameRef.current = null + if (renameSessionIdRef.current !== sessionId || renamingPaneId !== paneId) { + return + } + const input = renameInputRef.current + if (!input) { + return + } + input.focus() + input.select() + renameEnableBlurFrameRef.current = requestAnimationFrame(() => { + renameEnableBlurFrameRef.current = null + if ( + renameSessionIdRef.current === sessionId && + renamingPaneId === paneId && + renameInputRef.current === input && + document.activeElement === input + ) { + renameBlurCommitEnabledRef.current = true + } + }) }) - return () => cancelAnimationFrame(frame) - }, [renamingPaneId]) + return () => cancelPendingRenameFrames() + }, [cancelPendingRenameFrames, renamingPaneId]) const contextMenu = useTerminalPaneContextMenu({ managerRef, @@ -1553,7 +1647,7 @@ export default function TerminalPane({ handleRenameCancel() } }} - onBlur={handleRenameSubmit} + onBlur={handleRenameBlur} /> ) : ( <> diff --git a/tests/e2e/terminal-panes.spec.ts b/tests/e2e/terminal-panes.spec.ts index 64e3bbe7288..dd6f58d7a5f 100644 --- a/tests/e2e/terminal-panes.spec.ts +++ b/tests/e2e/terminal-panes.spec.ts @@ -42,12 +42,7 @@ import { import { pressShortcut } from './helpers/shortcuts' async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise { - const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = - process.platform === 'win32' ? ['Control'] : [] - await page - .locator('.xterm:visible') - .first() - .click({ button: 'right', position: { x: 40, y: 40 }, modifiers }) + await openTerminalContextMenu(page) await page.getByText('Set Title…', { exact: true }).click() const titleInput = page.locator('.pane-title-input').first() await expect(titleInput).toBeVisible() @@ -63,6 +58,24 @@ async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise< await expect(titleInput).toHaveCount(0) } +async function openTerminalContextMenu(page: Page): Promise { + const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = (await page.evaluate(() => + navigator.userAgent.includes('Windows') + )) + ? ['Control'] + : [] + const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac')) + await page + .locator('.xterm:visible') + .first() + .click({ + button: isMac ? 'left' : 'right', + position: { x: 40, y: 40 }, + modifiers: isMac ? ['Control'] : modifiers + }) + await expect(page.getByText('Set Title…', { exact: true })).toBeVisible() +} + async function getTabCustomTitle( page: Page, worktreeId: string, @@ -211,6 +224,72 @@ test.describe('Terminal Panes', () => { expect(activeLeafId).toMatch(UUID_RE) }) + test('first Set Title from terminal context menu stays open for typing', async ({ orcaPage }) => { + const title = `First menu title ${Date.now()}` + + await openTerminalContextMenu(orcaPage) + await orcaPage.getByText('Set Title…', { exact: true }).click() + + const titleInput = orcaPage.locator('.pane-title-input').first() + await expect(titleInput).toBeVisible() + await expect(titleInput).toBeFocused() + await orcaPage.waitForTimeout(250) + await expect(titleInput).toBeVisible() + await expect(titleInput).toBeFocused() + + await titleInput.fill(title) + await titleInput.press('Enter') + + await expect(titleInput).toHaveCount(0) + await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1) + }) + + test('Set Title survives an early blur during first focus handoff', async ({ orcaPage }) => { + await openTerminalContextMenu(orcaPage) + await orcaPage.evaluate(() => { + const blurOnFirstTitleFocus = (event: FocusEvent): void => { + const target = event.target + if ( + !(target instanceof HTMLInputElement) || + !target.classList.contains('pane-title-input') + ) { + return + } + document.removeEventListener('focusin', blurOnFirstTitleFocus, true) + queueMicrotask(() => target.blur()) + } + document.addEventListener('focusin', blurOnFirstTitleFocus, true) + }) + await orcaPage.getByText('Set Title…', { exact: true }).click() + + const titleInput = orcaPage.locator('.pane-title-input').first() + await expect(titleInput).toBeVisible() + await expect(titleInput).toBeFocused() + await orcaPage.waitForTimeout(250) + await expect(titleInput).toBeVisible() + await expect(titleInput).toBeFocused() + }) + + test('Set Title still commits by blur after focus settles', async ({ orcaPage }) => { + const title = `Blur commit title ${Date.now()}` + + await openTerminalContextMenu(orcaPage) + await orcaPage.getByText('Set Title…', { exact: true }).click() + + const titleInput = orcaPage.locator('.pane-title-input').first() + await expect(titleInput).toBeVisible() + await expect(titleInput).toBeFocused() + await orcaPage.waitForTimeout(100) + await titleInput.fill(title) + await orcaPage + .locator('.xterm:visible') + .first() + .click({ position: { x: 40, y: 60 } }) + + await expect(titleInput).toHaveCount(0) + await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1) + }) + test('Set Title stays pane-local during agent title churn', async ({ orcaPage }) => { const worktreeId = (await getActiveWorktreeId(orcaPage))! const tabId = (await getActiveTabId(orcaPage))! From b1a8c68b9a5979067f649e5cbfd6666434fddd03 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 19 May 2026 10:44:01 -0700 Subject: [PATCH 05/17] Rename delete worktree copy to workspace (#2325) Co-authored-by: Orca --- .../src/components/right-sidebar/ChecksPanel.tsx | 2 +- .../src/components/right-sidebar/PRActions.tsx | 2 +- src/renderer/src/components/settings/GeneralPane.tsx | 10 +++++----- src/renderer/src/components/settings/general-search.ts | 4 ++-- .../src/components/sidebar/DeleteWorktreeDialog.tsx | 10 +++++----- .../src/components/sidebar/delete-worktree-flow.ts | 4 ++-- .../components/sidebar/delete-worktree-toast.test.ts | 4 ++-- .../src/components/sidebar/delete-worktree-toast.ts | 4 ++-- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 6d2da15c662..e9d67274483 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -1009,7 +1009,7 @@ export default function ChecksPanel(): React.JSX.Element { )} - {/* 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/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.

- - - 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..9221e82b5cb 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -41,6 +41,7 @@ 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 { @@ -2101,6 +2102,7 @@ function PRActionsPanel({ const [mergePending, setMergePending] = useState(false) const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const confirm = useConfirmationDialog() const actionItem = { ...item, state: localState } const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin) const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' @@ -2135,7 +2137,16 @@ function PRActionsPanel({ return } const label = nextState === 'closed' ? 'Close' : 'Reopen' - if (!window.confirm(`${label} PR #${item.number}?`)) { + const confirmed = await confirm({ + title: `${label} PR #${item.number}?`, + description: + nextState === 'closed' + ? 'This will close the pull request on GitHub.' + : 'This will reopen the pull request on GitHub.', + confirmLabel: label, + confirmVariant: nextState === 'closed' ? 'destructive' : 'default' + }) + if (!confirmed) { return } const previousState = localState @@ -2165,7 +2176,12 @@ function PRActionsPanel({ } const label = method === 'squash' ? 'Squash and merge' : method === 'rebase' ? 'Rebase and merge' : 'Merge' - if (!window.confirm(`${label} PR #${item.number}?`)) { + const confirmed = await confirm({ + title: `${label} PR #${item.number}?`, + description: 'This will update the pull request on GitHub.', + confirmLabel: label + }) + if (!confirmed) { return } setMergePending(true) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 5008dcb7c03..1ade83fb1f5 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, @@ -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 } 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/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/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 5f66a1cab84..6a9830ca9c3 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -31,6 +31,7 @@ import { useAppStore } from '../../store' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { applyDocumentTheme } from '@/lib/document-theme' +import { useConfirmationDialog } from '@/components/confirmation-dialog' import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane' @@ -277,6 +278,7 @@ function Settings(): React.JSX.Element { const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0) const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false) const [commitPromptDiscardSignal, setCommitPromptDiscardSignal] = useState(0) + const confirm = useConfirmationDialog() // Why: the hidden-experimental group is an unlock — Shift-clicking the // Experimental sidebar entry reveals it for the remainder of the session. // Not persisted on purpose: it's a power-user affordance we don't want to @@ -291,22 +293,25 @@ function Settings(): React.JSX.Element { const repoHooksRequestSeqRef = useRef(0) const repoHooksRuntimeIdentityRef = useRef('local') - const confirmDiscardCommitPromptChanges = useCallback((): boolean => { + const confirmDiscardCommitPromptChanges = useCallback(async (): Promise => { if (!hasUnsavedCommitPromptChanges) { return true } - const shouldDiscard = window.confirm( - 'You have unsaved AI commit prompt changes. Leave without saving?' - ) + const shouldDiscard = await confirm({ + title: 'Discard unsaved commit prompt changes?', + description: 'You have unsaved AI commit prompt changes. Leaving will discard them.', + confirmLabel: 'Discard', + confirmVariant: 'destructive' + }) if (shouldDiscard) { setCommitPromptDiscardSignal((signal) => signal + 1) setHasUnsavedCommitPromptChanges(false) } return shouldDiscard - }, [hasUnsavedCommitPromptChanges]) + }, [confirm, hasUnsavedCommitPromptChanges]) - const closeSettingsPageWithPromptGuard = useCallback((): void => { - if (!confirmDiscardCommitPromptChanges()) { + const closeSettingsPageWithPromptGuard = useCallback(async (): Promise => { + if (!(await confirmDiscardCommitPromptChanges())) { return } closeSettingsPage() @@ -319,10 +324,33 @@ function Settings(): React.JSX.Element { const runtimeTargetIdentity = getRuntimeTargetIdentity(settings) useEffect(() => { + const hasVisibleOverlay = (): boolean => + Array.from( + document.querySelectorAll('[role="dialog"], [role="listbox"], [role="menu"]') + ).some((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + if (element.closest('[aria-hidden="true"]')) { + return false + } + const style = window.getComputedStyle(element) + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + element.getClientRects().length > 0 + ) + }) + const handleKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || event.defaultPrevented) { return } + // Why: nested dialogs and menus own Escape before Settings page-level + // navigation, including the unsaved commit prompt confirmation dialog. + if (hasVisibleOverlay()) { + return + } // Why: Escape in an editable control usually means "cancel this edit", // not "close Settings". Closing the entire page would discard the user's // in-progress typing. Defer to the field's own handler when focus is on @@ -331,7 +359,7 @@ function Settings(): React.JSX.Element { if (isEditableTarget(event.target)) { return } - closeSettingsPageWithPromptGuard() + void closeSettingsPageWithPromptGuard() } document.addEventListener('keydown', handleKeyDown) @@ -949,11 +977,11 @@ function Settings(): React.JSX.Element { }, [visibleNavSections]) const scrollToSection = useCallback( - ( + async ( sectionId: string, modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean } - ) => { - if (sectionId !== activeSectionId && !confirmDiscardCommitPromptChanges()) { + ): Promise => { + if (sectionId !== activeSectionId && !(await confirmDiscardCommitPromptChanges())) { return } // Why: Shift-clicking the Experimental sidebar entry unlocks a hidden @@ -971,8 +999,8 @@ function Settings(): React.JSX.Element { [activeSectionId, confirmDiscardCommitPromptChanges] ) - const openComputerUseFromBrowser = useCallback(() => { - if (!confirmDiscardCommitPromptChanges()) { + const openComputerUseFromBrowser = useCallback(async () => { + if (!(await confirmDiscardCommitPromptChanges())) { return } pendingNavSectionRef.current = 'computer-use' From fd2b782b1980c68d129bb7dea85d383ee050acae Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 11:09:33 -0700 Subject: [PATCH 07/17] fix: pr-bug-scan validated finding from #2274 (#2296) Co-authored-by: orca-bug-scan-bot --- src/main/ipc/ssh.ts | 4 ++++ 1 file changed, 4 insertions(+) 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) } } From 63b11f11a40f41262cfe3e5ab45d8728be8c954e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 19 May 2026 11:36:24 -0700 Subject: [PATCH 08/17] Add PR automation CLI and mobile browser streaming (#2328) * Squashed commits - WIP: uncommitted changes before rebase - ci - Show inline PR check details in task drawer - Add a Checks tab that opens from the PR checks cell and expands runs inline - Fetch check output, annotations, and workflow job steps through IPC/RPC - Improve markdown/comment wrapping so long PR content stays within the drawer * Use app-styled confirmations for PR actions (#2324) Co-authored-by: Orca * fix: pr-bug-scan validated finding from #2274 (#2296) Co-authored-by: orca-bug-scan-bot --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Co-authored-by: Orca Co-authored-by: buf0-bot[bot] <252831055+buf0-bot[bot]@users.noreply.github.com> Co-authored-by: orca-bug-scan-bot --- .../github/client-pr-check-details.test.ts | 142 ++ src/main/github/client.ts | 154 ++ src/main/ipc/github.ts | 29 + src/main/runtime/orca-runtime.ts | 16 + src/main/runtime/rpc/methods/github.ts | 20 + src/preload/api-types.ts | 10 + src/preload/index.ts | 10 + .../src/components/GitHubItemDialog.tsx | 1246 +++++++++++++---- src/renderer/src/components/TaskPage.tsx | 36 +- .../components/sidebar/CommentMarkdown.tsx | 2 +- src/renderer/src/store/slices/ui.ts | 5 + src/renderer/src/web/web-preload-api.ts | 1 + src/shared/types.ts | 43 + untitled.md | 6 + 14 files changed, 1423 insertions(+), 297 deletions(-) create mode 100644 src/main/github/client-pr-check-details.test.ts create mode 100644 untitled.md 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..2312687f8cc 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 diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index c526a4c4938..65ca8280b1a 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, @@ -301,6 +302,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', ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bf27f76c1d2..ec755f45a78 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -124,6 +124,7 @@ import { listWorkItems, countWorkItems, getPRChecks, + getPRCheckDetails, rerunPRChecks, getPRComments, getIssue, @@ -5195,6 +5196,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, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 77fbe27da09..c24bd0a100e 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() @@ -315,6 +323,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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c25932b35ae..a1720c397b7 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, @@ -825,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 diff --git a/src/preload/index.ts b/src/preload/index.ts index d97bf466da8..77803df151d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -839,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 diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 9221e82b5cb..cb44b3b467b 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -28,6 +28,7 @@ import { GitMerge, GitPullRequest, LayoutList, + ListChecks, LoaderCircle, MessageSquare, MessageSquarePlus, @@ -35,6 +36,7 @@ import { Send, UndoDot, Users, + Wrench, X } from 'lucide-react' import { toast } from 'sonner' @@ -92,6 +94,12 @@ import { 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, @@ -102,7 +110,10 @@ import type { GitHubAssignableUser, GitHubReaction, PRCheckDetail, - PRComment + PRCheckRunDetails, + PRComment, + TuiAgent, + Worktree } from '../../../shared/types' import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' @@ -133,7 +144,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 @@ -162,6 +173,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`, @@ -183,6 +204,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?: ( @@ -1694,12 +1716,12 @@ function ConversationTab({ headSha, baseSha, loading, + detailsLoaded, checks, participants: detailsParticipants, localState, onStateChange, projectOrigin, - onUse, onMutated, onChecksUpdated, onCommentAdded, @@ -1714,12 +1736,12 @@ 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 onCommentAdded: (comment: PRComment) => void @@ -1790,21 +1812,9 @@ function ConversationTab({ [item.number, item.repoId, item.type, onCommentAdded, repoPath] ) - const startWorkspaceButton = ( - - ) - const rightPanel = item.type === 'pr' ? (
- {startWorkspaceButton} - ) @@ -2797,26 +3372,28 @@ function ChecksTab({ [detailsByCheckKey, prRepo, repoId, repoPath] ) - const actions = ( -
- - - - - - Refresh checks - - + const refreshAction = ( + + + + + + Refresh checks + + + ) + const fixBrokenChecksAction = + failedChecks.length > 0 || fixingChecks ? ( - {failedChecks.length > 0 - ? 'Start the default AI agent on these checks' - : 'No broken checks to fix'} + Start the default AI agent on these checks + ) : null + const rerunAction = + list.length > 0 || rerunning ? ( - - - {linkCopied ? 'Copied' : 'Copy GitHub link'} - - - - - - - - Open on GitHub - - - - - - - - Close · Esc - - -
+
{workItem.type === 'pr' && ( )} + + + + + + {linkCopied ? 'Copied' : 'Copy GitHub link'} + + + + + + + + Open on GitHub + + + + + + + + Close · Esc + +
@@ -4680,6 +5339,11 @@ export default function GitHubItemDialog({ patchCachedPRChecks(detailsCacheKey, nextChecks) } }} + onBodyUpdated={(nextBody) => { + if (detailsCacheKey) { + patchCachedWorkItemBody(detailsCacheKey, nextBody) + } + }} onCommentAdded={appendOptimisticComment} onReviewersRequested={(nextReviewRequests) => { if (detailsCacheKey) { diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx index a877361f5f2..0c632615285 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx @@ -3,6 +3,45 @@ import { describe, expect, it } from 'vitest' import CommentMarkdown from './CommentMarkdown' describe('CommentMarkdown', () => { + it('autolinks same-repo GitHub issue references when repo context is provided', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('href="https://github.com/stablyai/orca/issues/2316"') + expect(markup).toContain(' { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('href="https://github.com/another-org/other-repo/issues/42"') + }) + + it('does not autolink GitHub issue references inside existing links or code', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('href="https://example.com/already-linked"') + expect(markup).not.toContain('href="https://github.com/stablyai/orca/issues/2316"') + expect(markup).not.toContain('href="https://github.com/stablyai/orca/issues/2317"') + }) + it('contains long PR body markdown inside its available width', () => { const markup = renderToStaticMarkup( ['rehypePlugins']> +type GitHubRepoReference = { + owner: string + repo: string +} + +type MarkdownTextNode = { + type: 'text' + value: string +} + +type MarkdownLinkNode = { + type: 'link' + url: string + title: null + children: MarkdownTextNode[] +} + +type MarkdownNode = { + type: string + value?: string + children?: MarkdownNode[] +} + // Why: sidebar comments are rendered at 11px in a narrow card, so we strip // block-level wrappers that add unwanted margins and only keep inline // formatting (bold, italic, code, links) plus compact lists and line breaks. @@ -170,6 +193,94 @@ const documentComponents: Components = { // with existing plain-text comments that rely on newline formatting. const remarkPlugins = [remarkGfm, remarkBreaks] +const GITHUB_REFERENCE_PATTERN = /(?:\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))?#([1-9][0-9]*)\b/g + +function createGitHubIssueUrl(owner: string, repo: string, number: string): string { + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}` +} + +function isEmbeddedGitHubReference(value: string, index: number): boolean { + if (index === 0) { + return false + } + return /[A-Za-z0-9_./-]/.test(value[index - 1] ?? '') +} + +function createGitHubReferenceLinkNode( + label: string, + owner: string, + repo: string, + number: string +): MarkdownLinkNode { + return { + type: 'link', + url: createGitHubIssueUrl(owner, repo, number), + title: null, + children: [{ type: 'text', value: label }] + } +} + +function splitGitHubReferenceText(value: string, defaultRepo: GitHubRepoReference): MarkdownNode[] { + const parts: MarkdownNode[] = [] + let cursor = 0 + + for (const match of value.matchAll(GITHUB_REFERENCE_PATTERN)) { + const label = match[0] + const index = match.index ?? 0 + if (isEmbeddedGitHubReference(value, index)) { + continue + } + + const owner = match[1] ?? defaultRepo.owner + const repo = match[2] ?? defaultRepo.repo + const number = match[3] + if (!number) { + continue + } + + if (index > cursor) { + parts.push({ type: 'text', value: value.slice(cursor, index) }) + } + parts.push(createGitHubReferenceLinkNode(label, owner, repo, number)) + cursor = index + label.length + } + + if (cursor === 0) { + return [{ type: 'text', value }] + } + if (cursor < value.length) { + parts.push({ type: 'text', value: value.slice(cursor) }) + } + return parts +} + +function transformGitHubReferenceChildren( + node: MarkdownNode, + defaultRepo: GitHubRepoReference +): void { + if (!node.children || node.type === 'link' || node.type === 'image') { + return + } + + const nextChildren: MarkdownNode[] = [] + for (const child of node.children) { + if (child.type === 'text' && child.value !== undefined) { + nextChildren.push(...splitGitHubReferenceText(child.value, defaultRepo)) + } else { + transformGitHubReferenceChildren(child, defaultRepo) + nextChildren.push(child) + } + } + + node.children = nextChildren +} + +export function remarkGitHubReferences( + defaultRepo: GitHubRepoReference +): () => (tree: MarkdownNode) => void { + return () => (tree) => transformGitHubReferenceChildren(tree, defaultRepo) +} + const commentMarkdownSanitizeSchema = { ...defaultSchema, tagNames: [...(defaultSchema.tagNames ?? []), 'details', 'summary', 'sub', 'sup', 'ins', 'kbd'], @@ -191,6 +302,7 @@ const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkd type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { content: string variant?: 'compact' | 'document' + githubRepo?: GitHubRepoReference | null } // Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref @@ -198,10 +310,14 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { // the child. Without forwarding both, the hover card cannot open or position. const CommentMarkdown = React.memo( React.forwardRef(function CommentMarkdown( - { content, className, variant = 'compact', ...rest }, + { content, className, variant = 'compact', githubRepo, ...rest }, ref ) { const components = variant === 'document' ? documentComponents : compactComponents + const activeRemarkPlugins = React.useMemo( + () => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins), + [githubRepo] + ) return (
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 383d89734f3..b99b6c7ee85 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -705,6 +705,7 @@ function createGitHubApi(): NonNullable['gh']> { mergePR: direct('github.mergePR'), updatePRState: direct('github.updatePRState'), requestPRReviewers: direct('github.requestPRReviewers'), + removePRReviewers: direct('github.removePRReviewers'), updateIssue: direct('github.updateIssue'), addIssueComment: direct('github.addIssueComment'), addPRReviewCommentReply: direct('github.addPRReviewCommentReply'), diff --git a/src/shared/types.ts b/src/shared/types.ts index 2a7fee4506b..d82f4794212 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -916,10 +916,9 @@ export type LinearComment = { export type GitHubIssueUpdate = { state?: 'open' | 'closed' title?: string - // Why: body writes are driven by the Project-mode slug-addressed path - // (`updateIssueBySlug`) because `gh issue edit` does not consistently - // cover every body-edit case the dialog needs; the repoPath-based - // `updateIssue` flow keeps ignoring `body` for backward compatibility. + // Why: body writes use the REST issue endpoint instead of `gh issue edit` + // because that command does not consistently cover every body-edit case the + // dialog needs. body?: string addLabels?: string[] removeLabels?: string[]