diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 6c52abb6518..879fcea5984 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -21,6 +21,7 @@ import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime- import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' +import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -56,4 +57,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useOsc52ClipboardDefaultOnNotice(persistedUIReady) useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() + useCodexSharedSettingsNotice() } diff --git a/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts b/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts new file mode 100644 index 00000000000..152f09d67f8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts @@ -0,0 +1,37 @@ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' + +type NoticeTestState = Record + +/** Stands in for `@/store`: a real zustand store, so selectors re-render and subscribe fires. */ +export const noticeTestStore = createStore()(() => ({})) + +export const useAppStore = Object.assign( + (selector: (state: NoticeTestState) => T): T => useStore(noticeTestStore, selector), + noticeTestStore +) + +const mountedRoots: Root[] = [] + +export async function mountHook(useHook: () => void): Promise { + function HookProbe(): null { + useHook() + return null + } + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + await act(async () => root.render(createElement(HookProbe))) +} + +export function unmountHooks(): void { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } +} + +/** Applies a store write and lets the hook re-render before returning. */ +export async function setNoticeState(patch: NoticeTestState): Promise { + await act(async () => noticeTestStore.setState(patch)) +} diff --git a/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts new file mode 100644 index 00000000000..a9de1beb870 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mountHook, + noticeTestStore, + setNoticeState, + unmountHooks +} from './codex-notice-test-harness' +import { useCodexSharedSettingsNotice } from './codex-shared-settings-notice' + +const { toastInfoMock } = vi.hoisted(() => ({ toastInfoMock: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) +vi.mock('@/store', () => import('./codex-notice-test-harness')) + +const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } + +function resetStore(overrides: Record = {}): void { + noticeTestStore.setState( + { + codexSharedSettingsNoticeSeen: false, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + markCodexSharedSettingsNoticeSeen: () => + noticeTestStore.setState({ codexSharedSettingsNoticeSeen: true }), + ...overrides + }, + true + ) +} + +const isSeen = (): unknown => noticeTestStore.getState().codexSharedSettingsNoticeSeen + +describe('useCodexSharedSettingsNotice', () => { + beforeEach(() => { + toastInfoMock.mockReset() + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }) + resetStore() + }) + + afterEach(() => { + unmountHooks() + vi.unstubAllGlobals() + }) + + it('shows once on Windows when a Codex terminal appears, and marks it seen', async () => { + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ tabsByWorktree: codexTab }) + await setNoticeState({ agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } }) + + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(toastInfoMock).toHaveBeenCalledWith('Codex in Orca now uses ~/.codex', { + id: 'codex-shared-settings-notice', + description: + 'Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca.', + duration: Infinity + }) + expect(isSeen()).toBe(true) + }) + + it('stays quiet off Windows', async () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }) + resetStore({ tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + expect(isSeen()).toBe(false) + }) + + it('stays quiet in a paired web client window', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + resetStore({ tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it('shows once the persisted seen flag loads after mount', async () => { + // Why seen: true: the store's default until persisted UI arrives. + resetStore({ codexSharedSettingsNoticeSeen: true, tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ codexSharedSettingsNoticeSeen: false }) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts new file mode 100644 index 00000000000..db05a38d4e7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' +import { whenCodexTerminalAppears } from './codex-terminal-presence' + +function showCodexSharedSettingsNotice(): void { + // Why mark before showing: seen means shown, so a quit or reload never repeats it. + useAppStore.getState().markCodexSharedSettingsNoticeSeen() + toast.info( + translate('terminal.codexSharedSettingsNotice.title', 'Codex in Orca now uses ~/.codex'), + { + // Why a stable id: a late sync that resets the flag can't stack a second toast. + id: 'codex-shared-settings-notice', + description: translate( + 'terminal.codexSharedSettingsNotice.description', + 'Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca.' + ), + // Why no timeout: it is marked seen before showing, so an auto-close would lose it for good. + duration: Infinity + } + ) +} + +export function useCodexSharedSettingsNotice(): void { + // Why no hydration check: the flag defaults to true until the persisted value arrives. + const seen = useAppStore((s) => s.codexSharedSettingsNoticeSeen) + + useEffect(() => { + // Why skip paired web clients: the change is on the host, whose own window shows this. + if (seen || !isLocalWindowsDesktopClient()) { + return + } + return whenCodexTerminalAppears(showCodexSharedSettingsNotice) + }, [seen]) +} diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts b/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts new file mode 100644 index 00000000000..51428c9d3f9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { noticeTestStore } from './codex-notice-test-harness' +import { whenCodexTerminalAppears } from './codex-terminal-presence' + +vi.mock('@/store', () => import('./codex-notice-test-harness')) + +describe('whenCodexTerminalAppears', () => { + beforeEach(() => { + noticeTestStore.setState( + { tabsByWorktree: {}, agentStatusByPaneKey: {}, paneForegroundAgentByPaneKey: {} }, + true + ) + }) + + it.each([ + ['an Orca-launched Codex tab', { tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }], + ['a hook-reported Codex', { agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } }], + [ + 'a typed codex in the foreground', + { paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'codex' } } } + ] + ])('calls back once for %s', (_name, patch) => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + + noticeTestStore.setState(patch) + noticeTestStore.setState({ tabsByWorktree: { 'wt-2': [{ launchAgent: 'codex' }] } }) + + expect(onAppear).toHaveBeenCalledTimes(1) + }) + + it('calls back at once when a Codex terminal already exists', () => { + noticeTestStore.setState({ tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }) + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + expect(onAppear).toHaveBeenCalledTimes(1) + }) + + it('ignores other agents', () => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + noticeTestStore.setState({ + tabsByWorktree: { 'wt-1': [{ launchAgent: 'claude' }] }, + agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'claude' } }, + paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'opencode' } } + }) + expect(onAppear).not.toHaveBeenCalled() + }) + + it('stops watching once unsubscribed', () => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear)() + noticeTestStore.setState({ tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }) + expect(onAppear).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts b/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts new file mode 100644 index 00000000000..6afcd315642 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts @@ -0,0 +1,37 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' + +// Why three sources: Orca-launched tabs, hook-reported agents (SSH too), and a typed `codex` seen locally. +function hasCodexTerminal(state: AppState): 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') + ) +} + +function didSourcesChange(state: AppState, previous: AppState): boolean { + return ( + state.tabsByWorktree !== previous.tabsByWorktree || + state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || + state.paneForegroundAgentByPaneKey !== previous.paneForegroundAgentByPaneKey + ) +} + +/** Calls `onAppear` once, as soon as a Codex terminal exists. Returns the unsubscribe. */ +export function whenCodexTerminalAppears(onAppear: () => void): () => void { + if (hasCodexTerminal(useAppStore.getState())) { + onAppear() + return () => {} + } + // Why a filtered subscription: a selector would rescan every tab on each store write. + const unsubscribe = useAppStore.subscribe((state, previous) => { + if (didSourcesChange(state, previous) && hasCodexTerminal(state)) { + unsubscribe() + onAppear() + } + }) + return unsubscribe +} diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts index ce7ee221898..87878d6fe4d 100644 --- a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts @@ -1,43 +1,27 @@ // @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 { + mountHook, + noticeTestStore, + setNoticeState, + unmountHooks +} from './codex-notice-test-harness' 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, _replace?: true): void => {} } -})) +const { toastInfoMock } = vi.hoisted(() => ({ toastInfoMock: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) - -vi.mock('@/store', async () => { - const { createStore } = await import('zustand/vanilla') - const backing = createStore>()(() => ({})) - harness.setState = (patch, replace) => - replace ? backing.setState(patch, true) : backing.setState(patch) - const useAppStore = (selector: (state: Record) => T): T => - selector(backing.getState()) - return { useAppStore: Object.assign(useAppStore, backing) } -}) - -const store = { - setState: (patch: Record, replace?: true) => harness.setState(patch, replace) -} -let seen = false +vi.mock('@/store', () => import('./codex-notice-test-harness')) const openSettingsPage = vi.fn() const openSettingsTarget = vi.fn() -const mountedRoots: Root[] = [] +const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } function resetStore(overrides: Record = {}): void { - seen = false - store.setState( + noticeTestStore.setState( { - persistedUIReady: true, codexTerminalServerIsolationNoticeSeen: false, settings: { codexTerminalServerIsolation: true }, tabsByWorktree: {}, @@ -45,33 +29,14 @@ function resetStore(overrides: Record = {}): void { paneForegroundAgentByPaneKey: {}, openSettingsPage, openSettingsTarget, - markCodexTerminalServerIsolationNoticeSeen: () => { - seen = true - store.setState({ codexTerminalServerIsolationNoticeSeen: true }) - }, + markCodexTerminalServerIsolationNoticeSeen: () => + noticeTestStore.setState({ codexTerminalServerIsolationNoticeSeen: true }), ...overrides }, true ) } -function HookProbe(): null { - useCodexTerminalServerIsolationNotice() - return null -} - -async function mountProbe(): Promise { - 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() @@ -80,65 +45,50 @@ describe('useCodexTerminalServerIsolationNotice', () => { resetStore() }) - afterEach(() => { - for (const root of mountedRoots.splice(0)) { - act(() => root.unmount()) - } - document.body.innerHTML = '' - }) + afterEach(unmountHooks) it('shows once when the first Codex terminal starts, and marks it seen', async () => { - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) expect(toastInfoMock).not.toHaveBeenCalled() - act(() => store.setState({ tabsByWorktree: codexTab })) - act(() => store.setState({ agentStatusByPaneKey: { 'tab-2:leaf': { agentType: 'codex' } } })) + await setNoticeState({ tabsByWorktree: codexTab }) + await setNoticeState({ 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() + expect(noticeTestStore.getState().codexTerminalServerIsolationNoticeSeen).toBe(true) }) it.each([ ['it was already seen', { codexTerminalServerIsolationNoticeSeen: true }], ['the user turned the setting off', { settings: { codexTerminalServerIsolation: false } }], - ['persisted UI has not hydrated', { persistedUIReady: false }] + ['settings have not loaded', { settings: null }] ])('stays quiet when %s', async (_name, overrides) => { resetStore({ ...overrides, tabsByWorktree: codexTab }) - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it('shows once the persisted seen flag loads after mount', async () => { + // Why seen: true: the store's default until persisted UI arrives. + resetStore({ codexTerminalServerIsolationNoticeSeen: true, tabsByWorktree: codexTab }) + await mountHook(useCodexTerminalServerIsolationNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ codexTerminalServerIsolationNoticeSeen: false }) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) + + it('stops waiting when the user turns the setting off', async () => { + await mountHook(useCodexTerminalServerIsolationNotice) + await setNoticeState({ settings: { codexTerminalServerIsolation: false } }) + await setNoticeState({ tabsByWorktree: codexTab }) expect(toastInfoMock).not.toHaveBeenCalled() }) it('opens Settings at the Codex server setting', async () => { resetStore({ tabsByWorktree: codexTab }) - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) toastInfoMock.mock.calls[0]?.[1]?.action.onClick() diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts index 4fe87294bdf..b04e63d8f00 100644 --- a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts @@ -6,46 +6,15 @@ 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' +import { whenCodexTerminalAppears } from './codex-terminal-presence' -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 { +// Why no hydration check: the seen flag defaults to true until the persisted value arrives. +function isNoticeDue(state: AppState): 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 + isCodexTerminalServerIsolationEnabled(state.settings) ) } @@ -93,27 +62,13 @@ function showCodexTerminalServerIsolationNotice(): void { } export function useCodexTerminalServerIsolationNotice(): void { - const seen = useAppStore((s) => s.codexTerminalServerIsolationNoticeSeen) + const due = useAppStore(isNoticeDue) useEffect(() => { // Why: a paired web client's terminals follow the host's setting, not this window's. - if (seen || isPairedWebClientWindow()) { + if (!due || 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]) + return whenCodexTerminalAppears(showCodexTerminalServerIsolationNotice) + }, [due]) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7b497327dac..cb7b6f4607f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18755,6 +18755,10 @@ "description": "This makes agent status more reliable. You can turn it back on in Settings.", "openSettings": "Open Settings" }, + "codexSharedSettingsNotice": { + "title": "Codex in Orca now uses ~/.codex", + "description": "Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca." + }, "codexSharedServerBanner": { "title": "This Codex is sharing a server with your other Codex tabs", "body": "Sessions may end unexpectedly, and agent status may be wrong.", diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index 180f96e427a..a69a18999c5 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -251,6 +251,19 @@ describe('createUISlice browser import hint dismissal', () => { }) }) +describe('createUISlice Codex shared-settings notice', () => { + it('counts as seen until hydration, then follows the persisted flag', () => { + const store = createUIStore() + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + + store.getState().hydratePersistedUI(makePersistedUI({})) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(false) + + store.getState().hydratePersistedUI(makePersistedUI({ codexSharedSettingsNoticeSeen: true })) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + }) +}) + describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { it('restores the armed notice from persisted UI', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts index f964d6e048a..6ec88e1e4f3 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -116,4 +116,6 @@ export type UISliceContextual = { dismissUsageEmptyState: () => void codexTerminalServerIsolationNoticeSeen: boolean markCodexTerminalServerIsolationNoticeSeen: () => void + codexSharedSettingsNoticeSeen: boolean + markCodexSharedSettingsNoticeSeen: () => void } diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 74f45118b09..dea5b0c2e80 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -244,6 +244,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, codexTerminalServerIsolationNoticeSeen: ui.codexTerminalServerIsolationNoticeSeen === true, + codexSharedSettingsNoticeSeen: ui.codexSharedSettingsNoticeSeen === true, ...hydrateAgentReadState(ui), workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts index dabf69bb29d..1e1c25573a2 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -148,6 +148,15 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial } window.api.ui.set({ codexTerminalServerIsolationNoticeSeen: true }).catch(console.error) return { codexTerminalServerIsolationNoticeSeen: true } + }), + codexSharedSettingsNoticeSeen: true, + markCodexSharedSettingsNoticeSeen: () => + set((s) => { + if (s.codexSharedSettingsNoticeSeen) { + return s + } + window.api.ui.set({ codexSharedSettingsNoticeSeen: true }).catch(console.error) + return { codexSharedSettingsNoticeSeen: true } }) } } diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index feb9a29d213..6eaa295eed4 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -172,6 +172,8 @@ export type PersistedUIState = { usageEmptyStateDismissed?: boolean /** One-shot toast announcing per-terminal Codex servers; set when shown, so absent means not yet seen. */ codexTerminalServerIsolationNoticeSeen?: boolean + /** Windows one-shot toast for Codex moving onto ~/.codex; set when shown, so absent means not yet seen. */ + codexSharedSettingsNoticeSeen?: boolean /** URL for new browser tabs; null = blank tab. */ browserDefaultUrl?: string | null browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index a039594604f..0295247a2e8 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -239,6 +239,7 @@ export const UiUpdateFields = z usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), codexTerminalServerIsolationNoticeSeen: z.boolean().optional(), + codexSharedSettingsNoticeSeen: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), customPets: UnknownRecordArray.optional(),