diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index 5881bc3530..d021e683eb 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -39,6 +39,7 @@ import { useInstanceModelCatalog } from '@/lib/hooks/use-instance-model-catalog' import { useLaunchFolder } from '@/lib/hooks/use-launch-folder'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; +import { usePersistedRunOnDestination } from '@/lib/hooks/use-persisted-run-on-destination'; import { createRemoteModelOverride } from '@/lib/hooks/use-session-model-options'; import { resolveContinueStartDisabled, @@ -54,6 +55,7 @@ import { import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; import { useFencedDraftLoad, useRemoteSpawnDraftCleanup } from '@/lib/persist/use-draft-load'; import { type InstancePickerInstance, type ModelPickerSelection } from '@/lib/picker-bridge'; +import { resolvePersistedRunOn } from '@/lib/run-on-destination'; import { shouldShowRunOnSelector } from '@/lib/should-show-run-on-selector'; import { peekSharePayload } from '@/lib/share-payload'; import { useNewSessionShareRemote } from '@/lib/use-new-session-share-remote'; @@ -115,6 +117,8 @@ export function NewSessionScreenBody() { // without navigating (failure), so an abandon after a failed spawn still // confirms. const skipDiscardGuardRef = useRef(false); + const runOnRestoredRef = useRef(false); + const runOnUserPickedRef = useRef(false); const showRunOnSelector = shouldShowRunOnSelector(organizationId); @@ -186,6 +190,11 @@ export function NewSessionScreenBody() { ); const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId); const { saveModel } = usePersistedAgentModel(); + const { + storedConnectionId, + hasLoaded: hasLoadedRunOn, + saveRunOn, + } = usePersistedRunOnDestination(); const attachments = useAgentAttachmentUpload({ organizationId }); // Custom modes and the pinned model come from the effective default profile. @@ -261,6 +270,17 @@ export function NewSessionScreenBody() { [instancesData] ); + useEffect(() => { + if (runOnRestoredRef.current || runOnUserPickedRef.current) { + return; + } + if (!hasLoadedRunOn || instancesData === undefined) { + return; + } + runOnRestoredRef.current = true; + setRunOnInstance(resolvePersistedRunOn(storedConnectionId, instanceList)); + }, [hasLoadedRunOn, instanceList, instancesData, storedConnectionId]); + // A successful session creation owns clearing the new-session draft; a // failure must preserve it for the retry. The success path navigates via // `replace`, so arm the discard-confirm bypass here — `onCreated` runs @@ -410,11 +430,13 @@ export function NewSessionScreenBody() { const handleRunOnChange = useCallback( (next: InstancePickerInstance | null) => { + runOnUserPickedRef.current = true; + saveRunOn(next?.connectionId ?? null); setRemoteOverride(null); setCloneImportFailureKey(null); handleRunOnInstanceChange(next); }, - [handleRunOnInstanceChange] + [handleRunOnInstanceChange, saveRunOn] ); function handlePromptChange(text: string) { diff --git a/apps/mobile/src/lib/auth/auth-context.test.tsx b/apps/mobile/src/lib/auth/auth-context.test.tsx index 45fc3fa7f8..19742e4ebd 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.test.tsx @@ -216,6 +216,10 @@ vi.mock('@/lib/hooks/use-persisted-agent-model', () => ({ clearAgentModelPreference: vi.fn(), })); +vi.mock('@/lib/hooks/use-persisted-run-on-destination', () => ({ + clearRunOnDestinationPreference: vi.fn(), +})); + const { clearKeepScreenOnPreference, clearReasoningPreference, clearPrReviewFooterPreference } = vi.hoisted(() => ({ clearKeepScreenOnPreference: vi.fn(), @@ -616,6 +620,9 @@ describe('sign-out teardown ordering', () => { expect(clearKeepScreenOnPreference).toHaveBeenCalled(); expect(clearReasoningPreference).toHaveBeenCalled(); expect(clearPrReviewFooterPreference).toHaveBeenCalled(); + const { clearRunOnDestinationPreference } = + await import('@/lib/hooks/use-persisted-run-on-destination'); + expect(clearRunOnDestinationPreference).toHaveBeenCalled(); }); it('closes the ownership gate before any await and blocks a late persist', async () => { diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index 94a1e210f7..59f92f2b2b 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -45,6 +45,7 @@ import { import { readStoredValueWithRetry } from '@/lib/auth/secure-store-read'; import { chainSave } from '@/lib/hooks/save-chain'; import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model'; +import { clearRunOnDestinationPreference } from '@/lib/hooks/use-persisted-run-on-destination'; import { clearKeepScreenOnPreference } from '@/lib/hooks/use-keep-screen-on-preference'; import { clearLiveActivityPreference } from '@/lib/hooks/use-live-activity-preference'; import { clearPrReviewFooterPreference } from '@/lib/hooks/use-pr-review-footer-preference'; @@ -476,6 +477,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { // Synchronous preference clears (best-effort) so nothing leaks to // the next signed-in account. clearAgentModelPreference(); + clearRunOnDestinationPreference(); clearReasoningPreference(); clearKeepScreenOnPreference(); clearLiveActivityPreference(); diff --git a/apps/mobile/src/lib/auth/credentials.test.ts b/apps/mobile/src/lib/auth/credentials.test.ts index 09ded102b0..f50a8503de 100644 --- a/apps/mobile/src/lib/auth/credentials.test.ts +++ b/apps/mobile/src/lib/auth/credentials.test.ts @@ -58,6 +58,9 @@ vi.mock('@/lib/query-client', () => ({ })); vi.mock('@/lib/auth/trpc-unauthorized', () => ({ setTrpcUnauthorizedHandler: vi.fn() })); vi.mock('@/lib/hooks/use-persisted-agent-model', () => ({ clearAgentModelPreference: vi.fn() })); +vi.mock('@/lib/hooks/use-persisted-run-on-destination', () => ({ + clearRunOnDestinationPreference: vi.fn(), +})); vi.mock('@/lib/hooks/use-keep-screen-on-preference', () => ({ clearKeepScreenOnPreference: vi.fn(), })); diff --git a/apps/mobile/src/lib/hooks/use-persisted-run-on-destination.ts b/apps/mobile/src/lib/hooks/use-persisted-run-on-destination.ts new file mode 100644 index 0000000000..9fcb54f921 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-persisted-run-on-destination.ts @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from 'react'; + +import { createSecureStorePreference } from '@/lib/hooks/secure-store-preference'; +import { parseStoredRunOnDestination } from '@/lib/run-on-destination'; +import { LAST_RUN_ON_DESTINATION_KEY } from '@/lib/storage-keys'; + +const store = createSecureStorePreference({ + key: LAST_RUN_ON_DESTINATION_KEY, + defaultValue: null, + parse: parseStoredRunOnDestination, + serialize: value => value ?? '', +}); + +export function clearRunOnDestinationPreference() { + store.clear(); +} + +function saveRunOn(connectionId: string | null) { + store.set(connectionId); +} + +export function usePersistedRunOnDestination() { + const storedConnectionId = useSyncExternalStore(store.subscribe, store.get); + const hasLoaded = useSyncExternalStore(store.subscribe, store.getHasLoaded); + return { storedConnectionId, hasLoaded, saveRunOn }; +} diff --git a/apps/mobile/src/lib/run-on-destination.test.ts b/apps/mobile/src/lib/run-on-destination.test.ts new file mode 100644 index 0000000000..45ffc09b21 --- /dev/null +++ b/apps/mobile/src/lib/run-on-destination.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { parseStoredRunOnDestination, resolvePersistedRunOn } from './run-on-destination'; + +const CLI = { connectionId: 'cli-1', name: 'MacBook' }; +const REMOTE = { connectionId: 'remote-1', name: 'VM' }; + +describe('parseStoredRunOnDestination', () => { + it('treats missing or empty storage as Cloud Agent', () => { + expect(parseStoredRunOnDestination(null)).toBeNull(); + expect(parseStoredRunOnDestination('')).toBeNull(); + }); + + it('returns a stored connection id', () => { + expect(parseStoredRunOnDestination('cli-1')).toBe('cli-1'); + }); +}); + +describe('resolvePersistedRunOn', () => { + it('defaults to Cloud Agent when nothing is stored', () => { + expect(resolvePersistedRunOn(null, [CLI])).toBeNull(); + }); + + it('returns the live row when the stored id is in the list', () => { + expect(resolvePersistedRunOn('cli-1', [REMOTE, CLI])).toBe(CLI); + }); + + it('falls back to Cloud Agent when the stored id is gone', () => { + expect(resolvePersistedRunOn('cli-1', [REMOTE])).toBeNull(); + expect(resolvePersistedRunOn('cli-1', [])).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/run-on-destination.ts b/apps/mobile/src/lib/run-on-destination.ts new file mode 100644 index 0000000000..034f99f0ab --- /dev/null +++ b/apps/mobile/src/lib/run-on-destination.ts @@ -0,0 +1,16 @@ +export function parseStoredRunOnDestination(raw: string | null): string | null { + if (!raw) { + return null; + } + return raw; +} + +export function resolvePersistedRunOn( + storedConnectionId: string | null, + instances: readonly T[] +): T | null { + if (!storedConnectionId) { + return null; + } + return instances.find(instance => instance.connectionId === storedConnectionId) ?? null; +} diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index a0b28b302c..6ab3436d05 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -14,6 +14,8 @@ export const SESSION_FILTERS_KEY = 'agent-session-filters'; export const LIVE_SESSION_FILTERS_KEY = 'live-session-filters'; export const NOTIFICATION_PROMPT_SEEN_KEY = 'notification-prompt-seen'; export const LAST_ACTIVE_INSTANCE_KEY = 'last-active-chat-instance'; +/** Last "Run on" destination on the new-agent screen. Empty means Cloud Agent. */ +export const LAST_RUN_ON_DESTINATION_KEY = 'last-run-on-destination'; export const CONSENT_USER_KEY_PREFIX = 'consent-accepted-'; export const AGENT_MODEL_PREFERENCE_KEY = 'agent-model-preference'; export const REASONING_DEFAULT_EXPANDED_KEY = 'agent-reasoning-default-expanded';