diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index a2b3d80ef27..8eff217445f 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -135,6 +135,21 @@ describe('useMobileNativeChatSessionOptions', () => { expect(effort!.kind).toMatchObject({ currentValue: 'low' }) }) + it('keeps the live tab’s tracked model when other tabs overflow the record cap', async () => { + mount() + await act(async () => { + await api!.setOption('model', 'opus') + }) + // Far more scopes than the cap, revisiting the live tab in between the way a + // chat↔terminal flip does — insertion-order eviction would shed it. + for (let index = 0; index < 40; index += 1) { + update({ scopeKey: `host\0worktree\0overflow-${index}` }) + update({ scopeKey: 'host\0worktree\0tab' }) + } + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' }) + }) + it('keeps the latest queued operation pending until it settles', async () => { const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = [] dispatchCommand.mockImplementation( diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index fec92ced23e..e91956565fe 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -42,18 +42,21 @@ const recordsByScope = new Map() function getScopedRecord(scopeKey: string, agent: string): NativeChatSessionOptionRecord { const existing = recordsByScope.get(scopeKey) - if (existing && existing.agent === agent) { - return existing - } - const created = createNativeChatSessionOptionRecord(agent) - if (!recordsByScope.has(scopeKey) && recordsByScope.size >= MOBILE_SESSION_OPTION_RECORD_CAP) { + const record = + existing && existing.agent === agent ? existing : createNativeChatSessionOptionRecord(agent) + // Why: delete-then-set on every read makes the touched scope most-recent, so + // eviction only sheds the oldest UNTOUCHED tab. Insertion order alone would let + // a long-lived active tab be the oldest key and lose its tracked model. + recordsByScope.delete(scopeKey) + recordsByScope.set(scopeKey, record) + while (recordsByScope.size > MOBILE_SESSION_OPTION_RECORD_CAP) { const oldest = recordsByScope.keys().next().value - if (oldest !== undefined) { - recordsByScope.delete(oldest) + if (oldest === undefined) { + break } + recordsByScope.delete(oldest) } - recordsByScope.set(scopeKey, created) - return created + return record } export function clearMobileSessionOptionRecordsForTests(): void { diff --git a/mobile/src/session/mobile-native-chat-session-option-commands.test.ts b/src/shared/native-chat-session-option-commands.test.ts similarity index 96% rename from mobile/src/session/mobile-native-chat-session-option-commands.test.ts rename to src/shared/native-chat-session-option-commands.test.ts index dc602737697..7c33ecf0835 100644 --- a/mobile/src/session/mobile-native-chat-session-option-commands.test.ts +++ b/src/shared/native-chat-session-option-commands.test.ts @@ -2,16 +2,16 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_SESSION_OPTION_CATALOG, CODEX_SESSION_OPTION_CATALOG -} from '../../../src/shared/agent-session-option-catalog-claude-codex' +} from './agent-session-option-catalog-claude-codex' import { buildNativeChatSessionOptionCommand, parseBuiltSessionOptionCommand, recordNativeChatSessionOptionCommand -} from '../../../src/shared/native-chat-session-option-commands' +} from './native-chat-session-option-commands' import { createNativeChatSessionOptionRecord, type NativeChatSessionOptionRecord -} from '../../../src/shared/native-chat-session-option-state' +} from './native-chat-session-option-state' function claudeRecord(model?: string): NativeChatSessionOptionRecord { const record = createNativeChatSessionOptionRecord('claude') diff --git a/src/shared/native-chat-session-option-commands.ts b/src/shared/native-chat-session-option-commands.ts index cccbeb33beb..a6eb28fa47b 100644 --- a/src/shared/native-chat-session-option-commands.ts +++ b/src/shared/native-chat-session-option-commands.ts @@ -109,6 +109,8 @@ function recordCommandApply(args: { const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null if (optionId === 'model') { if (previousModelId !== value) { + // Why: a model command can reset model-scoped state, so an older value + // from a prior visit is no longer evidence about this live session. delete record.valuesByModel[value] } record.model = { value, source: 'dispatched' } diff --git a/mobile/src/session/mobile-native-chat-session-option-state.test.ts b/src/shared/native-chat-session-option-snapshot.test.ts similarity index 63% rename from mobile/src/session/mobile-native-chat-session-option-state.test.ts rename to src/shared/native-chat-session-option-snapshot.test.ts index 6872647f729..19feac2e230 100644 --- a/mobile/src/session/mobile-native-chat-session-option-state.test.ts +++ b/src/shared/native-chat-session-option-snapshot.test.ts @@ -2,14 +2,12 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_SESSION_OPTION_CATALOG, CODEX_SESSION_OPTION_CATALOG -} from '../../../src/shared/agent-session-option-catalog-claude-codex' +} from './agent-session-option-catalog-claude-codex' import { - applyNativeChatReportedSessionOptions, createNativeChatSessionOptionRecord, - matchNativeChatCatalogModelId, type NativeChatSessionOptionRecord -} from '../../../src/shared/native-chat-session-option-state' -import { buildNativeChatSessionOptionSnapshot } from '../../../src/shared/native-chat-session-option-snapshot' +} from './native-chat-session-option-state' +import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot' function claudeRecord(): NativeChatSessionOptionRecord { return createNativeChatSessionOptionRecord('claude') @@ -96,37 +94,3 @@ describe('buildNativeChatSessionOptionSnapshot', () => { expect(fastMode).toMatchObject({ action: { type: 'toggle-command' } }) }) }) - -describe('applyNativeChatReportedSessionOptions', () => { - it('reports become authority and reset stale per-model values on a model change', () => { - const record = claudeRecord() - record.model = { value: 'sonnet', source: 'dispatched' } - record.valuesByModel.opus = { effort: { value: 'high', source: 'dispatched' } } - expect(applyNativeChatReportedSessionOptions(record, { model: 'opus' })).toBe(true) - expect(record.model).toEqual({ value: 'opus', source: 'reported' }) - // A model change invalidates previously tracked values for the destination. - expect(record.valuesByModel.opus).toEqual({}) - }) - - it('is a no-op when the report matches tracked state', () => { - const record = claudeRecord() - record.model = { value: 'sonnet', source: 'reported' } - expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false) - }) -}) - -describe('matchNativeChatCatalogModelId', () => { - it('matches exact ids, labels, and provider-id containment', () => { - expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'sonnet')).toBe('sonnet') - expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'Sonnet 5')).toBe('sonnet') - expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'claude-sonnet-5')).toBe( - 'sonnet' - ) - expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'gpt-5.5')).toBe('gpt-5.5') - }) - - it('returns null for unrecognized reports', () => { - expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'mystery-model')).toBeNull() - expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, '')).toBeNull() - }) -}) diff --git a/src/shared/native-chat-session-option-snapshot.ts b/src/shared/native-chat-session-option-snapshot.ts index e0c4da8dbc6..b10a9eab6ea 100644 --- a/src/shared/native-chat-session-option-snapshot.ts +++ b/src/shared/native-chat-session-option-snapshot.ts @@ -58,6 +58,8 @@ function actionForApply( if (apply.midSession?.kind === 'agent-picker') { return { type: 'agent-picker' } } + // Why: only unknown flip-only options are actions; once we have a tracked + // baseline the UI can show absolute On/Off without inventing a start state. return isFlipOnlyMidSession(apply.midSession) && !tracked ? { type: 'toggle-command' } : undefined } diff --git a/src/shared/native-chat-session-option-state.test.ts b/src/shared/native-chat-session-option-state.test.ts new file mode 100644 index 00000000000..f2827207bf7 --- /dev/null +++ b/src/shared/native-chat-session-option-state.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + CLAUDE_SESSION_OPTION_CATALOG, + CODEX_SESSION_OPTION_CATALOG +} from './agent-session-option-catalog-claude-codex' +import { + applyNativeChatReportedSessionOptions, + createNativeChatSessionOptionRecord, + matchNativeChatCatalogModelId, + type NativeChatSessionOptionRecord +} from './native-chat-session-option-state' + +function claudeRecord(): NativeChatSessionOptionRecord { + return createNativeChatSessionOptionRecord('claude') +} + +describe('applyNativeChatReportedSessionOptions', () => { + it('reports become authority and reset stale per-model values on a model change', () => { + const record = claudeRecord() + record.model = { value: 'sonnet', source: 'dispatched' } + record.valuesByModel.opus = { effort: { value: 'high', source: 'dispatched' } } + expect(applyNativeChatReportedSessionOptions(record, { model: 'opus' })).toBe(true) + expect(record.model).toEqual({ value: 'opus', source: 'reported' }) + // A model change invalidates previously tracked values for the destination. + expect(record.valuesByModel.opus).toEqual({}) + }) + + it('promotes a dispatched guess the report confirms', () => { + const record = claudeRecord() + record.model = { value: 'sonnet', source: 'dispatched' } + expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(true) + expect(record.model).toEqual({ value: 'sonnet', source: 'reported' }) + }) + + it('keeps locally tracked options when the reported model is unchanged', () => { + const record = claudeRecord() + record.model = { value: 'sonnet', source: 'reported' } + record.valuesByModel.sonnet = { effort: { value: 'high', source: 'dispatched' } } + expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false) + expect(record.valuesByModel.sonnet?.effort).toEqual({ value: 'high', source: 'dispatched' }) + }) + + it('is a no-op when the report matches tracked state', () => { + const record = claudeRecord() + record.model = { value: 'sonnet', source: 'reported' } + expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false) + }) +}) + +describe('matchNativeChatCatalogModelId', () => { + it('matches exact ids, labels, and provider-id containment', () => { + expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'sonnet')).toBe('sonnet') + expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'Sonnet 5')).toBe('sonnet') + expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'claude-sonnet-5')).toBe( + 'sonnet' + ) + expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'gpt-5.5')).toBe('gpt-5.5') + }) + + it('returns null for unrecognized reports', () => { + expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'mystery-model')).toBeNull() + expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, '')).toBeNull() + }) +})