From 1f6eba959608ce5327cd6004eb2f500058c2c743 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 01:33:16 +0000 Subject: [PATCH 001/230] release: v1.4.23 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5ddd3db72f5..e76c4690944 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.23-rc.3", + "version": "1.4.23", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 38f354069618768b14699e3c9c58130dbc682948 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 23 May 2026 19:02:13 -0700 Subject: [PATCH 002/230] Fix Monaco diff editor disposal crash (#2704) --- .../lib/monaco-diff-editor-disposal.test.ts | 70 ++++++++++++++++++ .../src/lib/monaco-diff-editor-disposal.ts | 73 +++++++++++++++++++ src/renderer/src/lib/monaco-setup.ts | 2 + 3 files changed, 145 insertions(+) create mode 100644 src/renderer/src/lib/monaco-diff-editor-disposal.test.ts create mode 100644 src/renderer/src/lib/monaco-diff-editor-disposal.ts diff --git a/src/renderer/src/lib/monaco-diff-editor-disposal.test.ts b/src/renderer/src/lib/monaco-diff-editor-disposal.test.ts new file mode 100644 index 00000000000..b8df5cf159b --- /dev/null +++ b/src/renderer/src/lib/monaco-diff-editor-disposal.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { editor } from 'monaco-editor' +import { + guardMonacoDiffEditorDispose, + installMonacoDiffEditorDisposalGuard +} from './monaco-diff-editor-disposal' + +function createMockDiffEditor(dispose: () => void): editor.IStandaloneDiffEditor { + return { dispose } as unknown as editor.IStandaloneDiffEditor +} + +describe('guardMonacoDiffEditorDispose', () => { + it('contains Monaco disposal errors after invoking the real dispose path', () => { + const disposeError = new AggregateError( + [new Error('inner dispose failed')], + 'Encountered errors while disposing of store' + ) + const reportError = vi.fn() + const originalDispose = vi.fn(() => { + throw disposeError + }) + const diffEditor = createMockDiffEditor(originalDispose) + + guardMonacoDiffEditorDispose(diffEditor, reportError) + + expect(() => diffEditor.dispose()).not.toThrow() + expect(originalDispose).toHaveBeenCalledTimes(1) + expect(reportError).toHaveBeenCalledWith(disposeError) + }) + + it('does not repeatedly dispose an editor after the guarded disposal has run', () => { + const originalDispose = vi.fn() + const diffEditor = createMockDiffEditor(originalDispose) + + guardMonacoDiffEditorDispose(diffEditor) + diffEditor.dispose() + diffEditor.dispose() + + expect(originalDispose).toHaveBeenCalledTimes(1) + }) +}) + +describe('installMonacoDiffEditorDisposalGuard', () => { + it('wraps diff editors created by Monaco and keeps factory installation idempotent', () => { + const disposeError = new AggregateError( + [new Error('inner dispose failed')], + 'Encountered errors while disposing of store' + ) + const reportError = vi.fn() + const originalDispose = vi.fn(() => { + throw disposeError + }) + const createDiffEditor = vi.fn((_element: HTMLElement) => createMockDiffEditor(originalDispose)) + const monaco = { + editor: { + createDiffEditor + } + } + + installMonacoDiffEditorDisposalGuard(monaco, reportError) + installMonacoDiffEditorDisposalGuard(monaco, reportError) + + const diffEditor = monaco.editor.createDiffEditor({} as HTMLElement) + + expect(createDiffEditor).toHaveBeenCalledTimes(1) + expect(() => diffEditor.dispose()).not.toThrow() + expect(originalDispose).toHaveBeenCalledTimes(1) + expect(reportError).toHaveBeenCalledWith(disposeError) + }) +}) diff --git a/src/renderer/src/lib/monaco-diff-editor-disposal.ts b/src/renderer/src/lib/monaco-diff-editor-disposal.ts new file mode 100644 index 00000000000..262bd33d79d --- /dev/null +++ b/src/renderer/src/lib/monaco-diff-editor-disposal.ts @@ -0,0 +1,73 @@ +import type { editor } from 'monaco-editor' + +type CreateDiffEditor = ( + domElement: HTMLElement, + options?: editor.IStandaloneDiffEditorConstructionOptions, + override?: editor.IEditorOverrideServices +) => editor.IStandaloneDiffEditor + +type MonacoDiffEditorNamespace = { + editor: { + createDiffEditor: CreateDiffEditor + } +} + +type GuardedDiffEditor = editor.IStandaloneDiffEditor & { + __orcaDiffEditorDisposeGuardInstalled?: true +} + +type GuardedEditorNamespace = MonacoDiffEditorNamespace['editor'] & { + __orcaDiffEditorFactoryGuardInstalled?: true +} + +type DisposeErrorReporter = (error: unknown) => void + +function reportMonacoDiffDisposeError(error: unknown): void { + console.warn('[monaco] Diff editor disposal threw after teardown was requested', error) +} + +export function guardMonacoDiffEditorDispose( + diffEditor: editor.IStandaloneDiffEditor, + reportError: DisposeErrorReporter = reportMonacoDiffDisposeError +): editor.IStandaloneDiffEditor { + const guardedDiffEditor = diffEditor as GuardedDiffEditor + if (guardedDiffEditor.__orcaDiffEditorDisposeGuardInstalled) { + return diffEditor + } + + const originalDispose = diffEditor.dispose.bind(diffEditor) + let didDispose = false + + guardedDiffEditor.dispose = () => { + if (didDispose) { + return + } + didDispose = true + + try { + originalDispose() + } catch (error) { + // Why: Monaco's DisposableStore throws AggregateError after attempting + // teardown; letting it escape React cleanup can crash the renderer. + reportError(error) + } + } + guardedDiffEditor.__orcaDiffEditorDisposeGuardInstalled = true + + return diffEditor +} + +export function installMonacoDiffEditorDisposalGuard( + monaco: MonacoDiffEditorNamespace, + reportError?: DisposeErrorReporter +): void { + const editorNamespace = monaco.editor as GuardedEditorNamespace + if (editorNamespace.__orcaDiffEditorFactoryGuardInstalled) { + return + } + + const createDiffEditor = editorNamespace.createDiffEditor.bind(editorNamespace) + editorNamespace.createDiffEditor = ((...args: Parameters) => + guardMonacoDiffEditorDispose(createDiffEditor(...args), reportError)) as CreateDiffEditor + editorNamespace.__orcaDiffEditorFactoryGuardInstalled = true +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index f987a8df65b..34c86dd39e7 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -10,6 +10,7 @@ import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { registerAstroLanguage } from './monaco-languages/register-astro' import { registerSvelteLanguage } from './monaco-languages/register-svelte' import { registerVueLanguage } from './monaco-languages/register-vue' +import { installMonacoDiffEditorDisposalGuard } from './monaco-diff-editor-disposal' globalThis.MonacoEnvironment = { getWorker(_workerId, label) { @@ -71,6 +72,7 @@ monacoTS.javascriptDefaults.setCompilerOptions({ registerVueLanguage(monaco) registerSvelteLanguage(monaco) registerAstroLanguage(monaco) +installMonacoDiffEditorDisposalGuard(monaco) // Configure Monaco to use the locally bundled editor instead of CDN loader.config({ monaco }) From b8a7adf8edcfdb9926a6da92ac105dc40296df7e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 23 May 2026 19:07:07 -0700 Subject: [PATCH 003/230] Keep terminal shortcut notice visible Keep the terminal shortcut capture warning on screen until the user dismisses it or opens shortcut settings. --- ...nal-shortcut-capture-notification.test.tsx | 59 +++++++++++++++++++ ...terminal-shortcut-capture-notification.tsx | 4 ++ 2 files changed, 63 insertions(+) create mode 100644 src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx diff --git a/src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx b/src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx new file mode 100644 index 00000000000..1a49f6634f1 --- /dev/null +++ b/src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { showTerminalShortcutCaptureNotification } from './terminal-shortcut-capture-notification' + +const { toastMessage } = vi.hoisted(() => ({ + toastMessage: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + message: toastMessage + } +})) + +function createLocalStorage(): Storage { + const values = new Map() + + return { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => Array.from(values.keys())[index] ?? null, + removeItem: (key: string) => { + values.delete(key) + }, + setItem: (key: string, value: string) => { + values.set(key, value) + } + } +} + +describe('showTerminalShortcutCaptureNotification', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createLocalStorage()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('keeps the shortcut capture warning visible until dismissed', () => { + showTerminalShortcutCaptureNotification({ + actionId: 'tab.close', + platform: 'darwin' + }) + + expect(toast.message).toHaveBeenCalledWith( + 'Orca handled a terminal shortcut', + expect.objectContaining({ + duration: Infinity, + dismissible: true, + action: expect.objectContaining({ label: 'Open Shortcuts' }) + }) + ) + }) +}) diff --git a/src/renderer/src/lib/terminal-shortcut-capture-notification.tsx b/src/renderer/src/lib/terminal-shortcut-capture-notification.tsx index 541fb4a336e..0e814fcc39f 100644 --- a/src/renderer/src/lib/terminal-shortcut-capture-notification.tsx +++ b/src/renderer/src/lib/terminal-shortcut-capture-notification.tsx @@ -62,6 +62,10 @@ export function showTerminalShortcutCaptureNotification({ ) toast.message('Orca handled a terminal shortcut', { description: `${definition.title} (${bindingLabel}) can be changed in Keyboard Shortcuts.`, + // Why: this is the user's one-time rebind path for a captured shortcut; it + // should stay visible until they act on or dismiss it. + duration: Infinity, + dismissible: true, icon: , action: { label: 'Open Shortcuts', From 3dd281eb8e9e1a0ab6457432bb34562784d61585 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 23 May 2026 19:16:50 -0700 Subject: [PATCH 004/230] Hide branch names behind workspace card property --- src/main/persistence.test.ts | 1 + .../runtime/rpc/methods/client-ui.test.ts | 4 +- src/main/runtime/rpc/methods/client-ui.ts | 1 + .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 1 + .../WorktreeCard.quick-actions.test.tsx | 35 ++++++++++++++++ .../src/components/sidebar/WorktreeCard.tsx | 19 +++++---- .../WorktreeList.lineage-child-card.test.ts | 18 +++++++++ .../src/components/sidebar/WorktreeList.tsx | 40 +++++++++++-------- src/renderer/src/store/slices/ui.test.ts | 13 ++++++ src/shared/constants.test.ts | 12 +++++- src/shared/types.ts | 3 ++ src/shared/worktree-card-properties.ts | 1 + 12 files changed, 119 insertions(+), 29 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index f3a1c2fd7f8..9388428b82d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2111,6 +2111,7 @@ describe('Store', () => { expect(store.getUI().worktreeCardProperties).toContain('inline-agents') expect(store.getUI().worktreeCardProperties).toContain('linear-issue') expect(store.getUI().worktreeCardProperties).toContain('ports') + expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 7c591597c21..6cd4892a53e 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -136,7 +136,7 @@ describe('client UI RPC methods', () => { it('accepts persisted literal UI arrays and nested UI state', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), - worktreeCardProperties: ['status', 'inline-agents'], + worktreeCardProperties: ['status', 'branch', 'inline-agents'], statusBarItems: ['codex'], taskResumeState: { githubMode: 'items', @@ -163,7 +163,7 @@ describe('client UI RPC methods', () => { const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) const payload = { - worktreeCardProperties: ['status', 'inline-agents'], + worktreeCardProperties: ['status', 'branch', 'inline-agents'], statusBarItems: ['codex'], taskResumeState: { githubMode: 'items', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 0098722c14f..6e9d5705007 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -10,6 +10,7 @@ const UnknownRecordArray = z.array(UnknownRecord) const WorktreeCardProperty = z.enum([ 'status', 'unread', + 'branch', 'ci', 'issue', 'linear-issue', diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index bf27e5dcde3..32f32ae86c9 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -35,6 +35,7 @@ const GROUP_BY_OPTIONS = [ ] as const const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ + { id: 'branch', label: 'Branch name' }, { id: 'issue', label: 'GitHub ticket' }, { id: 'linear-issue', label: 'Linear issue' }, { id: 'pr', label: 'PR/MR link' }, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index 63b37973a07..cfe4aa3827c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -125,6 +125,41 @@ describe('WorktreeCard quick actions', () => { expect(markup).toContain('data-workspace-board-preserve-open=""') }) + it('hides the branch name from workspace card metadata by default', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Visible workspace') + expect(markup).not.toContain('feature/secret-ref') + expect(markup).not.toContain('secret-ref') + }) + + it('renders the branch name when the Branch name property is enabled', () => { + worktreeCardProperties = ['status', 'unread', 'branch'] + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Visible workspace') + expect(markup).toContain('feature/show-ref') + }) + it('shows delete as the top-right quick action for an inactive workspace', () => { const markup = renderToStaticMarkup( diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 45dc3d5467c..ec70021a761 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -254,6 +254,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showLinearIssue = cardProps.includes('linear-issue') const showComment = cardProps.includes('comment') const showPorts = cardProps.includes('ports') + const showBranch = cardProps.includes('branch') // Skip hosted-review fetches when the corresponding card sections are hidden. // This preference is purely presentational, so background refreshes would @@ -415,8 +416,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) // Why: deleting the active/current workspace or one with live activity is a // disruptive hover action; keep the quick action delete-only and passive. - const showDeleteQuickAction = - !isCurrentWorktree && !hasActiveActivity && !worktree.isMainWorktree + const showDeleteQuickAction = !isCurrentWorktree && !hasActiveActivity && !worktree.isMainWorktree const handleWorkspaceQuickAction = useCallback( (event: React.MouseEvent) => { event.preventDefault() @@ -624,10 +624,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} {/* Why: weight alone carries the unread signal; color stays - at text-foreground in both states so the title keeps - hierarchy against the muted branch row below (muting the - title as well flattened the card — same reasoning as the - repo chip comment below). */} + at text-foreground in both states so the title remains + the card's primary scan target even when optional branch + metadata is hidden. */} + + {detectedAgents.length > 0 && ( @@ -400,3 +403,25 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React ) } + +export function AgentStatusHooksSetting({ + settings, + updateSettings +}: AgentsPaneProps): React.JSX.Element { + const enabled = settings.agentStatusHooksEnabled !== false + return ( +
+ + updateSettings({ + agentStatusHooksEnabled: !enabled + }) + } + ariaLabel={AGENT_STATUS_HOOKS_TITLE} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/agent-status-hooks-copy.ts b/src/renderer/src/components/settings/agent-status-hooks-copy.ts new file mode 100644 index 00000000000..99c9cfb689b --- /dev/null +++ b/src/renderer/src/components/settings/agent-status-hooks-copy.ts @@ -0,0 +1,20 @@ +export const AGENT_STATUS_HOOKS_TITLE = 'Agent status hooks' + +export const AGENT_STATUS_HOOKS_DESCRIPTION = + 'Shows working, waiting, and done states in Orca. For Claude and Codex, Orca uses scoped settings and profiles so terminal sessions outside Orca keep your existing config. Turn off to remove Orca-managed hooks and stop reinstalling them.' + +export const AGENT_STATUS_HOOKS_SEARCH_KEYWORDS = [ + 'hooks', + 'status', + 'working', + 'waiting', + 'done', + 'remove', + 'restore', + 'scoped', + 'profile', + 'settings', + 'config', + 'claude', + 'codex' +] diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 279481783d9..1095348de24 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -4,6 +4,11 @@ import { getAgentAwakeDescription, getAgentAwakeSearchKeywords } from './agent-awake-copy' +import { + AGENT_STATUS_HOOKS_DESCRIPTION, + AGENT_STATUS_HOOKS_SEARCH_KEYWORDS, + AGENT_STATUS_HOOKS_TITLE +} from './agent-status-hooks-copy' export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { @@ -45,6 +50,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'detected' ] }, + { + title: AGENT_STATUS_HOOKS_TITLE, + description: AGENT_STATUS_HOOKS_DESCRIPTION, + keywords: AGENT_STATUS_HOOKS_SEARCH_KEYWORDS + }, { title: AGENT_AWAKE_TITLE, description: getAgentAwakeDescription(), diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 72ffda4e769..43e5b2175d2 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1792,7 +1792,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent: tuiAgent, prompt: startupPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) // Why: thread agent_started telemetry through the queued startup so @@ -1869,6 +1871,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepoIsGit, selectedRepoRequiresConnection, settings?.agentCmdOverrides, + settings?.agentStatusHooksEnabled, settings?.rightSidebarOpenByDefault, setRightSidebarOpen, setRightSidebarTab, @@ -2015,7 +2018,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent, draft: quickDraftPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) let startupPlan: ReturnType = null @@ -2033,7 +2038,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS prompt: quickPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) if (startupPlan && quickDraftPrompt) { startupPlan.draftPrompt = quickDraftPrompt @@ -2113,6 +2120,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepoIsGit, selectedRepoRequiresConnection, settings?.agentCmdOverrides, + settings?.agentStatusHooksEnabled, settings?.rightSidebarOpenByDefault, setRightSidebarOpen, setRightSidebarTab, diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 82cadca7fb5..4e196775862 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '@/runtime/runtime-compatibility-test-fixture' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const mockSpawn = vi.fn() @@ -21,6 +18,8 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkTrusted = vi.fn() const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const CLAUDE_SCOPED_SETTINGS = + 'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json"' function expectStablePaneSpawn(): string { const spawnArgs = mockSpawn.mock.calls[0]?.[0] @@ -74,11 +73,8 @@ describe('launchAgentBackgroundSession', () => { clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() mockRuntimeEnvironmentTransportCall.mockImplementation( - (args: RuntimeEnvironmentCallRequest) => { - return ( - createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) - ) - } + (args) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) ) state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } state.repos = [{ id: 'repo-1', connectionId: null }] @@ -128,7 +124,7 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo/worktree', - command: "claude 'run the automation'", + command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`, env: expect.objectContaining({ ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'wt-1' @@ -273,7 +269,10 @@ describe('launchAgentBackgroundSession', () => { dataSidecar('user@remote repo % ') vi.advanceTimersByTime(50) - expect(mockWrite).toHaveBeenCalledWith('pty-1', "claude 'run the automation'\r") + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + `${CLAUDE_SCOPED_SETTINGS} 'run the automation'\r` + ) } finally { vi.useRealTimers() } @@ -307,7 +306,7 @@ describe('launchAgentBackgroundSession', () => { method: 'terminal.create', params: expect.objectContaining({ worktree: 'wt-1', - command: "claude 'run the automation'", + command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`, env: expect.objectContaining({ ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1', diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 03b0659d73b..a35f13ae515 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -63,6 +63,7 @@ export async function launchAgentBackgroundSession( } } const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -75,7 +76,9 @@ export async function launchAgentBackgroundSession( prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -84,7 +87,9 @@ export async function launchAgentBackgroundSession( prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: !hasPrompt + allowEmptyPromptLaunch: !hasPrompt, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) } if (!startupPlan) { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 79170f70dc6..0a83c2b5f0c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -72,6 +72,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } = args const store = useAppStore.getState() const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -95,7 +96,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt submitPastedPrompt = true @@ -105,7 +108,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, draft: trimmedPrompt, cmdOverrides, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) if (draftLaunchPlan) { startupPlan = { @@ -121,7 +126,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } @@ -131,7 +138,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -140,7 +149,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: !hasPrompt + allowEmptyPromptLaunch: !hasPrompt, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 2d85e8b84dc..6cc05211029 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -298,7 +298,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom agent: effectiveAgent, draft: draftContent, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) if (draftLaunchPlan) { startupPlan = { @@ -315,7 +317,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom prompt: '', cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) } diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 1d8d68d637a..21dcf02fb01 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -29,7 +29,9 @@ export function buildOnboardingFolderAgentStartup( prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, platform: getClientPlatform(), - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false }) if (!startupPlan) { return undefined diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index aecc46612b7..523ef251669 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendOrcaCodexAgentStatusProfile } from '../../../shared/codex-profile' import type { Worktree } from '../../../shared/types' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' @@ -81,7 +82,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d5d5b8c3bbe..2cfce6d4998 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -96,7 +96,11 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): prompt: '', cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: + useAppStore.getState().settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: + useAppStore.getState().settings?.agentStatusHooksEnabled !== false }) if (!startupPlan) { return undefined diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index 0634a8f951c..e2aaea6fb84 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { appendOrcaCodexAgentStatusProfile } from '../../../../shared/codex-profile' import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { createTestStore, makeWorktree } from './store-test-helpers' @@ -57,7 +58,7 @@ describe('repo slice skipped-onboarding folder startup', () => { 'folder-1::/folder', { startup: { - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/claude-settings.ts b/src/shared/claude-settings.ts new file mode 100644 index 00000000000..fe82752beb5 --- /dev/null +++ b/src/shared/claude-settings.ts @@ -0,0 +1,19 @@ +export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV = 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' +export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE = 'claude-agent-status-settings.json' + +type ClaudeSettingsShell = 'posix' | 'powershell' | 'cmd' + +export function appendOrcaClaudeAgentStatusSettings( + command: string, + shell: ClaudeSettingsShell +): string { + // Why: Claude's --settings is a per-process overlay. CLAUDE_CONFIG_DIR + // would fork auth/session state and normal external Claude launches. + if (shell === 'powershell') { + return `${command} --settings $Env:${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}` + } + if (shell === 'cmd') { + return `${command} --settings "%${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}%"` + } + return `${command} --settings "$HOME/.orca/agent-hooks/${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE}"` +} diff --git a/src/shared/codex-profile.ts b/src/shared/codex-profile.ts new file mode 100644 index 00000000000..c3268ac066f --- /dev/null +++ b/src/shared/codex-profile.ts @@ -0,0 +1,5 @@ +export const ORCA_CODEX_AGENT_STATUS_PROFILE = 'orca-agent-status' + +export function appendOrcaCodexAgentStatusProfile(command: string): string { + return `${command} --profile-v2 ${ORCA_CODEX_AGENT_STATUS_PROFILE}` +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8d403832335..a5b62e79b2e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -250,6 +250,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, + agentStatusHooksEnabled: true, keepComputerAwakeWhileAgentsRun: false, // Why: 'auto' runs a layout-aware probe at boot (see // src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 5313a410310..f244f3bef52 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -36,6 +36,79 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') }) + it('launches Codex with the Orca profile when agent status hooks are enabled', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'linux', + useOrcaCodexAgentStatusProfile: true + }) + + expect(plan?.launchCommand).toBe("codex --profile-v2 orca-agent-status 'fix it'") + }) + + it('launches Claude with the Orca settings file when agent status hooks are enabled', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'linux', + useOrcaClaudeAgentStatusSettings: true + }) + + expect(plan?.launchCommand).toBe( + 'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json" \'fix it\'' + ) + }) + + it('uses the target shell syntax for Claude settings injection', () => { + expect( + buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'win32', + useOrcaClaudeAgentStatusSettings: true + })?.launchCommand + ).toBe("claude --settings $Env:ORCA_CLAUDE_AGENT_STATUS_SETTINGS 'fix it'") + + expect( + buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd', + useOrcaClaudeAgentStatusSettings: true + })?.launchCommand + ).toBe('claude --settings "%ORCA_CLAUDE_AGENT_STATUS_SETTINGS%" "fix it"') + }) + + it('leaves Claude command overrides untouched', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: { claude: 'claude --dangerously-skip-permissions' }, + platform: 'linux', + useOrcaClaudeAgentStatusSettings: true + }) + + expect(plan?.launchCommand).toBe("claude --dangerously-skip-permissions 'fix it'") + }) + + it('leaves Codex command overrides untouched', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: 'fix it', + cmdOverrides: { codex: 'codex --profile work' }, + platform: 'linux', + useOrcaCodexAgentStatusProfile: true + }) + + expect(plan?.launchCommand).toBe("codex --profile work 'fix it'") + }) + it('clears draft environment variables with the target shell syntax', () => { expect( buildAgentDraftLaunchPlan({ diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 31b8ebb5065..1fc1b13dd64 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,4 +1,6 @@ import { isShellProcess } from './agent-detection' +import { appendOrcaClaudeAgentStatusSettings } from './claude-settings' +import { appendOrcaCodexAgentStatusProfile } from './codex-profile' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './types' @@ -44,6 +46,26 @@ function commandSeparator(shell: AgentStartupShell): string { return shell === 'cmd' ? ' & ' : '; ' } +function resolveBaseCommand(args: { + agent: TuiAgent + cmdOverrides: Partial> + shell: AgentStartupShell + useOrcaClaudeAgentStatusSettings?: boolean + useOrcaCodexAgentStatusProfile?: boolean +}): string { + const override = args.cmdOverrides[args.agent] + if (override) { + return override + } + const command = TUI_AGENT_CONFIG[args.agent].launchCmd + if (args.agent === 'claude' && args.useOrcaClaudeAgentStatusSettings) { + return appendOrcaClaudeAgentStatusSettings(command, args.shell) + } + return args.agent === 'codex' && args.useOrcaCodexAgentStatusProfile + ? appendOrcaCodexAgentStatusProfile(command) + : command +} + export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -51,12 +73,20 @@ export function buildAgentStartupPlan(args: { platform: NodeJS.Platform shell?: AgentStartupShell allowEmptyPromptLaunch?: boolean + useOrcaClaudeAgentStatusSettings?: boolean + useOrcaCodexAgentStatusProfile?: boolean }): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] - const baseCommand = cmdOverrides[agent] ?? config.launchCmd + const baseCommand = resolveBaseCommand({ + agent, + cmdOverrides, + shell, + useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, + useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile + }) if (!trimmedPrompt) { if (!allowEmptyPromptLaunch) { @@ -129,6 +159,8 @@ export function buildAgentDraftLaunchPlan(args: { cmdOverrides: Partial> platform: NodeJS.Platform shell?: AgentStartupShell + useOrcaClaudeAgentStatusSettings?: boolean + useOrcaCodexAgentStatusProfile?: boolean }): AgentDraftLaunchPlan | null { const { agent, draft, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) @@ -137,7 +169,13 @@ export function buildAgentDraftLaunchPlan(args: { if (!trimmed) { return null } - const baseCommand = cmdOverrides[agent] ?? config.launchCmd + const baseCommand = resolveBaseCommand({ + agent, + cmdOverrides, + shell, + useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, + useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile + }) if (config.draftPromptFlag) { const quoted = quoteStartupArg(trimmed, shell) return { diff --git a/src/shared/types.ts b/src/shared/types.ts index 8fe5ba7b12c..57da943379c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1770,6 +1770,9 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> + /** Why: disabling must persist so startup does not reinstall global agent + * hook entries right after the user removes them from Settings or CLI. */ + agentStatusHooksEnabled: boolean /** When true, Orca requests local awake assertions while hook-reported agents are working. */ keepComputerAwakeWhileAgentsRun: boolean /** Why: macOS terminals must choose between letting Option compose layout From 5ec39aebd1e35b681ec1d380a9e492b33b9f5349 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 23 May 2026 21:30:05 -0700 Subject: [PATCH 011/230] fix: address review findings (#2737) --- .../tab-bar/TabBarQuickCommandsButton.tsx | 13 +++++++++---- .../src/components/tab-group/TabGroupPanel.tsx | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index eba28d405a0..ac08356d285 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -95,6 +95,9 @@ export function TabBarQuickCommandsButton({ const totalVisible = repoCommands.length + globalCommands.length const hasAnyCommands = totalVisible > 0 + // Why: the bordered quick-command chip reads lower than adjacent icon-only + // toolbar buttons in the 32px tab row; lift the chip to match optically. + const quickCommandToolbarLiftClass = '-translate-y-0.5' const handleOpenChange = (next: boolean): void => { setMenuOpen(next) @@ -152,7 +155,10 @@ export function TabBarQuickCommandsButton({ command: createTerminalQuickCommandDraft({ type: 'repo', repoId }) }) } - className="my-auto flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" + className={cn( + quickCommandToolbarLiftClass, + 'flex h-7 shrink-0 items-center gap-1 self-center rounded-md px-1.5 leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' + )} aria-label="Add quick command" > @@ -175,10 +181,9 @@ export function TabBarQuickCommandsButton({ ) } - const splitButtonClass = - 'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground' + const splitButtonClass = `flex h-7 ${quickCommandToolbarLiftClass} shrink-0 items-stretch self-center overflow-hidden rounded-md leading-none text-muted-foreground ring-1 ring-inset ring-border/60` const innerButtonBase = - 'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' + 'flex h-full items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' const renderItem = (command: TerminalQuickCommand): React.JSX.Element => ( Date: Sat, 23 May 2026 21:44:40 -0700 Subject: [PATCH 012/230] Fix SSH target removal state cleanup (#2710) --- src/main/providers/ssh-pty-id.ts | 56 +---- .../src/components/settings/SshPane.tsx | 4 + src/renderer/src/hooks/useIpcEvents.test.ts | 130 +++++++++- src/renderer/src/hooks/useIpcEvents.ts | 181 ++++++++------ .../src/store/slices/ssh-target-cleanup.ts | 205 +++++++++++++++ src/renderer/src/store/slices/ssh.test.ts | 234 ++++++++++++++++++ src/renderer/src/store/slices/ssh.ts | 20 +- src/shared/ssh-pty-id.ts | 54 ++++ 8 files changed, 752 insertions(+), 132 deletions(-) create mode 100644 src/renderer/src/store/slices/ssh-target-cleanup.ts create mode 100644 src/renderer/src/store/slices/ssh.test.ts create mode 100644 src/shared/ssh-pty-id.ts diff --git a/src/main/providers/ssh-pty-id.ts b/src/main/providers/ssh-pty-id.ts index 992111cd664..7b1f430c12c 100644 --- a/src/main/providers/ssh-pty-id.ts +++ b/src/main/providers/ssh-pty-id.ts @@ -1,54 +1,2 @@ -const SSH_PTY_ID_PREFIX = 'ssh:' -const SSH_PTY_ID_SEPARATOR = '@@' - -// Why: SSH relays allocate target-local ids like "pty-1"; app-wide routing -// needs the target id embedded so two relays cannot collide after restore. -export type ParsedSshPtyId = { - connectionId: string - relayPtyId: string -} - -export function parseAppSshPtyId(ptyId: string): ParsedSshPtyId | null { - if (!ptyId.startsWith(SSH_PTY_ID_PREFIX)) { - return null - } - const separatorIndex = ptyId.indexOf(SSH_PTY_ID_SEPARATOR, SSH_PTY_ID_PREFIX.length) - if (separatorIndex === -1) { - return null - } - const encodedConnectionId = ptyId.slice(SSH_PTY_ID_PREFIX.length, separatorIndex) - const relayPtyId = ptyId.slice(separatorIndex + SSH_PTY_ID_SEPARATOR.length) - if (!encodedConnectionId || !relayPtyId) { - return null - } - try { - return { - connectionId: decodeURIComponent(encodedConnectionId), - relayPtyId - } - } catch { - return null - } -} - -export function toAppSshPtyId(connectionId: string, relayPtyId: string): string { - const parsed = parseAppSshPtyId(relayPtyId) - if (parsed) { - if (parsed.connectionId !== connectionId) { - throw new Error(`PTY ${relayPtyId} belongs to SSH connection "${parsed.connectionId}"`) - } - return relayPtyId - } - return `${SSH_PTY_ID_PREFIX}${encodeURIComponent(connectionId)}${SSH_PTY_ID_SEPARATOR}${relayPtyId}` -} - -export function toRelaySshPtyId(connectionId: string, ptyId: string): string { - const parsed = parseAppSshPtyId(ptyId) - if (!parsed) { - return ptyId - } - if (parsed.connectionId !== connectionId) { - throw new Error(`PTY ${ptyId} belongs to SSH connection "${parsed.connectionId}"`) - } - return parsed.relayPtyId -} +export { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../../shared/ssh-pty-id' +export type { ParsedSshPtyId } from '../../shared/ssh-pty-id' diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 00526abafe6..c415def4480 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -53,6 +53,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const [testingIds, setTestingIds] = useState>(new Set()) const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) + const clearRemovedSshTargetState = useAppStore((s) => s.clearRemovedSshTargetState) const loadTargets = useCallback( async (opts?: { signal?: AbortSignal }) => { @@ -152,6 +153,9 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const handleRemove = async (id: string): Promise => { try { await removeSshTargetWithBestEffortCleanup(window.api.ssh, id) + // Why: a deleted passphrase-gated target may still have deferred + // reconnect metadata; clear it so focused SSH tabs stop retrying it. + clearRemovedSshTargetState(id) toast.success('Target removed') await loadTargets() } catch (err) { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 25a073dad68..3e3f15881b1 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -329,6 +329,22 @@ describe('useIpcEvents updater integration', () => { it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { const clearTabPtyId = vi.fn() const setSshConnectionState = vi.fn() + const setSshTargetsMetadata = vi.fn() + const clearRemovedSshTargetState = vi.fn() + const pendingListTargets: { + resolve: (targets: { id: string; label: string }[]) => void + reject: (err: unknown) => void + }[] = [] + let listTargetsCallCount = 0 + const listTargets = vi.fn(() => { + listTargetsCallCount += 1 + if (listTargetsCallCount === 1) { + return Promise.resolve([{ id: 'conn-1', label: 'Remote' }]) + } + return new Promise<{ id: string; label: string }[]>((resolve, reject) => { + pendingListTargets.push({ resolve, reject }) + }) + }) const sshStateListenerRef: { current: ((data: { targetId: string; state: unknown }) => void) | null } = { @@ -355,12 +371,14 @@ describe('useIpcEvents updater integration', () => { setRateLimitsFromPush: vi.fn(), setSshConnectionState, setSshTargetLabels: vi.fn(), + setSshTargetsMetadata, setPortForwards: vi.fn(), clearPortForwards: vi.fn(), setDetectedPorts: vi.fn(), enqueueSshCredentialRequest: vi.fn(), removeSshCredentialRequest: vi.fn(), clearRemoteDetectedAgents: vi.fn(), + clearRemovedSshTargetState, clearTabPtyId, repos: [{ id: 'repo-1', connectionId: 'conn-1' }], worktreesByRepo: { @@ -500,7 +518,7 @@ describe('useIpcEvents updater integration', () => { onBrowserDriverChanged: () => () => {} }, ssh: { - listTargets: () => Promise.resolve([]), + listTargets, listPortForwards: () => Promise.resolve([]), listDetectedPorts: () => Promise.resolve([]), getState: () => Promise.resolve(null), @@ -538,6 +556,116 @@ describe('useIpcEvents updater integration', () => { expect(clearTabPtyId).toHaveBeenCalledWith('tab-1') expect(clearTabPtyId).not.toHaveBeenCalledWith('tab-2') expect(storeState.clearRemoteDetectedAgents).toHaveBeenCalledWith('conn-1') + + setSshConnectionState.mockClear() + sshStateListenerRef.current({ + targetId: 'conn-removed', + state: { status: 'disconnected', error: null, reconnectAttempt: 0 } + }) + expect(pendingListTargets).toHaveLength(1) + pendingListTargets.shift()!.resolve([]) + await Promise.resolve() + await Promise.resolve() + + expect(setSshConnectionState).not.toHaveBeenCalled() + expect(clearRemovedSshTargetState).toHaveBeenCalledWith('conn-removed') + + clearRemovedSshTargetState.mockClear() + setSshConnectionState.mockClear() + + const connectingState = { + targetId: 'conn-new', + status: 'connecting', + error: null, + reconnectAttempt: 0 + } + const errorState = { + targetId: 'conn-new', + status: 'error', + error: 'Connection failed', + reconnectAttempt: 0 + } + sshStateListenerRef.current({ + targetId: 'conn-new', + state: connectingState + }) + sshStateListenerRef.current({ + targetId: 'conn-new', + state: errorState + }) + + expect(pendingListTargets).toHaveLength(2) + const resolveConnectingTargets = pendingListTargets.shift()!.resolve + const resolveErrorTargets = pendingListTargets.shift()!.resolve + const targets = [{ id: 'conn-new', label: 'New remote' }] + resolveErrorTargets(targets) + await Promise.resolve() + await Promise.resolve() + resolveConnectingTargets(targets) + await Promise.resolve() + await Promise.resolve() + + expect(clearRemovedSshTargetState).not.toHaveBeenCalled() + expect(setSshTargetsMetadata).toHaveBeenCalledWith(targets) + expect(setSshConnectionState).toHaveBeenCalledTimes(1) + expect(setSshConnectionState).toHaveBeenCalledWith('conn-new', errorState) + + setSshTargetsMetadata.mockClear() + setSshConnectionState.mockClear() + + const staleState = { + targetId: 'conn-known-late', + status: 'connecting', + error: null, + reconnectAttempt: 0 + } + const latestState = { + targetId: 'conn-known-late', + status: 'error', + error: 'Connection failed', + reconnectAttempt: 1 + } + sshStateListenerRef.current({ + targetId: 'conn-known-late', + state: staleState + }) + expect(pendingListTargets).toHaveLength(1) + storeState.sshTargetLabels.set('conn-known-late', 'Late remote') + sshStateListenerRef.current({ + targetId: 'conn-known-late', + state: latestState + }) + pendingListTargets.shift()!.resolve([{ id: 'conn-known-late', label: 'Late remote' }]) + await Promise.resolve() + await Promise.resolve() + + expect(setSshTargetsMetadata).not.toHaveBeenCalled() + expect(setSshConnectionState).toHaveBeenCalledTimes(1) + expect(setSshConnectionState).toHaveBeenCalledWith('conn-known-late', latestState) + + setSshConnectionState.mockClear() + const refreshFailureState = { + targetId: 'conn-refresh-failure', + status: 'connecting', + error: null, + reconnectAttempt: 0 + } + sshStateListenerRef.current({ + targetId: 'conn-refresh-failure', + state: refreshFailureState + }) + expect(pendingListTargets).toHaveLength(1) + pendingListTargets.shift()!.reject(new Error('first refresh failed')) + await Promise.resolve() + expect(pendingListTargets).toHaveLength(1) + pendingListTargets.shift()!.reject(new Error('second refresh failed')) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(setSshConnectionState).toHaveBeenCalledTimes(1) + expect(setSshConnectionState).toHaveBeenCalledWith('conn-refresh-failure', refreshFailureState) }) it('activates the target worktree when CLI creates a terminal there', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 1db0c4867ee..fae33213b74 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1692,93 +1692,124 @@ export function useIpcEvents(): void { }) ) + const applySshConnectionStateChange = (targetId: string, state: SshConnectionState): void => { + const store = useAppStore.getState() + store.setSshConnectionState(targetId, state) + const remoteRepos = store.repos.filter((r) => r.connectionId === targetId) + + if (['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)) { + // Why: the remote agent list is tied to a live SSH connection. On + // disconnect the relay is gone, so clear the cached list and dedup + // promise. When the user reconnects and opens the quick-launch menu, + // ensureRemoteDetectedAgents will re-detect against the new relay. + store.clearRemoteDetectedAgents(targetId) + + // Why: defensive — clear port forward and detected port state in case + // the broadcast from removeAllForwards races with the state change. + store.clearPortForwards(targetId) + store.setDetectedPorts(targetId, []) + + // Why: an explicit disconnect or terminal failure tears down the SSH + // PTY provider without emitting per-PTY exit events. Clear the stale + // PTY ids in renderer state so a later reconnect remounts TerminalPane + // instead of keeping a dead remote PTY attached to the tab. + const remoteWorktreeIds = new Set( + Object.values(store.worktreesByRepo) + .flat() + .filter((w) => remoteRepos.some((r) => r.id === w.repoId)) + .map((w) => w.id) + ) + for (const worktreeId of remoteWorktreeIds) { + const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] + for (const tab of tabs) { + if (tab.ptyId) { + useAppStore.getState().clearTabPtyId(tab.id) + } + } + } + } + + if (state.status === 'connected') { + void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { + await useAppStore.getState().fetchWorktreeLineage() + // Why: terminal panes that failed to spawn (no PTY provider on cold + // start) sit inert. Bumping generation forces TerminalPane to remount + // and retry pty:spawn. Only bump tabs with no live ptyId. + const freshStore = useAppStore.getState() + const remoteRepoIds = new Set(remoteRepos.map((r) => r.id)) + const worktreeIds = Object.values(freshStore.worktreesByRepo) + .flat() + .filter((w) => remoteRepoIds.has(w.repoId)) + .map((w) => w.id) + + for (const worktreeId of worktreeIds) { + const tabs = freshStore.tabsByWorktree[worktreeId] ?? [] + const hasDead = tabs.some((t) => !t.ptyId) + if (hasDead) { + useAppStore.setState((s) => ({ + tabsByWorktree: { + ...s.tabsByWorktree, + [worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) => + t.ptyId ? t : { ...t, generation: (t.generation ?? 0) + 1 } + ) + } + })) + } + } + void syncRemoteWorkspaceAfterConnect(targetId).catch((err) => { + useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { + phase: 'error', + message: err instanceof Error ? err.message : 'Workspace sync failed' + }) + }) + }) + } + } + + let sshTargetStateEventId = 0 + const latestSshTargetStateEventByTargetId = new Map() + unsubs.push( window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => { const store = useAppStore.getState() const state = data.state as SshConnectionState - store.setSshConnectionState(data.targetId, state) - const remoteRepos = store.repos.filter((r) => r.connectionId === data.targetId) - - // Why: targets added after boot aren't in the labels map. Re-fetch - // so the status bar popover shows the new target immediately. + const stateEventId = ++sshTargetStateEventId + latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) if (!store.sshTargetLabels.has(data.targetId)) { + // Why: targets added after boot aren't in the labels map, while + // removed targets can still race a final disconnect event. Confirm + // with main before mutating renderer state for an unknown target id. window.api.ssh .listTargets() + // Why: this refresh is now a deletion guard, not just a label fetch. + // Retry once so a transient IPC failure does not drop a real added-target event. + .catch(() => window.api.ssh.listTargets()) .then((targets) => { - useAppStore.getState().setSshTargetsMetadata(targets) - }) - .catch(() => {}) - } - - if ( - ['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status) - ) { - // Why: the remote agent list is tied to a live SSH connection. On - // disconnect the relay is gone, so clear the cached list and dedup - // promise. When the user reconnects and opens the quick-launch menu, - // ensureRemoteDetectedAgents will re-detect against the new relay. - store.clearRemoteDetectedAgents(data.targetId) - - // Why: defensive — clear port forward and detected port state in case - // the broadcast from removeAllForwards races with the state change. - store.clearPortForwards(data.targetId) - store.setDetectedPorts(data.targetId, []) - - // Why: an explicit disconnect or terminal failure tears down the SSH - // PTY provider without emitting per-PTY exit events. Clear the stale - // PTY ids in renderer state so a later reconnect remounts TerminalPane - // instead of keeping a dead remote PTY attached to the tab. - const remoteWorktreeIds = new Set( - Object.values(store.worktreesByRepo) - .flat() - .filter((w) => remoteRepos.some((r) => r.id === w.repoId)) - .map((w) => w.id) - ) - for (const worktreeId of remoteWorktreeIds) { - const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] - for (const tab of tabs) { - if (tab.ptyId) { - useAppStore.getState().clearTabPtyId(tab.id) + if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { + return } - } - } - } - - if (state.status === 'connected') { - void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { - await useAppStore.getState().fetchWorktreeLineage() - // Why: terminal panes that failed to spawn (no PTY provider on cold - // start) sit inert. Bumping generation forces TerminalPane to remount - // and retry pty:spawn. Only bump tabs with no live ptyId. - const freshStore = useAppStore.getState() - const remoteRepoIds = new Set(remoteRepos.map((r) => r.id)) - const worktreeIds = Object.values(freshStore.worktreesByRepo) - .flat() - .filter((w) => remoteRepoIds.has(w.repoId)) - .map((w) => w.id) - - for (const worktreeId of worktreeIds) { - const tabs = freshStore.tabsByWorktree[worktreeId] ?? [] - const hasDead = tabs.some((t) => !t.ptyId) - if (hasDead) { - useAppStore.setState((s) => ({ - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) => - t.ptyId ? t : { ...t, generation: (t.generation ?? 0) + 1 } - ) - } - })) + latestSshTargetStateEventByTargetId.delete(data.targetId) + const latestStore = useAppStore.getState() + if (!targets.some((target) => target.id === data.targetId)) { + // Why: disconnect/state events can race after target removal. + // Treat absence from main's target list as deletion, not a new target. + latestStore.clearRemovedSshTargetState(data.targetId) + return } - } - void syncRemoteWorkspaceAfterConnect(data.targetId).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(data.targetId, { - phase: 'error', - message: err instanceof Error ? err.message : 'Workspace sync failed' - }) + latestStore.setSshTargetsMetadata(targets) + applySshConnectionStateChange(data.targetId, state) }) - }) + .catch(() => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state) + } + }) + return } + + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state) }) ) diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts new file mode 100644 index 00000000000..4ac4c97c74e --- /dev/null +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -0,0 +1,205 @@ +import type { AppState } from '../types' +import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' + +export function sshConnectionStatesEqual( + a: SshConnectionState | undefined, + b: SshConnectionState +): boolean { + return ( + a?.targetId === b.targetId && + a?.status === b.status && + a?.error === b.error && + a?.reconnectAttempt === b.reconnectAttempt + ) +} + +export function sshTargetLabelsEqual( + labels: Map, + targets: Pick[] +): boolean { + if (labels.size !== targets.length) { + return false + } + return targets.every((target) => labels.get(target.id) === target.label) +} + +function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set { + const repoIds = new Set( + state.repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id) + ) + const tabIds = new Set() + for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + if (!repoIds.has(repoId)) { + continue + } + for (const worktree of worktrees) { + for (const tab of state.tabsByWorktree[worktree.id] ?? []) { + tabIds.add(tab.id) + } + } + } + return tabIds +} + +function isSshTargetSessionId(sessionId: string, targetId: string): boolean { + return parseAppSshPtyId(sessionId)?.connectionId === targetId +} + +function shouldRemoveDeferredSshSession( + tabId: string, + sessionId: string, + targetId: string, + targetTabIds: Set +): boolean { + return targetTabIds.has(tabId) || isSshTargetSessionId(sessionId, targetId) +} + +function clearSshTargetTabPtyState( + state: AppState, + targetId: string, + targetTabIds: Set +): Pick< + AppState, + | 'tabsByWorktree' + | 'ptyIdsByTabId' + | 'lastKnownRelayPtyIdByTabId' + | 'pendingCodexPaneRestartIds' + | 'codexRestartNoticeByPtyId' +> & { changed: boolean } { + let nextTabsByWorktree = state.tabsByWorktree + const nextPtyIdsByTabId = { ...state.ptyIdsByTabId } + const nextLastKnownRelayPtyIdByTabId = { ...state.lastKnownRelayPtyIdByTabId } + const nextPendingCodexPaneRestartIds = { ...state.pendingCodexPaneRestartIds } + const nextCodexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId } + let changed = false + + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + let nextTabs = tabs + for (const [index, tab] of tabs.entries()) { + const lastKnownPtyId = state.lastKnownRelayPtyIdByTabId[tab.id] + const ptyIds = [ + ...new Set([ + ...(state.ptyIdsByTabId[tab.id] ?? []), + ...(tab.ptyId ? [tab.ptyId] : []), + ...(lastKnownPtyId ? [lastKnownPtyId] : []) + ]) + ] + const shouldClearTab = + targetTabIds.has(tab.id) || ptyIds.some((ptyId) => isSshTargetSessionId(ptyId, targetId)) + if (!shouldClearTab) { + continue + } + if (!tab.ptyId && ptyIds.length === 0 && nextLastKnownRelayPtyIdByTabId[tab.id] == null) { + continue + } + changed = true + if (nextTabs === tabs) { + nextTabs = [...tabs] + } + const { pendingActivationSpawn: _pendingActivationSpawn, ...tabWithoutActivationSpawn } = tab + void _pendingActivationSpawn + nextTabs[index] = { ...tabWithoutActivationSpawn, ptyId: null } + nextPtyIdsByTabId[tab.id] = [] + delete nextLastKnownRelayPtyIdByTabId[tab.id] + for (const ptyId of ptyIds) { + delete nextPendingCodexPaneRestartIds[ptyId] + delete nextCodexRestartNoticeByPtyId[ptyId] + } + } + if (nextTabs !== tabs) { + nextTabsByWorktree = { ...nextTabsByWorktree, [worktreeId]: nextTabs } + } + } + + return { + changed, + tabsByWorktree: nextTabsByWorktree, + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId + } +} + +export function buildRemovedSshTargetCleanupPatch( + state: AppState, + targetId: string +): Partial | null { + const targetTabIds = collectSshTargetTerminalTabIds(state, targetId) + const tabPtyState = clearSshTargetTabPtyState(state, targetId, targetTabIds) + const nextDeferredSessions: Record = {} + let removedDeferredSession = false + for (const [tabId, sessionId] of Object.entries(state.deferredSshSessionIdsByTabId)) { + if (shouldRemoveDeferredSshSession(tabId, sessionId, targetId, targetTabIds)) { + removedDeferredSession = true + continue + } + nextDeferredSessions[tabId] = sessionId + } + + const nextDeferredTargets = state.deferredSshReconnectTargets.filter((id) => id !== targetId) + const nextConnectionStates = new Map(state.sshConnectionStates) + const removedConnectionState = nextConnectionStates.delete(targetId) + const nextLabels = new Map(state.sshTargetLabels) + const removedLabel = nextLabels.delete(targetId) + const nextHydrated = new Set(state.remoteWorkspaceHydratedTargetIds) + const removedHydrated = nextHydrated.delete(targetId) + const removedSyncStatus = Object.prototype.hasOwnProperty.call( + state.remoteWorkspaceSyncStatusByTargetId, + targetId + ) + const removedPortForwards = Object.prototype.hasOwnProperty.call( + state.portForwardsByConnection, + targetId + ) + const removedDetectedPorts = Object.prototype.hasOwnProperty.call( + state.detectedPortsByConnection, + targetId + ) + const nextSyncStatus = { ...state.remoteWorkspaceSyncStatusByTargetId } + delete nextSyncStatus[targetId] + const nextPortForwards = { ...state.portForwardsByConnection } + delete nextPortForwards[targetId] + const nextDetectedPorts = { ...state.detectedPortsByConnection } + delete nextDetectedPorts[targetId] + const nextCredentialQueue = state.sshCredentialQueue.filter((req) => req.targetId !== targetId) + const removedCredentialRequest = nextCredentialQueue.length !== state.sshCredentialQueue.length + const removedDeferredTarget = + nextDeferredTargets.length !== state.deferredSshReconnectTargets.length + const changed = + removedConnectionState || + removedLabel || + removedHydrated || + removedSyncStatus || + removedPortForwards || + removedDetectedPorts || + tabPtyState.changed || + removedCredentialRequest || + removedDeferredTarget || + removedDeferredSession + if (!changed) { + return null + } + + return { + ...(removedConnectionState ? { sshConnectionStates: nextConnectionStates } : {}), + ...(removedLabel ? { sshTargetLabels: nextLabels } : {}), + ...(removedHydrated ? { remoteWorkspaceHydratedTargetIds: nextHydrated } : {}), + ...(removedSyncStatus ? { remoteWorkspaceSyncStatusByTargetId: nextSyncStatus } : {}), + ...(removedPortForwards ? { portForwardsByConnection: nextPortForwards } : {}), + ...(removedDetectedPorts ? { detectedPortsByConnection: nextDetectedPorts } : {}), + ...(tabPtyState.changed + ? { + tabsByWorktree: tabPtyState.tabsByWorktree, + ptyIdsByTabId: tabPtyState.ptyIdsByTabId, + lastKnownRelayPtyIdByTabId: tabPtyState.lastKnownRelayPtyIdByTabId, + pendingCodexPaneRestartIds: tabPtyState.pendingCodexPaneRestartIds, + codexRestartNoticeByPtyId: tabPtyState.codexRestartNoticeByPtyId + } + : {}), + ...(removedCredentialRequest ? { sshCredentialQueue: nextCredentialQueue } : {}), + ...(removedDeferredTarget ? { deferredSshReconnectTargets: nextDeferredTargets } : {}), + ...(removedDeferredSession ? { deferredSshSessionIdsByTabId: nextDeferredSessions } : {}) + } +} diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts new file mode 100644 index 00000000000..af8f17fd30d --- /dev/null +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { createTestStore, makeTab, makeWorktree, TEST_REPO } from './store-test-helpers' + +describe('createSshSlice', () => { + it('clears renderer state and deferred reconnect metadata for a removed SSH target', () => { + const store = createTestStore() + const targetId = 'ssh-1' + const otherTargetId = 'ssh-2' + const worktreeId = 'repo-ssh::/remote/project' + const otherWorktreeId = 'repo-other::/remote/project' + const removedPtyId = toAppSshPtyId(targetId, 'pty-live') + const staleLastKnownPtyId = toAppSshPtyId(targetId, 'pty-last-known') + const otherPtyId = toAppSshPtyId(otherTargetId, 'pty-other') + + store.setState({ + repos: [ + { ...TEST_REPO, id: 'repo-ssh', connectionId: targetId }, + { ...TEST_REPO, id: 'repo-other', connectionId: otherTargetId } + ], + worktreesByRepo: { + 'repo-ssh': [makeWorktree({ id: worktreeId, repoId: 'repo-ssh' })], + 'repo-other': [makeWorktree({ id: otherWorktreeId, repoId: 'repo-other' })] + }, + tabsByWorktree: { + [worktreeId]: [ + makeTab({ + id: 'tab-ssh', + worktreeId, + ptyId: removedPtyId, + pendingActivationSpawn: true + }) + ], + [otherWorktreeId]: [ + makeTab({ id: 'tab-other', worktreeId: otherWorktreeId, ptyId: otherPtyId }), + makeTab({ id: 'tab-stale-last-known', worktreeId: otherWorktreeId }) + ] + }, + ptyIdsByTabId: { + 'tab-ssh': [removedPtyId], + 'tab-other': [otherPtyId] + }, + lastKnownRelayPtyIdByTabId: { + 'tab-ssh': removedPtyId, + 'tab-stale-last-known': staleLastKnownPtyId, + 'tab-other': otherPtyId + }, + pendingCodexPaneRestartIds: { + [removedPtyId]: true, + [otherPtyId]: true + }, + codexRestartNoticeByPtyId: { + [removedPtyId]: { previousAccountLabel: 'Old', nextAccountLabel: 'New' }, + [otherPtyId]: { previousAccountLabel: 'Other old', nextAccountLabel: 'Other new' } + }, + sshConnectionStates: new Map([ + [targetId, { targetId, status: 'disconnected', error: null, reconnectAttempt: 0 }], + [ + otherTargetId, + { targetId: otherTargetId, status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]), + sshTargetLabels: new Map([ + [targetId, 'Removed target'], + [otherTargetId, 'Other target'] + ]), + remoteWorkspaceHydratedTargetIds: new Set([targetId, otherTargetId]), + remoteWorkspaceSyncStatusByTargetId: { + [targetId]: { phase: 'offline' }, + [otherTargetId]: { phase: 'synced' } + }, + portForwardsByConnection: { + [targetId]: [ + { + id: 'pf-1', + connectionId: targetId, + localPort: 3000, + remoteHost: '127.0.0.1', + remotePort: 3000 + } + ], + [otherTargetId]: [ + { + id: 'pf-2', + connectionId: otherTargetId, + localPort: 4000, + remoteHost: '127.0.0.1', + remotePort: 4000 + } + ] + }, + detectedPortsByConnection: { + [targetId]: [{ port: 3000, host: '127.0.0.1' }], + [otherTargetId]: [{ port: 4000, host: '127.0.0.1' }] + }, + sshCredentialQueue: [ + { requestId: 'req-1', targetId, kind: 'passphrase', detail: 'key' }, + { requestId: 'req-2', targetId: otherTargetId, kind: 'password', detail: 'password' } + ], + deferredSshReconnectTargets: [targetId, otherTargetId], + deferredSshSessionIdsByTabId: { + 'tab-ssh': 'legacy-session-without-target-prefix', + 'tab-stale-encoded': toAppSshPtyId(targetId, 'pty-1'), + 'tab-other': toAppSshPtyId(otherTargetId, 'pty-2') + } + }) + + store.getState().clearRemovedSshTargetState(targetId) + + const state = store.getState() + expect(state.sshConnectionStates.has(targetId)).toBe(false) + expect(state.sshTargetLabels.has(targetId)).toBe(false) + expect(state.remoteWorkspaceHydratedTargetIds.has(targetId)).toBe(false) + expect(state.remoteWorkspaceSyncStatusByTargetId[targetId]).toBeUndefined() + expect(state.portForwardsByConnection[targetId]).toBeUndefined() + expect(state.detectedPortsByConnection[targetId]).toBeUndefined() + expect(state.sshCredentialQueue.map((req) => req.targetId)).toEqual([otherTargetId]) + expect(state.deferredSshReconnectTargets).toEqual([otherTargetId]) + expect(state.deferredSshSessionIdsByTabId).toEqual({ + 'tab-other': toAppSshPtyId(otherTargetId, 'pty-2') + }) + expect(state.tabsByWorktree[worktreeId][0]).toMatchObject({ id: 'tab-ssh', ptyId: null }) + expect('pendingActivationSpawn' in state.tabsByWorktree[worktreeId][0]).toBe(false) + expect(state.ptyIdsByTabId['tab-ssh']).toEqual([]) + expect(state.lastKnownRelayPtyIdByTabId['tab-ssh']).toBeUndefined() + expect(state.lastKnownRelayPtyIdByTabId['tab-stale-last-known']).toBeUndefined() + expect(state.pendingCodexPaneRestartIds[removedPtyId]).toBeUndefined() + expect(state.codexRestartNoticeByPtyId[removedPtyId]).toBeUndefined() + + expect(state.sshConnectionStates.get(otherTargetId)?.status).toBe('connected') + expect(state.sshTargetLabels.get(otherTargetId)).toBe('Other target') + expect(state.remoteWorkspaceHydratedTargetIds.has(otherTargetId)).toBe(true) + expect(state.remoteWorkspaceSyncStatusByTargetId[otherTargetId]).toEqual({ phase: 'synced' }) + expect(state.portForwardsByConnection[otherTargetId]).toHaveLength(1) + expect(state.detectedPortsByConnection[otherTargetId]).toHaveLength(1) + expect(state.tabsByWorktree[otherWorktreeId][0]?.ptyId).toBe(otherPtyId) + expect(state.ptyIdsByTabId['tab-other']).toEqual([otherPtyId]) + expect(state.lastKnownRelayPtyIdByTabId['tab-other']).toBe(otherPtyId) + expect(state.pendingCodexPaneRestartIds[otherPtyId]).toBe(true) + expect(state.codexRestartNoticeByPtyId[otherPtyId]).toEqual({ + previousAccountLabel: 'Other old', + nextAccountLabel: 'Other new' + }) + }) + + it('keeps SSH target label references stable when refreshed metadata is unchanged', () => { + const store = createTestStore() + const labels = new Map([['ssh-1', 'Remote']]) + store.setState({ sshTargetLabels: labels }) + const previousState = store.getState() + + store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote' }]) + + expect(store.getState()).toBe(previousState) + expect(store.getState().sshTargetLabels).toBe(labels) + }) + + it('keeps SSH connection state references stable when duplicate state arrives', () => { + const store = createTestStore() + const sshConnectionStates = new Map([ + [ + 'ssh-1', + { targetId: 'ssh-1', status: 'connected' as const, error: null, reconnectAttempt: 0 } + ] + ]) + store.setState({ sshConnectionStates, sshConnectedGeneration: 1 }) + const previousState = store.getState() + + store.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + expect(store.getState()).toBe(previousState) + expect(store.getState().sshConnectionStates).toBe(sshConnectionStates) + expect(store.getState().sshConnectedGeneration).toBe(1) + }) + + it('does not publish state when cleanup finds no removed SSH target state', () => { + const store = createTestStore() + const previousState = store.getState() + + store.getState().clearRemovedSshTargetState('missing-target') + + expect(store.getState()).toBe(previousState) + }) + + it('preserves untouched cleanup slice references while removing deferred target metadata', () => { + const store = createTestStore() + const sshConnectionStates = new Map([ + [ + 'ssh-2', + { targetId: 'ssh-2', status: 'connected' as const, error: null, reconnectAttempt: 0 } + ] + ]) + const sshTargetLabels = new Map([['ssh-2', 'Other']]) + const remoteWorkspaceHydratedTargetIds = new Set(['ssh-2']) + const remoteWorkspaceSyncStatusByTargetId = { 'ssh-2': { phase: 'synced' as const } } + const portForwardsByConnection = { 'ssh-2': [] } + const detectedPortsByConnection = { 'ssh-2': [] } + const sshCredentialQueue = [ + { requestId: 'req-2', targetId: 'ssh-2', kind: 'password' as const, detail: 'password' } + ] + const deferredSshSessionIdsByTabId = { + 'tab-other': toAppSshPtyId('ssh-2', 'pty-2') + } + store.setState({ + sshConnectionStates, + sshTargetLabels, + remoteWorkspaceHydratedTargetIds, + remoteWorkspaceSyncStatusByTargetId, + portForwardsByConnection, + detectedPortsByConnection, + sshCredentialQueue, + deferredSshReconnectTargets: ['ssh-1'], + deferredSshSessionIdsByTabId + }) + + store.getState().clearRemovedSshTargetState('ssh-1') + + const state = store.getState() + expect(state.deferredSshReconnectTargets).toEqual([]) + expect(state.sshConnectionStates).toBe(sshConnectionStates) + expect(state.sshTargetLabels).toBe(sshTargetLabels) + expect(state.remoteWorkspaceHydratedTargetIds).toBe(remoteWorkspaceHydratedTargetIds) + expect(state.remoteWorkspaceSyncStatusByTargetId).toBe(remoteWorkspaceSyncStatusByTargetId) + expect(state.portForwardsByConnection).toBe(portForwardsByConnection) + expect(state.detectedPortsByConnection).toBe(detectedPortsByConnection) + expect(state.sshCredentialQueue).toBe(sshCredentialQueue) + expect(state.deferredSshSessionIdsByTabId).toBe(deferredSshSessionIdsByTabId) + }) +}) diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 0b28d74f928..b7d2b9eb445 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -6,6 +6,11 @@ import type { EnrichedDetectedPort, SshTarget } from '../../../../shared/ssh-types' +import { + buildRemovedSshTargetCleanupPatch, + sshConnectionStatesEqual, + sshTargetLabelsEqual +} from './ssh-target-cleanup' export type RemoteWorkspaceSyncStatus = { phase: 'idle' | 'pulling' | 'pushing' | 'synced' | 'conflict' | 'error' | 'offline' @@ -46,6 +51,7 @@ export type SshSlice = { setSshConnectionState: (targetId: string, state: SshConnectionState) => void setSshTargetLabels: (labels: Map) => void setSshTargetsMetadata: (targets: Pick[]) => void + clearRemovedSshTargetState: (targetId: string) => void markRemoteWorkspaceHydrated: (targetId: string) => void clearRemoteWorkspaceHydrated: (targetId: string) => void setRemoteWorkspaceSyncStatus: (targetId: string, status: RemoteWorkspaceSyncStatus) => void @@ -70,6 +76,9 @@ export const createSshSlice: StateCreator = (set) => set((s) => { const next = new Map(s.sshConnectionStates) const previous = next.get(targetId) + if (sshConnectionStatesEqual(previous, state)) { + return s + } next.set(targetId, state) return { sshConnectionStates: next, @@ -82,9 +91,16 @@ export const createSshSlice: StateCreator = (set) => setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }), setSshTargetsMetadata: (targets) => - set({ - sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])) + set((s) => { + if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) { + return s + } + return { + sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])) + } }), + clearRemovedSshTargetState: (targetId) => + set((s) => buildRemovedSshTargetCleanupPatch(s, targetId) ?? s), markRemoteWorkspaceHydrated: (targetId) => set((s) => { const next = new Set(s.remoteWorkspaceHydratedTargetIds) diff --git a/src/shared/ssh-pty-id.ts b/src/shared/ssh-pty-id.ts new file mode 100644 index 00000000000..992111cd664 --- /dev/null +++ b/src/shared/ssh-pty-id.ts @@ -0,0 +1,54 @@ +const SSH_PTY_ID_PREFIX = 'ssh:' +const SSH_PTY_ID_SEPARATOR = '@@' + +// Why: SSH relays allocate target-local ids like "pty-1"; app-wide routing +// needs the target id embedded so two relays cannot collide after restore. +export type ParsedSshPtyId = { + connectionId: string + relayPtyId: string +} + +export function parseAppSshPtyId(ptyId: string): ParsedSshPtyId | null { + if (!ptyId.startsWith(SSH_PTY_ID_PREFIX)) { + return null + } + const separatorIndex = ptyId.indexOf(SSH_PTY_ID_SEPARATOR, SSH_PTY_ID_PREFIX.length) + if (separatorIndex === -1) { + return null + } + const encodedConnectionId = ptyId.slice(SSH_PTY_ID_PREFIX.length, separatorIndex) + const relayPtyId = ptyId.slice(separatorIndex + SSH_PTY_ID_SEPARATOR.length) + if (!encodedConnectionId || !relayPtyId) { + return null + } + try { + return { + connectionId: decodeURIComponent(encodedConnectionId), + relayPtyId + } + } catch { + return null + } +} + +export function toAppSshPtyId(connectionId: string, relayPtyId: string): string { + const parsed = parseAppSshPtyId(relayPtyId) + if (parsed) { + if (parsed.connectionId !== connectionId) { + throw new Error(`PTY ${relayPtyId} belongs to SSH connection "${parsed.connectionId}"`) + } + return relayPtyId + } + return `${SSH_PTY_ID_PREFIX}${encodeURIComponent(connectionId)}${SSH_PTY_ID_SEPARATOR}${relayPtyId}` +} + +export function toRelaySshPtyId(connectionId: string, ptyId: string): string { + const parsed = parseAppSshPtyId(ptyId) + if (!parsed) { + return ptyId + } + if (parsed.connectionId !== connectionId) { + throw new Error(`PTY ${ptyId} belongs to SSH connection "${parsed.connectionId}"`) + } + return parsed.relayPtyId +} From 83c90db261922de6d8bfc434be22850869fb1cd5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:46 -0700 Subject: [PATCH 013/230] Fix onboarding rerun project continuation (#2735) --- .../components/onboarding/OnboardingFlow.tsx | 29 ++++++++++++--- .../components/onboarding/RepoStep.test.tsx | 36 +++++++++++++++++++ .../onboarding/use-onboarding-flow.ts | 35 ++++++++++++++++++ 3 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/onboarding/RepoStep.test.tsx diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index b7f9484fe3e..0dc7738fbbb 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -84,7 +84,12 @@ export default function OnboardingFlow({ const shouldShowSkipToProjectSetup = currentStep.id !== 'repo' && currentStep.id !== 'tour' const shouldShowStepHeading = !isTourStep const footerPrimaryLabel = isTourStep ? 'Skip the tour' : primaryActionLabel - const { next: flowNext, openFolder: flowOpenFolder, skipTourToRepo: flowSkipTourToRepo } = flow + const { + next: flowNext, + openFolder: flowOpenFolder, + continueWithExistingProject: flowContinueWithExistingProject, + skipTourToRepo: flowSkipTourToRepo + } = flow // Why: depend on stable callbacks + step id only so the listener doesn't // re-bind on every render of the parent (flow object identity changes). useEffect(() => { @@ -110,14 +115,26 @@ export default function OnboardingFlow({ return } if (currentStep.id === 'repo') { - void flowOpenFolder() + if (flow.hasExistingProject) { + void flowContinueWithExistingProject('keyboard') + } else { + void flowOpenFolder() + } } else { void flowNext('keyboard') } } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [currentStep.id, flowNext, flowOpenFolder, flowSkipTourToRepo, tourStarted]) + }, [ + currentStep.id, + flow.hasExistingProject, + flowContinueWithExistingProject, + flowNext, + flowOpenFolder, + flowSkipTourToRepo, + tourStarted + ]) return (
)} - {currentStep.id !== 'repo' && ( + {(currentStep.id !== 'repo' || flow.hasExistingProject) && (
- - {detectedAgents.length > 0 && ( @@ -403,25 +400,3 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React ) } - -export function AgentStatusHooksSetting({ - settings, - updateSettings -}: AgentsPaneProps): React.JSX.Element { - const enabled = settings.agentStatusHooksEnabled !== false - return ( -
- - updateSettings({ - agentStatusHooksEnabled: !enabled - }) - } - ariaLabel={AGENT_STATUS_HOOKS_TITLE} - /> -
- ) -} diff --git a/src/renderer/src/components/settings/agent-status-hooks-copy.ts b/src/renderer/src/components/settings/agent-status-hooks-copy.ts deleted file mode 100644 index 99c9cfb689b..00000000000 --- a/src/renderer/src/components/settings/agent-status-hooks-copy.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const AGENT_STATUS_HOOKS_TITLE = 'Agent status hooks' - -export const AGENT_STATUS_HOOKS_DESCRIPTION = - 'Shows working, waiting, and done states in Orca. For Claude and Codex, Orca uses scoped settings and profiles so terminal sessions outside Orca keep your existing config. Turn off to remove Orca-managed hooks and stop reinstalling them.' - -export const AGENT_STATUS_HOOKS_SEARCH_KEYWORDS = [ - 'hooks', - 'status', - 'working', - 'waiting', - 'done', - 'remove', - 'restore', - 'scoped', - 'profile', - 'settings', - 'config', - 'claude', - 'codex' -] diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 1095348de24..279481783d9 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -4,11 +4,6 @@ import { getAgentAwakeDescription, getAgentAwakeSearchKeywords } from './agent-awake-copy' -import { - AGENT_STATUS_HOOKS_DESCRIPTION, - AGENT_STATUS_HOOKS_SEARCH_KEYWORDS, - AGENT_STATUS_HOOKS_TITLE -} from './agent-status-hooks-copy' export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { @@ -50,11 +45,6 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'detected' ] }, - { - title: AGENT_STATUS_HOOKS_TITLE, - description: AGENT_STATUS_HOOKS_DESCRIPTION, - keywords: AGENT_STATUS_HOOKS_SEARCH_KEYWORDS - }, { title: AGENT_AWAKE_TITLE, description: getAgentAwakeDescription(), diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 43e5b2175d2..72ffda4e769 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1792,9 +1792,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent: tuiAgent, prompt: startupPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + platform: CLIENT_PLATFORM }) // Why: thread agent_started telemetry through the queued startup so @@ -1871,7 +1869,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepoIsGit, selectedRepoRequiresConnection, settings?.agentCmdOverrides, - settings?.agentStatusHooksEnabled, settings?.rightSidebarOpenByDefault, setRightSidebarOpen, setRightSidebarTab, @@ -2018,9 +2015,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent, draft: quickDraftPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + platform: CLIENT_PLATFORM }) let startupPlan: ReturnType = null @@ -2038,9 +2033,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS prompt: quickPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + allowEmptyPromptLaunch: true }) if (startupPlan && quickDraftPrompt) { startupPlan.draftPrompt = quickDraftPrompt @@ -2120,7 +2113,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepoIsGit, selectedRepoRequiresConnection, settings?.agentCmdOverrides, - settings?.agentStatusHooksEnabled, settings?.rightSidebarOpenByDefault, setRightSidebarOpen, setRightSidebarTab, diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 4e196775862..82cadca7fb5 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const mockSpawn = vi.fn() @@ -18,8 +21,6 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkTrusted = vi.fn() const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ -const CLAUDE_SCOPED_SETTINGS = - 'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json"' function expectStablePaneSpawn(): string { const spawnArgs = mockSpawn.mock.calls[0]?.[0] @@ -73,8 +74,11 @@ describe('launchAgentBackgroundSession', () => { clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() mockRuntimeEnvironmentTransportCall.mockImplementation( - (args) => - createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) + (args: RuntimeEnvironmentCallRequest) => { + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) + ) + } ) state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } state.repos = [{ id: 'repo-1', connectionId: null }] @@ -124,7 +128,7 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo/worktree', - command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`, + command: "claude 'run the automation'", env: expect.objectContaining({ ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'wt-1' @@ -269,10 +273,7 @@ describe('launchAgentBackgroundSession', () => { dataSidecar('user@remote repo % ') vi.advanceTimersByTime(50) - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - `${CLAUDE_SCOPED_SETTINGS} 'run the automation'\r` - ) + expect(mockWrite).toHaveBeenCalledWith('pty-1', "claude 'run the automation'\r") } finally { vi.useRealTimers() } @@ -306,7 +307,7 @@ describe('launchAgentBackgroundSession', () => { method: 'terminal.create', params: expect.objectContaining({ worktree: 'wt-1', - command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`, + command: "claude 'run the automation'", env: expect.objectContaining({ ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1', diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index a35f13ae515..03b0659d73b 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -63,7 +63,6 @@ export async function launchAgentBackgroundSession( } } const cmdOverrides = store.settings?.agentCmdOverrides ?? {} - const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -76,9 +75,7 @@ export async function launchAgentBackgroundSession( prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -87,9 +84,7 @@ export async function launchAgentBackgroundSession( prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: !hasPrompt, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + allowEmptyPromptLaunch: !hasPrompt }) } if (!startupPlan) { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 0a83c2b5f0c..79170f70dc6 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -72,7 +72,6 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } = args const store = useAppStore.getState() const cmdOverrides = store.settings?.agentCmdOverrides ?? {} - const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -96,9 +95,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt submitPastedPrompt = true @@ -108,9 +105,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, draft: trimmedPrompt, cmdOverrides, - platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + platform: CLIENT_PLATFORM }) if (draftLaunchPlan) { startupPlan = { @@ -126,9 +121,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt } @@ -138,9 +131,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -149,9 +140,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: !hasPrompt, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + allowEmptyPromptLaunch: !hasPrompt }) } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 6cc05211029..2d85e8b84dc 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -298,9 +298,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom agent: effectiveAgent, draft: draftContent, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + platform: CLIENT_PLATFORM }) if (draftLaunchPlan) { startupPlan = { @@ -317,9 +315,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom prompt: '', cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + allowEmptyPromptLaunch: true }) } diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 21dcf02fb01..1d8d68d637a 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -29,9 +29,7 @@ export function buildOnboardingFolderAgentStartup( prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, platform: getClientPlatform(), - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false + allowEmptyPromptLaunch: true }) if (!startupPlan) { return undefined diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 523ef251669..aecc46612b7 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { appendOrcaCodexAgentStatusProfile } from '../../../shared/codex-profile' import type { Worktree } from '../../../shared/types' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' @@ -82,7 +81,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2cfce6d4998..d5d5b8c3bbe 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -96,11 +96,7 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): prompt: '', cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: - useAppStore.getState().settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: - useAppStore.getState().settings?.agentStatusHooksEnabled !== false + allowEmptyPromptLaunch: true }) if (!startupPlan) { return undefined diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index e2aaea6fb84..0634a8f951c 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { appendOrcaCodexAgentStatusProfile } from '../../../../shared/codex-profile' import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { createTestStore, makeWorktree } from './store-test-helpers' @@ -58,7 +57,7 @@ describe('repo slice skipped-onboarding folder startup', () => { 'folder-1::/folder', { startup: { - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/claude-settings.ts b/src/shared/claude-settings.ts deleted file mode 100644 index fe82752beb5..00000000000 --- a/src/shared/claude-settings.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV = 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' -export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE = 'claude-agent-status-settings.json' - -type ClaudeSettingsShell = 'posix' | 'powershell' | 'cmd' - -export function appendOrcaClaudeAgentStatusSettings( - command: string, - shell: ClaudeSettingsShell -): string { - // Why: Claude's --settings is a per-process overlay. CLAUDE_CONFIG_DIR - // would fork auth/session state and normal external Claude launches. - if (shell === 'powershell') { - return `${command} --settings $Env:${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}` - } - if (shell === 'cmd') { - return `${command} --settings "%${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}%"` - } - return `${command} --settings "$HOME/.orca/agent-hooks/${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE}"` -} diff --git a/src/shared/codex-profile.ts b/src/shared/codex-profile.ts deleted file mode 100644 index c3268ac066f..00000000000 --- a/src/shared/codex-profile.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const ORCA_CODEX_AGENT_STATUS_PROFILE = 'orca-agent-status' - -export function appendOrcaCodexAgentStatusProfile(command: string): string { - return `${command} --profile-v2 ${ORCA_CODEX_AGENT_STATUS_PROFILE}` -} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a5b62e79b2e..8d403832335 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -250,7 +250,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, - agentStatusHooksEnabled: true, keepComputerAwakeWhileAgentsRun: false, // Why: 'auto' runs a layout-aware probe at boot (see // src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index f244f3bef52..5313a410310 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -36,79 +36,6 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') }) - it('launches Codex with the Orca profile when agent status hooks are enabled', () => { - const plan = buildAgentStartupPlan({ - agent: 'codex', - prompt: 'fix it', - cmdOverrides: {}, - platform: 'linux', - useOrcaCodexAgentStatusProfile: true - }) - - expect(plan?.launchCommand).toBe("codex --profile-v2 orca-agent-status 'fix it'") - }) - - it('launches Claude with the Orca settings file when agent status hooks are enabled', () => { - const plan = buildAgentStartupPlan({ - agent: 'claude', - prompt: 'fix it', - cmdOverrides: {}, - platform: 'linux', - useOrcaClaudeAgentStatusSettings: true - }) - - expect(plan?.launchCommand).toBe( - 'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json" \'fix it\'' - ) - }) - - it('uses the target shell syntax for Claude settings injection', () => { - expect( - buildAgentStartupPlan({ - agent: 'claude', - prompt: 'fix it', - cmdOverrides: {}, - platform: 'win32', - useOrcaClaudeAgentStatusSettings: true - })?.launchCommand - ).toBe("claude --settings $Env:ORCA_CLAUDE_AGENT_STATUS_SETTINGS 'fix it'") - - expect( - buildAgentStartupPlan({ - agent: 'claude', - prompt: 'fix it', - cmdOverrides: {}, - platform: 'win32', - shell: 'cmd', - useOrcaClaudeAgentStatusSettings: true - })?.launchCommand - ).toBe('claude --settings "%ORCA_CLAUDE_AGENT_STATUS_SETTINGS%" "fix it"') - }) - - it('leaves Claude command overrides untouched', () => { - const plan = buildAgentStartupPlan({ - agent: 'claude', - prompt: 'fix it', - cmdOverrides: { claude: 'claude --dangerously-skip-permissions' }, - platform: 'linux', - useOrcaClaudeAgentStatusSettings: true - }) - - expect(plan?.launchCommand).toBe("claude --dangerously-skip-permissions 'fix it'") - }) - - it('leaves Codex command overrides untouched', () => { - const plan = buildAgentStartupPlan({ - agent: 'codex', - prompt: 'fix it', - cmdOverrides: { codex: 'codex --profile work' }, - platform: 'linux', - useOrcaCodexAgentStatusProfile: true - }) - - expect(plan?.launchCommand).toBe("codex --profile work 'fix it'") - }) - it('clears draft environment variables with the target shell syntax', () => { expect( buildAgentDraftLaunchPlan({ diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 1fc1b13dd64..31b8ebb5065 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,6 +1,4 @@ import { isShellProcess } from './agent-detection' -import { appendOrcaClaudeAgentStatusSettings } from './claude-settings' -import { appendOrcaCodexAgentStatusProfile } from './codex-profile' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './types' @@ -46,26 +44,6 @@ function commandSeparator(shell: AgentStartupShell): string { return shell === 'cmd' ? ' & ' : '; ' } -function resolveBaseCommand(args: { - agent: TuiAgent - cmdOverrides: Partial> - shell: AgentStartupShell - useOrcaClaudeAgentStatusSettings?: boolean - useOrcaCodexAgentStatusProfile?: boolean -}): string { - const override = args.cmdOverrides[args.agent] - if (override) { - return override - } - const command = TUI_AGENT_CONFIG[args.agent].launchCmd - if (args.agent === 'claude' && args.useOrcaClaudeAgentStatusSettings) { - return appendOrcaClaudeAgentStatusSettings(command, args.shell) - } - return args.agent === 'codex' && args.useOrcaCodexAgentStatusProfile - ? appendOrcaCodexAgentStatusProfile(command) - : command -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -73,20 +51,12 @@ export function buildAgentStartupPlan(args: { platform: NodeJS.Platform shell?: AgentStartupShell allowEmptyPromptLaunch?: boolean - useOrcaClaudeAgentStatusSettings?: boolean - useOrcaCodexAgentStatusProfile?: boolean }): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] - const baseCommand = resolveBaseCommand({ - agent, - cmdOverrides, - shell, - useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, - useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile - }) + const baseCommand = cmdOverrides[agent] ?? config.launchCmd if (!trimmedPrompt) { if (!allowEmptyPromptLaunch) { @@ -159,8 +129,6 @@ export function buildAgentDraftLaunchPlan(args: { cmdOverrides: Partial> platform: NodeJS.Platform shell?: AgentStartupShell - useOrcaClaudeAgentStatusSettings?: boolean - useOrcaCodexAgentStatusProfile?: boolean }): AgentDraftLaunchPlan | null { const { agent, draft, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) @@ -169,13 +137,7 @@ export function buildAgentDraftLaunchPlan(args: { if (!trimmed) { return null } - const baseCommand = resolveBaseCommand({ - agent, - cmdOverrides, - shell, - useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, - useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile - }) + const baseCommand = cmdOverrides[agent] ?? config.launchCmd if (config.draftPromptFlag) { const quoted = quoteStartupArg(trimmed, shell) return { diff --git a/src/shared/types.ts b/src/shared/types.ts index 57da943379c..8fe5ba7b12c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1770,9 +1770,6 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> - /** Why: disabling must persist so startup does not reinstall global agent - * hook entries right after the user removes them from Settings or CLI. */ - agentStatusHooksEnabled: boolean /** When true, Orca requests local awake assertions while hook-reported agents are working. */ keepComputerAwakeWhileAgentsRun: boolean /** Why: macOS terminals must choose between letting Option compose layout From bea744b7e4df581dd94d8447ea64e1458b340ca4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 23 May 2026 23:04:59 -0700 Subject: [PATCH 017/230] Revert "Hide branch names behind workspace card property" (#2740) --- src/main/persistence.test.ts | 1 - .../runtime/rpc/methods/client-ui.test.ts | 4 +- src/main/runtime/rpc/methods/client-ui.ts | 1 - .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 1 - .../WorktreeCard.quick-actions.test.tsx | 35 ---------------- .../src/components/sidebar/WorktreeCard.tsx | 19 ++++----- .../WorktreeList.lineage-child-card.test.ts | 18 --------- .../src/components/sidebar/WorktreeList.tsx | 40 ++++++++----------- src/renderer/src/store/slices/ui.test.ts | 13 ------ src/shared/constants.test.ts | 12 +----- src/shared/types.ts | 3 -- src/shared/worktree-card-properties.ts | 1 - 12 files changed, 29 insertions(+), 119 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9388428b82d..f3a1c2fd7f8 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2111,7 +2111,6 @@ describe('Store', () => { expect(store.getUI().worktreeCardProperties).toContain('inline-agents') expect(store.getUI().worktreeCardProperties).toContain('linear-issue') expect(store.getUI().worktreeCardProperties).toContain('ports') - expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 6cd4892a53e..7c591597c21 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -136,7 +136,7 @@ describe('client UI RPC methods', () => { it('accepts persisted literal UI arrays and nested UI state', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), - worktreeCardProperties: ['status', 'branch', 'inline-agents'], + worktreeCardProperties: ['status', 'inline-agents'], statusBarItems: ['codex'], taskResumeState: { githubMode: 'items', @@ -163,7 +163,7 @@ describe('client UI RPC methods', () => { const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) const payload = { - worktreeCardProperties: ['status', 'branch', 'inline-agents'], + worktreeCardProperties: ['status', 'inline-agents'], statusBarItems: ['codex'], taskResumeState: { githubMode: 'items', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 6e9d5705007..0098722c14f 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -10,7 +10,6 @@ const UnknownRecordArray = z.array(UnknownRecord) const WorktreeCardProperty = z.enum([ 'status', 'unread', - 'branch', 'ci', 'issue', 'linear-issue', diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 32f32ae86c9..bf27e5dcde3 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -35,7 +35,6 @@ const GROUP_BY_OPTIONS = [ ] as const const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ - { id: 'branch', label: 'Branch name' }, { id: 'issue', label: 'GitHub ticket' }, { id: 'linear-issue', label: 'Linear issue' }, { id: 'pr', label: 'PR/MR link' }, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index cfe4aa3827c..63b37973a07 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -125,41 +125,6 @@ describe('WorktreeCard quick actions', () => { expect(markup).toContain('data-workspace-board-preserve-open=""') }) - it('hides the branch name from workspace card metadata by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Visible workspace') - expect(markup).not.toContain('feature/secret-ref') - expect(markup).not.toContain('secret-ref') - }) - - it('renders the branch name when the Branch name property is enabled', () => { - worktreeCardProperties = ['status', 'unread', 'branch'] - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Visible workspace') - expect(markup).toContain('feature/show-ref') - }) - it('shows delete as the top-right quick action for an inactive workspace', () => { const markup = renderToStaticMarkup( diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index ec70021a761..45dc3d5467c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -254,7 +254,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showLinearIssue = cardProps.includes('linear-issue') const showComment = cardProps.includes('comment') const showPorts = cardProps.includes('ports') - const showBranch = cardProps.includes('branch') // Skip hosted-review fetches when the corresponding card sections are hidden. // This preference is purely presentational, so background refreshes would @@ -416,7 +415,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) // Why: deleting the active/current workspace or one with live activity is a // disruptive hover action; keep the quick action delete-only and passive. - const showDeleteQuickAction = !isCurrentWorktree && !hasActiveActivity && !worktree.isMainWorktree + const showDeleteQuickAction = + !isCurrentWorktree && !hasActiveActivity && !worktree.isMainWorktree const handleWorkspaceQuickAction = useCallback( (event: React.MouseEvent) => { event.preventDefault() @@ -624,9 +624,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} {/* Why: weight alone carries the unread signal; color stays - at text-foreground in both states so the title remains - the card's primary scan target even when optional branch - metadata is hidden. */} + at text-foreground in both states so the title keeps + hierarchy against the muted branch row below (muting the + title as well flattened the card — same reasoning as the + repo chip comment below). */}