Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/mobile/src/components/agents/new-session-screen-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/lib/auth/auth-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/auth/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/auth/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/lib/hooks/use-persisted-run-on-destination.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>({
key: LAST_RUN_ON_DESTINATION_KEY,
defaultValue: null,
parse: parseStoredRunOnDestination,
serialize: value => value ?? '',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Empty SecureStore values fail on iOS, so picking Cloud Agent will not persist

serialize writes '' when the user selects Cloud Agent (saveRunOn(null)). iOS Keychain / expo-secure-store reject empty values, so the write fails (error toast) and the previous CLI connection id stays on disk. On the next visit, restore still selects that CLI.

Use a non-empty sentinel, or store.clear() when the destination is Cloud Agent.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

});

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 };
}
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/run-on-destination.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
16 changes: 16 additions & 0 deletions apps/mobile/src/lib/run-on-destination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function parseStoredRunOnDestination(raw: string | null): string | null {
if (!raw) {
return null;
}
return raw;
}

export function resolvePersistedRunOn<T extends { connectionId: string }>(
storedConnectionId: string | null,
instances: readonly T[]
): T | null {
if (!storedConnectionId) {
return null;
}
return instances.find(instance => instance.connectionId === storedConnectionId) ?? null;
}
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/storage-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down