mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
feat(settings): let Codex terminals opt back into Codex's shared server (#23929)
* feat(settings): add a setting to run Codex terminals on Codex's shared server Off injects ORCA_CODEX_ISOLATE=0 into new terminals on every host (local, daemon, SSH relay, WSL), which the codex shell wrapper from #23900 reads to skip --no-daemon. On (the default) injects nothing. * feat(terminal): announce per-terminal Codex servers once Shows a one-time toast the first time a Codex terminal starts, with an Open Settings action that lands on the new Codex server setting. The seen flag persists in UI state and is set when the toast is shown. * fix(settings): drop the status warning from the Codex server setting * fix(terminal): simplify the Codex shared-server notice * fix(terminal): say agent status in the Codex notice * fix(settings): hide the Codex server setting from paired web search Gives its search entry an id and gates it with includeCodexTerminalServerIsolation, as the other host-only rows are, so a paired web client cannot find a row it never renders. Its search keywords now use catalogued agents-search keys instead of missing ones; CODEX_ISOLATE_ENV is no longer exported; the toast id comment names the case it guards. * test(settings): assert the Codex server search gate through the metadata builder Calling getAgentsPaneSearchEntries directly stayed green with the web gate deleted; the metadata builder test fails without it.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { buildRuntimePtySpawnOptions } from '../runtime/spawn-options'
|
||||
import { createRuntimePtySpawnState } from '../runtime/spawn-state'
|
||||
import type { PtyRuntimeControllerDeps } from '../runtime/controller-deps'
|
||||
import { buildPtyIpcSpawnOptions } from './spawn-options'
|
||||
import { createPtyIpcSpawnState } from './spawn-state'
|
||||
import type { PtySpawnIpcDeps } from './spawn-types'
|
||||
|
||||
function settingsWith(isolation: boolean | undefined): GlobalSettings {
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
if (isolation === undefined) {
|
||||
delete settings.codexTerminalServerIsolation
|
||||
return settings
|
||||
}
|
||||
return { ...settings, codexTerminalServerIsolation: isolation }
|
||||
}
|
||||
|
||||
async function ipcSpawnEnv(
|
||||
isolation: boolean | undefined,
|
||||
connectionId: string | null
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildPtyIpcSpawnOptions only reads the members stubbed here; the rest belong to later spawn stages this test never runs.
|
||||
const deps = {
|
||||
transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(),
|
||||
getSettings: () => settingsWith(isolation),
|
||||
runtime: { registerPreAllocatedHandleForPty: vi.fn() }
|
||||
} as unknown as PtySpawnIpcDeps
|
||||
const ctx = createPtyIpcSpawnState(deps, { cols: 80, rows: 24, connectionId })
|
||||
ctx.env = { KEEP: '1' }
|
||||
await buildPtyIpcSpawnOptions(ctx)
|
||||
return ctx.spawnOptions.env
|
||||
}
|
||||
|
||||
async function runtimeSpawnEnv(
|
||||
isolation: boolean | undefined,
|
||||
connectionId: string | null
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildRuntimePtySpawnOptions only reads getSettings here; the rest belong to later spawn stages this test never runs.
|
||||
const deps = { getSettings: () => settingsWith(isolation) } as unknown as PtyRuntimeControllerDeps
|
||||
const ctx = createRuntimePtySpawnState(deps, { cols: 80, rows: 24, connectionId })
|
||||
ctx.env = { KEEP: '1' }
|
||||
await buildRuntimePtySpawnOptions(ctx)
|
||||
return ctx.spawnOptions.env
|
||||
}
|
||||
|
||||
// Why both hosts: SSH panes reach the relay with this env untouched, local ones reach
|
||||
// node-pty or the daemon; WSL forwarding is covered by wsl-orca-env.test.ts.
|
||||
describe.each([
|
||||
['renderer spawn', ipcSpawnEnv],
|
||||
['runtime spawn', runtimeSpawnEnv]
|
||||
])('%s: Codex terminal server isolation', (_name, spawnEnv) => {
|
||||
it.each([null, 'ssh-1'])(
|
||||
'injects nothing while the setting is on (connection %s)',
|
||||
async (id) => {
|
||||
expect(await spawnEnv(true, id)).toEqual({ KEEP: '1' })
|
||||
expect(await spawnEnv(undefined, id)).toEqual({ KEEP: '1' })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([null, 'ssh-1'])(
|
||||
'opts new terminals out with ORCA_CODEX_ISOLATE=0 when off (connection %s)',
|
||||
async (id) => {
|
||||
expect(await spawnEnv(false, id)).toEqual({ KEEP: '1', ORCA_CODEX_ISOLATE: '0' })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -23,6 +23,7 @@ import { ptySizes } from '../delivery/visibility-state'
|
||||
import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size'
|
||||
import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies'
|
||||
import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args'
|
||||
import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-terminal-server-isolation'
|
||||
import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn'
|
||||
@@ -32,9 +33,11 @@ export async function buildPtyIpcSpawnOptions(
|
||||
ctx: PtyIpcSpawnState
|
||||
): Promise<{ isReattach: true } | null> {
|
||||
const args = ctx.args
|
||||
ctx.spawnEnv = ctx.preAllocatedHandle
|
||||
? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle }
|
||||
: ctx.env
|
||||
// Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env.
|
||||
ctx.spawnEnv = withCodexTerminalServerIsolationEnv(
|
||||
ctx.preAllocatedHandle ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } : ctx.env,
|
||||
ctx.deps.getSettings?.()
|
||||
)
|
||||
const envToDelete = ctx.claudeAuth?.stripAuthEnv
|
||||
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
|
||||
: undefined
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment'
|
||||
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir'
|
||||
import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env'
|
||||
import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args'
|
||||
import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-terminal-server-isolation'
|
||||
import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command'
|
||||
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
||||
import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies'
|
||||
@@ -42,6 +43,8 @@ export async function buildRuntimePtySpawnOptions(
|
||||
> {
|
||||
const args = ctx.args
|
||||
|
||||
// Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env.
|
||||
ctx.env = withCodexTerminalServerIsolationEnv(ctx.env, ctx.deps.getSettings?.())
|
||||
const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv
|
||||
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
|
||||
: undefined
|
||||
|
||||
@@ -64,6 +64,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_CLI_COMMAND: 'orca-ide',
|
||||
ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe',
|
||||
ORCA_CODEX_ISOLATE: '0',
|
||||
ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml',
|
||||
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
|
||||
ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts',
|
||||
@@ -90,6 +91,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CODEX_ISOLATE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p')
|
||||
expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
|
||||
|
||||
@@ -363,6 +363,17 @@ describe('PtyHandler', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the Codex server opt-out the client asked for', async () => {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { ORCA_CODEX_ISOLATE: '0' }
|
||||
})
|
||||
|
||||
const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record<string, string>
|
||||
expect(spawnEnv.ORCA_CODEX_ISOLATE).toBe('0')
|
||||
})
|
||||
|
||||
it('drops an ORCA_HISTFILE handed over in the client env', async () => {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cols: 80,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync'
|
||||
import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync'
|
||||
import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers'
|
||||
import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice'
|
||||
import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice'
|
||||
|
||||
/**
|
||||
* App-level subscriptions that must outlive any individual surface. Each one is here because
|
||||
@@ -52,4 +53,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }):
|
||||
usePrimarySelectionPaste(primarySelectionMiddleClickPaste)
|
||||
useOsc52ClipboardDefaultOnNotice(persistedUIReady)
|
||||
useBrowserIdentityMigrationNotice()
|
||||
useCodexTerminalServerIsolationNotice()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAgentWorkspaceTrustTitle
|
||||
} from './agent-workspace-trust-copy'
|
||||
import { getAgentAwakeDescription, getAgentAwakeTitle } from './agent-awake-copy'
|
||||
import { getCodexTerminalServerIsolationTitle } from './codex-terminal-server-isolation-copy'
|
||||
import { AgentAwakeSetting } from './AgentAwakeSetting'
|
||||
import { AgentRuntimeSetting } from './AgentRuntimeSetting'
|
||||
import type * as AgentRuntimeSettingModule from './AgentRuntimeSetting'
|
||||
@@ -283,6 +284,17 @@ describe('AgentsPane', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the host-only Codex server row out of paired web clients', () => {
|
||||
Reflect.set(globalThis, '__ORCA_WEB_CLIENT__', true)
|
||||
try {
|
||||
expect(renderPane(getDefaultSettings('/tmp'))).not.toContain(
|
||||
getCodexTerminalServerIsolationTitle()
|
||||
)
|
||||
} finally {
|
||||
Reflect.deleteProperty(globalThis, '__ORCA_WEB_CLIENT__')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the agent runtime control on Windows-class hosts', () => {
|
||||
const markup = renderPane(
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAppStore } from '@/store'
|
||||
import { AgentAwakeSetting } from './AgentAwakeSetting'
|
||||
import { AgentCacheTimerSection } from './AgentCacheTimerSection'
|
||||
import { AgentRuntimeSetting } from './AgentRuntimeSetting'
|
||||
import { CodexTerminalServerIsolationSetting } from './CodexTerminalServerIsolationSetting'
|
||||
import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control'
|
||||
import {
|
||||
getAgentGeneratedTabTitlesDescription,
|
||||
@@ -260,7 +261,13 @@ export function AgentsPane({
|
||||
/>
|
||||
<AgentStatusHooksSetting settings={settings} updateSettings={updateSettings} />
|
||||
{!isPairedWebClientWindow() ? (
|
||||
<AgentWorkspaceTrustSetting settings={settings} updateSettings={updateSettings} />
|
||||
<>
|
||||
<AgentWorkspaceTrustSetting settings={settings} updateSettings={updateSettings} />
|
||||
<CodexTerminalServerIsolationSetting
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<AgentGeneratedTabTitlesSetting settings={settings} updateSettings={updateSettings} />
|
||||
{!isPairedWebClientWindow() ? (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { isCodexTerminalServerIsolationEnabled } from '../../../../shared/codex-terminal-server-isolation'
|
||||
import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
|
||||
import {
|
||||
getCodexTerminalServerIsolationDescription,
|
||||
getCodexTerminalServerIsolationSearchKeywords,
|
||||
getCodexTerminalServerIsolationTitle
|
||||
} from './codex-terminal-server-isolation-copy'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsSwitchRow } from './SettingsFormControls'
|
||||
|
||||
type CodexTerminalServerIsolationSettingProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function CodexTerminalServerIsolationSetting({
|
||||
settings,
|
||||
updateSettings
|
||||
}: CodexTerminalServerIsolationSettingProps): React.JSX.Element {
|
||||
const title = getCodexTerminalServerIsolationTitle()
|
||||
const description = getCodexTerminalServerIsolationDescription()
|
||||
const enabled = isCodexTerminalServerIsolationEnabled(settings)
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<SearchableSetting
|
||||
id={CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID}
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={getCodexTerminalServerIsolationSearchKeywords()}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={title}
|
||||
description={description}
|
||||
checked={enabled}
|
||||
onChange={() => void updateSettings({ codexTerminalServerIsolation: !enabled })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getAgentWorkspaceTrustTitle
|
||||
} from './agent-workspace-trust-copy'
|
||||
import { getAgentCacheTimerSearchEntries } from './agent-cache-timer-search'
|
||||
import {
|
||||
getCodexTerminalServerIsolationDescription,
|
||||
getCodexTerminalServerIsolationSearchKeywords,
|
||||
getCodexTerminalServerIsolationTitle
|
||||
} from './codex-terminal-server-isolation-copy'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { searchKeywords, translateSearchKeyword, uniqueKeywords } from './settings-search-keywords'
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
@@ -68,11 +73,13 @@ type AgentsPaneSearchOptions = {
|
||||
includeAgentAwake?: boolean
|
||||
includeAgentRuntime?: boolean
|
||||
includeAgentWorkspaceTrust?: boolean
|
||||
includeCodexTerminalServerIsolation?: boolean
|
||||
}
|
||||
|
||||
const AGENT_AWAKE_SEARCH_ENTRY_ID = 'agent-awake'
|
||||
const AGENT_WORKSPACE_TRUST_SEARCH_ENTRY_ID = 'agent-workspace-trust'
|
||||
const AGENT_RUNTIME_SEARCH_ENTRY_ID = 'agent-runtime'
|
||||
const CODEX_TERMINAL_SERVER_ISOLATION_SEARCH_ENTRY_ID = 'codex-terminal-server-isolation'
|
||||
|
||||
const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [
|
||||
{
|
||||
@@ -121,6 +128,12 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [
|
||||
description: getAgentWorkspaceTrustDescription(),
|
||||
keywords: getAgentWorkspaceTrustSearchKeywords()
|
||||
},
|
||||
{
|
||||
title: getCodexTerminalServerIsolationTitle(),
|
||||
id: CODEX_TERMINAL_SERVER_ISOLATION_SEARCH_ENTRY_ID,
|
||||
description: getCodexTerminalServerIsolationDescription(),
|
||||
keywords: getCodexTerminalServerIsolationSearchKeywords()
|
||||
},
|
||||
{
|
||||
title: getAgentGeneratedTabTitlesTitle(),
|
||||
description: getAgentGeneratedTabTitlesDescription(),
|
||||
@@ -159,15 +172,23 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [
|
||||
export function getAgentsPaneSearchEntries({
|
||||
includeAgentAwake = true,
|
||||
includeAgentRuntime = true,
|
||||
includeAgentWorkspaceTrust = true
|
||||
includeAgentWorkspaceTrust = true,
|
||||
includeCodexTerminalServerIsolation = true
|
||||
}: AgentsPaneSearchOptions = {}) {
|
||||
const entries = getAllAgentsPaneSearchEntries()
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
(!('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID || includeAgentRuntime) &&
|
||||
(!('id' in entry) || entry.id !== AGENT_AWAKE_SEARCH_ENTRY_ID || includeAgentAwake) &&
|
||||
(!('id' in entry) ||
|
||||
entry.id !== AGENT_WORKSPACE_TRUST_SEARCH_ENTRY_ID ||
|
||||
includeAgentWorkspaceTrust)
|
||||
const hiddenIds = new Set<string>()
|
||||
if (!includeAgentAwake) {
|
||||
hiddenIds.add(AGENT_AWAKE_SEARCH_ENTRY_ID)
|
||||
}
|
||||
if (!includeAgentRuntime) {
|
||||
hiddenIds.add(AGENT_RUNTIME_SEARCH_ENTRY_ID)
|
||||
}
|
||||
if (!includeAgentWorkspaceTrust) {
|
||||
hiddenIds.add(AGENT_WORKSPACE_TRUST_SEARCH_ENTRY_ID)
|
||||
}
|
||||
if (!includeCodexTerminalServerIsolation) {
|
||||
hiddenIds.add(CODEX_TERMINAL_SERVER_ISOLATION_SEARCH_ENTRY_ID)
|
||||
}
|
||||
return getAllAgentsPaneSearchEntries().filter(
|
||||
(entry) => !('id' in entry) || !hiddenIds.has(entry.id)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { searchKeywords } from './settings-search-keywords'
|
||||
|
||||
export function getCodexTerminalServerIsolationTitle(): string {
|
||||
return translate(
|
||||
'settings.agents.codexTerminalServerIsolation.title',
|
||||
'Run each Codex terminal on its own server'
|
||||
)
|
||||
}
|
||||
|
||||
export function getCodexTerminalServerIsolationDescription(): string {
|
||||
return translate(
|
||||
'settings.agents.codexTerminalServerIsolation.description',
|
||||
"Keeps Orca's status and closing tabs working correctly. Turn off to use Codex's shared server and its agents overview. Applies to new terminals."
|
||||
)
|
||||
}
|
||||
|
||||
export function getCodexTerminalServerIsolationSearchKeywords(): string[] {
|
||||
return searchKeywords([
|
||||
{
|
||||
key: 'auto.components.settings.agents.search.5ded38b843',
|
||||
fallback: 'codex',
|
||||
englishOnly: true
|
||||
},
|
||||
{ key: 'auto.components.settings.agents.search.7e15b89f6e', fallback: 'server' },
|
||||
{ key: 'auto.components.settings.agents.search.ff457e1e7b', fallback: 'daemon' },
|
||||
{ key: 'auto.components.settings.agents.search.9f3becc4e8', fallback: 'shared' },
|
||||
{ key: 'auto.components.settings.agents.search.34337ed5c7', fallback: 'isolate' },
|
||||
{ key: 'auto.components.settings.agents.search.9a84e65118', fallback: 'agents overview' },
|
||||
{ key: 'auto.components.settings.agents.search.6984d4291a', fallback: 'status' }
|
||||
])
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
|
||||
import { useCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice'
|
||||
|
||||
// Why a real zustand store double: the hook relies on subscribe/setState semantics.
|
||||
const { toastInfoMock, harness } = vi.hoisted(() => ({
|
||||
toastInfoMock: vi.fn(),
|
||||
harness: { setState: (_patch: Record<string, unknown>, _replace?: true): void => {} }
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: toastInfoMock } }))
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { createStore } = await import('zustand/vanilla')
|
||||
const backing = createStore<Record<string, unknown>>()(() => ({}))
|
||||
harness.setState = (patch, replace) =>
|
||||
replace ? backing.setState(patch, true) : backing.setState(patch)
|
||||
const useAppStore = <T>(selector: (state: Record<string, unknown>) => T): T =>
|
||||
selector(backing.getState())
|
||||
return { useAppStore: Object.assign(useAppStore, backing) }
|
||||
})
|
||||
|
||||
const store = {
|
||||
setState: (patch: Record<string, unknown>, replace?: true) => harness.setState(patch, replace)
|
||||
}
|
||||
let seen = false
|
||||
|
||||
const openSettingsPage = vi.fn()
|
||||
const openSettingsTarget = vi.fn()
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
function resetStore(overrides: Record<string, unknown> = {}): void {
|
||||
seen = false
|
||||
store.setState(
|
||||
{
|
||||
persistedUIReady: true,
|
||||
codexTerminalServerIsolationNoticeSeen: false,
|
||||
settings: { codexTerminalServerIsolation: true },
|
||||
tabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
paneForegroundAgentByPaneKey: {},
|
||||
openSettingsPage,
|
||||
openSettingsTarget,
|
||||
markCodexTerminalServerIsolationNoticeSeen: () => {
|
||||
seen = true
|
||||
store.setState({ codexTerminalServerIsolationNoticeSeen: true })
|
||||
},
|
||||
...overrides
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
function HookProbe(): null {
|
||||
useCodexTerminalServerIsolationNotice()
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountProbe(): Promise<void> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe))
|
||||
})
|
||||
}
|
||||
|
||||
const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] }
|
||||
|
||||
describe('useCodexTerminalServerIsolationNotice', () => {
|
||||
beforeEach(() => {
|
||||
toastInfoMock.mockReset()
|
||||
openSettingsPage.mockReset()
|
||||
openSettingsTarget.mockReset()
|
||||
resetStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('shows once when the first Codex terminal starts, and marks it seen', async () => {
|
||||
await mountProbe()
|
||||
expect(toastInfoMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => store.setState({ tabsByWorktree: codexTab }))
|
||||
act(() => store.setState({ agentStatusByPaneKey: { 'tab-2:leaf': { agentType: 'codex' } } }))
|
||||
|
||||
expect(toastInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(toastInfoMock.mock.calls[0]?.[1]).toMatchObject({ duration: Infinity })
|
||||
expect(seen).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a typed codex seen by hooks',
|
||||
{ agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } }
|
||||
],
|
||||
[
|
||||
'a typed codex in the foreground',
|
||||
{ paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'codex' } } }
|
||||
]
|
||||
])('also triggers on %s', async (_name, patch) => {
|
||||
await mountProbe()
|
||||
act(() => store.setState(patch))
|
||||
expect(toastInfoMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never shows for other agents', async () => {
|
||||
await mountProbe()
|
||||
act(() =>
|
||||
store.setState({
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent: 'claude' }] },
|
||||
agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'claude' } },
|
||||
paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'opencode' } }
|
||||
})
|
||||
)
|
||||
expect(toastInfoMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['it was already seen', { codexTerminalServerIsolationNoticeSeen: true }],
|
||||
['the user turned the setting off', { settings: { codexTerminalServerIsolation: false } }],
|
||||
['persisted UI has not hydrated', { persistedUIReady: false }]
|
||||
])('stays quiet when %s', async (_name, overrides) => {
|
||||
resetStore({ ...overrides, tabsByWorktree: codexTab })
|
||||
await mountProbe()
|
||||
expect(toastInfoMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens Settings at the Codex server setting', async () => {
|
||||
resetStore({ tabsByWorktree: codexTab })
|
||||
await mountProbe()
|
||||
|
||||
toastInfoMock.mock.calls[0]?.[1]?.action.onClick()
|
||||
|
||||
expect(openSettingsPage).toHaveBeenCalledTimes(1)
|
||||
expect(openSettingsTarget).toHaveBeenCalledWith({
|
||||
pane: 'agents',
|
||||
repoId: null,
|
||||
sectionId: CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
|
||||
import { isCodexTerminalServerIsolationEnabled } from '../../../../shared/codex-terminal-server-isolation'
|
||||
|
||||
type CodexNoticeState = Pick<
|
||||
AppState,
|
||||
| 'persistedUIReady'
|
||||
| 'codexTerminalServerIsolationNoticeSeen'
|
||||
| 'settings'
|
||||
| 'tabsByWorktree'
|
||||
| 'agentStatusByPaneKey'
|
||||
| 'paneForegroundAgentByPaneKey'
|
||||
>
|
||||
|
||||
// Why three sources: Orca-launched tabs, hook-reported agents (SSH too), and a typed `codex` seen locally.
|
||||
function hasCodexTerminal(state: CodexNoticeState): boolean {
|
||||
return (
|
||||
Object.values(state.tabsByWorktree).some((tabs) =>
|
||||
tabs.some((tab) => tab.launchAgent === 'codex')
|
||||
) ||
|
||||
Object.values(state.agentStatusByPaneKey).some((entry) => entry.agentType === 'codex') ||
|
||||
Object.values(state.paneForegroundAgentByPaneKey).some((entry) => entry.agent === 'codex')
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowCodexTerminalServerIsolationNotice(state: CodexNoticeState): boolean {
|
||||
return (
|
||||
state.persistedUIReady &&
|
||||
!state.codexTerminalServerIsolationNoticeSeen &&
|
||||
state.settings !== null &&
|
||||
// Why: a user who already opted out needs no announcement of the default.
|
||||
isCodexTerminalServerIsolationEnabled(state.settings) &&
|
||||
hasCodexTerminal(state)
|
||||
)
|
||||
}
|
||||
|
||||
function didNoticeInputsChange(state: CodexNoticeState, previous: CodexNoticeState): boolean {
|
||||
return (
|
||||
state.persistedUIReady !== previous.persistedUIReady ||
|
||||
state.settings !== previous.settings ||
|
||||
state.tabsByWorktree !== previous.tabsByWorktree ||
|
||||
state.agentStatusByPaneKey !== previous.agentStatusByPaneKey ||
|
||||
state.paneForegroundAgentByPaneKey !== previous.paneForegroundAgentByPaneKey
|
||||
)
|
||||
}
|
||||
|
||||
function showCodexTerminalServerIsolationNotice(): void {
|
||||
// Why mark before showing: seen means shown, so a quit or reload never repeats it.
|
||||
useAppStore.getState().markCodexTerminalServerIsolationNoticeSeen()
|
||||
toast.info(
|
||||
translate(
|
||||
'terminal.codexTerminalServerIsolationNotice.title',
|
||||
'Orca now runs Codex without its shared server'
|
||||
),
|
||||
{
|
||||
// Why a stable id: a late sync that resets the flag can't stack a second toast.
|
||||
id: 'codex-terminal-server-isolation-notice',
|
||||
description: translate(
|
||||
'terminal.codexTerminalServerIsolationNotice.description',
|
||||
'This makes agent status more reliable. You can turn it back on in Settings.'
|
||||
),
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: translate(
|
||||
'terminal.codexTerminalServerIsolationNotice.openSettings',
|
||||
'Open Settings'
|
||||
),
|
||||
onClick: () => {
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsPage()
|
||||
store.openSettingsTarget({
|
||||
pane: 'agents',
|
||||
repoId: null,
|
||||
sectionId: CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function useCodexTerminalServerIsolationNotice(): void {
|
||||
const seen = useAppStore((s) => s.codexTerminalServerIsolationNoticeSeen)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: a paired web client's terminals follow the host's setting, not this window's.
|
||||
if (seen || isPairedWebClientWindow()) {
|
||||
return
|
||||
}
|
||||
if (shouldShowCodexTerminalServerIsolationNotice(useAppStore.getState())) {
|
||||
showCodexTerminalServerIsolationNotice()
|
||||
return
|
||||
}
|
||||
// Why a filtered subscription: a selector would rescan every tab on each store write.
|
||||
const unsubscribe = useAppStore.subscribe((state, previous) => {
|
||||
if (
|
||||
didNoticeInputsChange(state, previous) &&
|
||||
shouldShowCodexTerminalServerIsolationNotice(state)
|
||||
) {
|
||||
unsubscribe()
|
||||
showCodexTerminalServerIsolationNotice()
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [seen])
|
||||
}
|
||||
@@ -43,7 +43,8 @@ export function buildCapabilitySettingsSections({
|
||||
searchEntries: getAgentsPaneSearchEntries({
|
||||
includeAgentAwake: !isWebClient,
|
||||
includeAgentRuntime: isLocalWindowsHost,
|
||||
includeAgentWorkspaceTrust: !isWebClient
|
||||
includeAgentWorkspaceTrust: !isWebClient,
|
||||
includeCodexTerminalServerIsolation: !isWebClient
|
||||
}),
|
||||
group: 'capabilities'
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { getCodexTerminalServerIsolationTitle } from '../components/settings/codex-terminal-server-isolation-copy'
|
||||
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
@@ -179,6 +180,21 @@ describe('settings navigation metadata', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lists the host-only Codex server setting in desktop Agents search only', () => {
|
||||
const agentTitles = (isWebClient: boolean): string[] | undefined =>
|
||||
buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isWebClient,
|
||||
repos: [repo]
|
||||
})
|
||||
.find((section) => section.id === 'agents')
|
||||
?.searchEntries.map((entry) => entry.title)
|
||||
|
||||
expect(agentTitles(false)).toContain(getCodexTerminalServerIsolationTitle())
|
||||
expect(agentTitles(true)).not.toContain(getCodexTerminalServerIsolationTitle())
|
||||
})
|
||||
|
||||
it('keeps the Browser shortcut searchable for a capable web runtime', () => {
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
|
||||
+8
-3
@@ -1588,14 +1588,12 @@
|
||||
"042c551bc5": "config",
|
||||
"0d1c334987": "lid",
|
||||
"0d752916f8": "hooks",
|
||||
"agent-trust-trust": "trust",
|
||||
"agent-trust-folder": "folder",
|
||||
"agent-trust-worktree": "worktree",
|
||||
"13b20636a6": "waiting",
|
||||
"167daeb5e9": "command",
|
||||
"2afd3b5858": "enable",
|
||||
"2e188c771c": "hide",
|
||||
"32836788b0": "generated title",
|
||||
"34337ed5c7": "isolate",
|
||||
"48f84d10f1": "running",
|
||||
"52115d0d7c": "auto",
|
||||
"5784ae8c43": "rename",
|
||||
@@ -1605,16 +1603,22 @@
|
||||
"66b6b82eb4": "awake",
|
||||
"6956646a1e": "title",
|
||||
"6984d4291a": "status",
|
||||
"7e15b89f6e": "server",
|
||||
"845ad9128a": "power",
|
||||
"848dcae8d3": "generated",
|
||||
"8599603496": "done",
|
||||
"87fffe6c20": "show",
|
||||
"8a17fd6026": "stable",
|
||||
"966890236d": "name",
|
||||
"9a84e65118": "agents overview",
|
||||
"9f3becc4e8": "shared",
|
||||
"a6d594c17d": "install",
|
||||
"a79d266f71": "session",
|
||||
"afbf35be68": "stable session",
|
||||
"affbf130f6": "working",
|
||||
"agent-trust-folder": "folder",
|
||||
"agent-trust-trust": "trust",
|
||||
"agent-trust-worktree": "worktree",
|
||||
"be59907510": "override",
|
||||
"be7ea3553b": "tab",
|
||||
"c1317fe641": "restore",
|
||||
@@ -1627,6 +1631,7 @@
|
||||
"ef804b7337": "Agent Location",
|
||||
"f2932bf22b": "detected",
|
||||
"f412abbba5": "claude",
|
||||
"ff457e1e7b": "daemon",
|
||||
"ff8de8a2ad": "display"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -208,6 +208,12 @@
|
||||
"restartRequired": "Restart required",
|
||||
"unavailable": "Browser identity is unavailable."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"codexTerminalServerIsolation": {
|
||||
"title": "Run each Codex terminal on its own server",
|
||||
"description": "Keeps Orca's status and closing tabs working correctly. Turn off to use Codex's shared server and its agents overview. Applies to new terminals."
|
||||
}
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -9190,6 +9196,11 @@
|
||||
"042c551bc5": "config",
|
||||
"f412abbba5": "claude",
|
||||
"5ded38b843": "codex",
|
||||
"7e15b89f6e": "server",
|
||||
"ff457e1e7b": "daemon",
|
||||
"9f3becc4e8": "shared",
|
||||
"34337ed5c7": "isolate",
|
||||
"9a84e65118": "agents overview",
|
||||
"be7ea3553b": "tab",
|
||||
"6956646a1e": "title",
|
||||
"32836788b0": "generated title",
|
||||
@@ -18523,6 +18534,11 @@
|
||||
"title": "Could not save terminal session",
|
||||
"capacity": "The device saving this terminal reported full storage or an exceeded storage quota. Free space on that device, then try again.",
|
||||
"unknown": "Orca could not save the terminal state. Try again. If this continues, share the Orca logs with support so we can identify the cause."
|
||||
},
|
||||
"codexTerminalServerIsolationNotice": {
|
||||
"title": "Orca now runs Codex without its shared server",
|
||||
"description": "This makes agent status more reliable. You can turn it back on in Settings.",
|
||||
"openSettings": "Open Settings"
|
||||
}
|
||||
},
|
||||
"fileExplorer": {
|
||||
|
||||
@@ -66,6 +66,8 @@ export const BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID = 'browser-termina
|
||||
export const BROWSER_CLIENT_HOSTED_REMOTE_SETTINGS_TARGET_ID = 'browser-client-hosted-remote'
|
||||
export const BROWSER_SSH_WORKSPACE_ROUTING_SETTINGS_TARGET_ID = 'browser-ssh-workspace-routing'
|
||||
export const BROWSER_USER_AGENT_SETTINGS_TARGET_ID = 'browser-user-agent'
|
||||
export const CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID =
|
||||
'agents-codex-terminal-server-isolation'
|
||||
export const GLOBAL_WORKTREE_VISIBILITY_SETTINGS_TARGET_ID = 'general-global-worktree-visibility'
|
||||
|
||||
export type SettingsNavigationTarget = {
|
||||
|
||||
@@ -114,4 +114,6 @@ export type UISliceContextual = {
|
||||
dismissUsagePercentageDisplayChangeNotice: () => void
|
||||
usageEmptyStateDismissed: boolean
|
||||
dismissUsageEmptyState: () => void
|
||||
codexTerminalServerIsolationNoticeSeen: boolean
|
||||
markCodexTerminalServerIsolationNoticeSeen: () => void
|
||||
}
|
||||
|
||||
@@ -242,6 +242,8 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par
|
||||
ui.usagePercentageDisplayChangeNoticeDismissed === true,
|
||||
// Why: default false so existing users still see the CTA; only explicit dismissal persists true.
|
||||
usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true,
|
||||
codexTerminalServerIsolationNoticeSeen:
|
||||
ui.codexTerminalServerIsolationNoticeSeen === true,
|
||||
...hydrateAgentReadState(ui),
|
||||
workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals(
|
||||
ui.workspaceCleanup?.dismissals
|
||||
|
||||
@@ -138,6 +138,16 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial
|
||||
}
|
||||
window.api.ui.set({ usageEmptyStateDismissed: true }).catch(console.error)
|
||||
return { usageEmptyStateDismissed: true }
|
||||
}),
|
||||
// Why default true: nothing may toast before hydration reads the persisted flag.
|
||||
codexTerminalServerIsolationNoticeSeen: true,
|
||||
markCodexTerminalServerIsolationNoticeSeen: () =>
|
||||
set((s) => {
|
||||
if (s.codexTerminalServerIsolationNoticeSeen) {
|
||||
return s
|
||||
}
|
||||
window.api.ui.set({ codexTerminalServerIsolationNoticeSeen: true }).catch(console.error)
|
||||
return { codexTerminalServerIsolationNoticeSeen: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
|
||||
type CodexTerminalServerIsolationSettings =
|
||||
| Partial<Pick<GlobalSettings, 'codexTerminalServerIsolation'>>
|
||||
| null
|
||||
| undefined
|
||||
|
||||
// Why this name: the codex shell wrapper (codex-shell-launch-preflight.ts) reads it to skip --no-daemon.
|
||||
const CODEX_ISOLATE_ENV = 'ORCA_CODEX_ISOLATE'
|
||||
|
||||
export function isCodexTerminalServerIsolationEnabled(
|
||||
settings: CodexTerminalServerIsolationSettings
|
||||
): boolean {
|
||||
return settings?.codexTerminalServerIsolation !== false
|
||||
}
|
||||
|
||||
/** Opt-out only: with isolation on nothing is injected, so behaviour matches the pre-setting default. */
|
||||
export function withCodexTerminalServerIsolationEnv(
|
||||
env: Record<string, string> | undefined,
|
||||
settings: CodexTerminalServerIsolationSettings
|
||||
): Record<string, string> | undefined {
|
||||
if (isCodexTerminalServerIsolationEnabled(settings)) {
|
||||
return env
|
||||
}
|
||||
return { ...env, [CODEX_ISOLATE_ENV]: '0' }
|
||||
}
|
||||
@@ -221,6 +221,7 @@ export function buildDefaultSettings(args: {
|
||||
agentYoloDefaultsMigrated: true,
|
||||
agentStatusHooksEnabled: true,
|
||||
agentWorkspaceTrustEnabled: true,
|
||||
codexTerminalServerIsolation: true,
|
||||
tabAutoGenerateTitle: false,
|
||||
confirmClosePinnedTab: true,
|
||||
editorPreviewTabsEnabled: true,
|
||||
|
||||
@@ -419,6 +419,8 @@ export type GlobalSettings = {
|
||||
agentStatusHooksEnabled: boolean
|
||||
/** Pre-trust the worktree or folder Orca starts an agent in, so its "trust this folder?" prompt is skipped. Defaults on. */
|
||||
agentWorkspaceTrustEnabled: boolean
|
||||
/** Why: Codex's shared server runs every tab's hooks with the first tab's env; off opts new terminals back into it. Absent reads as on. */
|
||||
codexTerminalServerIsolation?: boolean
|
||||
/** Dismissed freshness tuples: no write authority, just suppress re-nudging the same official placement/revision. */
|
||||
dismissedSkillFreshnessNudges?: string[]
|
||||
/** Why: generated tab titles are subjective, so they stay opt-in and manual renames win. */
|
||||
|
||||
@@ -170,6 +170,8 @@ export type PersistedUIState = {
|
||||
usagePercentageDisplayChangeNoticeDismissed?: boolean
|
||||
/** User-hidden empty-state usage CTA; permanently hides the "Connect AI accounts" prompt even if providers are later disconnected. */
|
||||
usageEmptyStateDismissed?: boolean
|
||||
/** One-shot toast announcing per-terminal Codex servers; set when shown, so absent means not yet seen. */
|
||||
codexTerminalServerIsolationNoticeSeen?: boolean
|
||||
/** URL for new browser tabs; null = blank tab. */
|
||||
browserDefaultUrl?: string | null
|
||||
browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null
|
||||
|
||||
@@ -238,6 +238,7 @@ export const UiUpdateFields = z
|
||||
projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
|
||||
usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(),
|
||||
usageEmptyStateDismissed: z.boolean().optional(),
|
||||
codexTerminalServerIsolationNoticeSeen: z.boolean().optional(),
|
||||
petVisible: z.boolean().optional(),
|
||||
petId: z.string().optional(),
|
||||
customPets: UnknownRecordArray.optional(),
|
||||
|
||||
Reference in New Issue
Block a user