diff --git a/src/main/ipc/onboarding.ts b/src/main/ipc/onboarding.ts index bc0029831f9..0ee539a88e2 100644 --- a/src/main/ipc/onboarding.ts +++ b/src/main/ipc/onboarding.ts @@ -1,11 +1,12 @@ import { ipcMain } from 'electron' import { sanitizeOnboardingUpdate, type Store } from '../persistence' import type { OnboardingState } from '../../shared/onboarding-state-types' +import type { NativeChatUpgradeTipVariant } from '../../shared/native-chat-upgrade-tip-audience' export function registerOnboardingHandlers(store: Store): void { ipcMain.removeHandler('onboarding:get') ipcMain.removeHandler('onboarding:update') - ipcMain.removeHandler('onboarding:isInNativeChatUpgradeTipAudience') + ipcMain.removeHandler('onboarding:getNativeChatUpgradeTipVariant') ipcMain.handle('onboarding:get', (): OnboardingState => store.getOnboarding()) // Why: never trust renderer input — a compromised/buggy caller could send @@ -14,7 +15,7 @@ export function registerOnboardingHandlers(store: Store): void { ipcMain.handle('onboarding:update', (_event, updates: unknown): OnboardingState => { return store.updateOnboarding(sanitizeOnboardingUpdate(updates)) }) - ipcMain.handle('onboarding:isInNativeChatUpgradeTipAudience', (): boolean => - store.isInNativeChatUpgradeTipAudience() + ipcMain.handle('onboarding:getNativeChatUpgradeTipVariant', (): NativeChatUpgradeTipVariant => + store.getNativeChatUpgradeTipVariant() ) } diff --git a/src/main/persistence-native-chat-upgrade-tip-audience.test.ts b/src/main/persistence-native-chat-upgrade-tip-audience.test.ts index 4fc23b63fd5..67e8d7e9906 100644 --- a/src/main/persistence-native-chat-upgrade-tip-audience.test.ts +++ b/src/main/persistence-native-chat-upgrade-tip-audience.test.ts @@ -10,7 +10,10 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultPersistedState } from '../shared/constants' -import type { NativeChatUpgradeTipAudience } from '../shared/native-chat-upgrade-tip-audience' +import type { + NativeChatUpgradeTipAudience, + NativeChatUpgradeTipVariant +} from '../shared/native-chat-upgrade-tip-audience' import { normalizeProfileProjectState } from './orca-profiles/profile-project-state-file' vi.mock('electron', () => ({ @@ -28,8 +31,11 @@ afterEach(async () => { }) // Every build before the chat upgrade saved the whole settings document, defaults included. -const preUpgradeSettings = (experimentalNativeChat?: boolean): Record => ({ - openAgentTabsInChatByDefault: false, +const preUpgradeSettings = ( + experimentalNativeChat?: boolean, + openAgentTabsInChatByDefault = experimentalNativeChat === true +): Record => ({ + openAgentTabsInChatByDefault, ...(experimentalNativeChat === undefined ? {} : { experimentalNativeChat }) }) @@ -38,16 +44,16 @@ async function reopen(): Promise> { return createStore() } -/** Turns Chat UI on, restarts, and reports membership after each of several launches. */ -async function membershipAfterLaterOptIn( +/** Turns Chat UI on, restarts, and reports the tip variant after each of several launches. */ +async function variantAfterLaterOptIn( store: ReturnType -): Promise { +): Promise { store.updateSettings({ experimentalNativeChat: true }) store.flush() - const launches: boolean[] = [] + const launches: NativeChatUpgradeTipVariant[] = [] for (let launch = 0; launch < 3; launch += 1) { const reopened = await reopen() - launches.push(reopened.isInNativeChatUpgradeTipAudience()) + launches.push(reopened.getNativeChatUpgradeTipVariant()) reopened.updateSettings({ experimentalNativeChat: true }) reopened.flush() } @@ -56,30 +62,41 @@ async function membershipAfterLaterOptIn( describe('native chat upgrade tip audience', () => { it.each([ - ['Chat UI on', preUpgradeSettings(true), 'eligible', 'chat-ui-on'], - ['Chat UI off', preUpgradeSettings(false), 'excluded', 'chat-ui-off'], - ['Chat UI never set', preUpgradeSettings(), 'excluded', 'chat-ui-unset'], + ['Chat UI on', preUpgradeSettings(true), 'eligible', 'chat-ui-on', 'standard'], + [ + 'Chat UI on but new tabs opening in the terminal', + preUpgradeSettings(true, false), + 'eligible', + 'chat-ui-on-terminal-default', + 'keep-terminal' + ], + ['Chat UI off', preUpgradeSettings(false), 'excluded', 'chat-ui-off', 'none'], + ['Chat UI never set', preUpgradeSettings(), 'excluded', 'chat-ui-unset', 'none'], [ 'Chat UI on but saved by a build that already had the chat upgrade', { experimentalNativeChat: true }, 'excluded', - 'chat-ui-on-unproven' + 'chat-ui-on-unproven', + 'none' ] - ])('decides an existing profile with %s from what it saved', (_, settings, membership, basis) => { - writeDataFile({ settings }) - const store = createStore() - expect(store.isInNativeChatUpgradeTipAudience()).toBe(membership === 'eligible') - store.flush() - expect(readDataFile()).toHaveProperty('nativeChatUpgradeTipAudience', { - version: 1, - membership, - basis - }) - }) + ])( + 'decides an existing profile with %s from what it saved', + (_, settings, membership, basis, variant) => { + writeDataFile({ settings }) + const store = createStore() + expect(store.getNativeChatUpgradeTipVariant()).toBe(variant) + store.flush() + expect(readDataFile()).toHaveProperty('nativeChatUpgradeTipAudience', { + version: 1, + membership, + basis + }) + } + ) it('keeps a brand new profile out of the audience', () => { const store = createStore() - expect(store.isInNativeChatUpgradeTipAudience()).toBe(false) + expect(store.getNativeChatUpgradeTipVariant()).toBe('none') store.flush() expect(readDataFile()).toHaveProperty('nativeChatUpgradeTipAudience.basis', 'new-profile') }) @@ -93,17 +110,44 @@ describe('native chat upgrade tip audience', () => { async (_, seed) => { seed() const store = createStore() - expect(store.isInNativeChatUpgradeTipAudience()).toBe(false) - expect(await membershipAfterLaterOptIn(store)).toEqual([false, false, false]) + expect(store.getNativeChatUpgradeTipVariant()).toBe('none') + expect(await variantAfterLaterOptIn(store)).toEqual(['none', 'none', 'none']) } ) - it('keeps an original member in the audience after turning Chat UI off', async () => { + it('turns Chat UI off for a profile whose new tabs opened in the terminal, once', async () => { + writeDataFile({ settings: preUpgradeSettings(true, false) }) + const store = createStore() + expect(store.getSettings().experimentalNativeChat).toBe(false) + store.flush() + expect(readDataFile()).toHaveProperty('settings.experimentalNativeChat', false) + // Turning it back on later sticks: the saved Default view is gone after the first save. + store.updateSettings({ experimentalNativeChat: true }) + store.flush() + expect((await reopen()).getSettings().experimentalNativeChat).toBe(true) + }) + + it('keeps Chat UI on for a profile whose new tabs opened in chat', () => { + writeDataFile({ settings: preUpgradeSettings(true) }) + expect(createStore().getSettings().experimentalNativeChat).toBe(true) + }) + + it('keeps a chat-view member on the standard tip after turning Chat UI off', async () => { writeDataFile({ settings: preUpgradeSettings(true) }) const store = createStore() store.updateSettings({ experimentalNativeChat: false }) store.flush() - expect((await reopen()).isInNativeChatUpgradeTipAudience()).toBe(true) + expect((await reopen()).getNativeChatUpgradeTipVariant()).toBe('standard') + }) + + it('keeps a terminal-view member on the chat mode switch tip after turning Chat UI on', async () => { + writeDataFile({ settings: preUpgradeSettings(true, false) }) + const store = createStore() + expect(await variantAfterLaterOptIn(store)).toEqual([ + 'keep-terminal', + 'keep-terminal', + 'keep-terminal' + ]) }) it('keeps an excluded record when a later build turns Chat UI on by default', async () => { @@ -114,7 +158,7 @@ describe('native chat upgrade tip audience', () => { }) for (let launch = 0; launch < 3; launch += 1) { const store = launch === 0 ? createStore() : await reopen() - expect(store.isInNativeChatUpgradeTipAudience()).toBe(false) + expect(store.getNativeChatUpgradeTipVariant()).toBe('none') store.flush() } expect(readDataFile()).toHaveProperty('nativeChatUpgradeTipAudience.basis', 'chat-ui-off') @@ -124,7 +168,7 @@ describe('native chat upgrade tip audience', () => { // A record lost by some later rewrite must not be recaptured from the live switch. writeDataFile({ settings: { experimentalNativeChat: true } }) const store = createStore() - expect(store.isInNativeChatUpgradeTipAudience()).toBe(false) + expect(store.getNativeChatUpgradeTipVariant()).toBe('none') store.flush() expect(readDataFile()).toHaveProperty( 'nativeChatUpgradeTipAudience.basis', @@ -153,7 +197,7 @@ describe('native chat upgrade tip audience', () => { ])('fails closed on a saved record with %s', (_, record) => { writeDataFile({ settings: preUpgradeSettings(true), nativeChatUpgradeTipAudience: record }) const store = createStore() - expect(store.isInNativeChatUpgradeTipAudience()).toBe(false) + expect(store.getNativeChatUpgradeTipVariant()).toBe('none') store.flush() expect(readDataFile()).toHaveProperty('nativeChatUpgradeTipAudience.basis', 'unreadable-record') }) @@ -163,7 +207,7 @@ describe('native chat upgrade tip audience', () => { settings: preUpgradeSettings(false), nativeChatUpgradeTipAudience: { version: 1, membership: 'eligible', basis: 'chat-ui-on' } }) - expect(createStore().isInNativeChatUpgradeTipAudience()).toBe(true) + expect(createStore().getNativeChatUpgradeTipVariant()).toBe('standard') }) it('profile transfers carry a saved record and never invent one', () => { diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 3ed0b25f84b..574577c27f8 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -91,6 +91,21 @@ export function stripRetiredGlobalSettings( return rest } +/** + * Chat UI was on but the retired Default view left new agent tabs in the terminal. Every build + * before the retirement saved that key, so a profile without it was already upgraded. + */ +export function savedChatUiWithTerminalDefaultView(settings: unknown): boolean { + return ( + typeof settings === 'object' && + settings !== null && + 'experimentalNativeChat' in settings && + settings.experimentalNativeChat === true && + 'openAgentTabsInChatByDefault' in settings && + settings.openAgentTabsInChatByDefault !== true + ) +} + export function migrateTerminalScrollbackRows(settings: unknown): { rows: number needsSave: boolean diff --git a/src/main/persistence/loading-store/loaded-cohort-migrations.ts b/src/main/persistence/loading-store/loaded-cohort-migrations.ts index 66db918da00..4084ca206a4 100644 --- a/src/main/persistence/loading-store/loaded-cohort-migrations.ts +++ b/src/main/persistence/loading-store/loaded-cohort-migrations.ts @@ -5,6 +5,7 @@ import { parseNativeChatUpgradeTipAudience, type NativeChatUpgradeTipAudience } from '../../../shared/native-chat-upgrade-tip-audience' +import { savedChatUiWithTerminalDefaultView } from '../applying-settings/terminal-settings-migrations' import type { StoreRuntimeState } from './store-runtime-state' @@ -51,9 +52,16 @@ export class LoadedCohortMigrationOperations { if (chatUi === true) { // Why: every build before the chat upgrade saved this key and the upgrade strips it, so // without it the opt-in may postdate the upgrade and cannot prove membership. - return settings && Object.hasOwn(settings, 'openAgentTabsInChatByDefault') - ? { version: 1, membership: 'eligible', basis: 'chat-ui-on' } - : { version: 1, membership: 'excluded', basis: 'chat-ui-on-unproven' } + if (!settings || !Object.hasOwn(settings, 'openAgentTabsInChatByDefault')) { + return { version: 1, membership: 'excluded', basis: 'chat-ui-on-unproven' } + } + return { + version: 1, + membership: 'eligible', + basis: savedChatUiWithTerminalDefaultView(settings) + ? 'chat-ui-on-terminal-default' + : 'chat-ui-on' + } } return { version: 1, diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index 0db64a75fb8..6d5294bc11d 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -50,12 +50,23 @@ describe('retired chat default selectors', () => { const normalized = normalizeLegacyProfile({ experimentalNativeChat: true, experimentalStructuredNativeChat: false, - openAgentTabsInChatByDefault: false + openAgentTabsInChatByDefault: true }) expect(normalized.experimentalNativeChat).toBe(true) expect(normalized).not.toHaveProperty('experimentalStructuredNativeChat') expect(normalized).not.toHaveProperty('openAgentTabsInChatByDefault') }) + + it('turns Chat UI off when new tabs opened in the terminal, dropping both older keys', () => { + const normalized = normalizeLegacyProfile({ + experimentalNativeChat: true, + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: false + }) + expect(normalized.experimentalNativeChat).toBe(false) + expect(normalized).not.toHaveProperty('experimentalStructuredNativeChat') + expect(normalized).not.toHaveProperty('openAgentTabsInChatByDefault') + }) }) describe('structured chat shell environment settings', () => { diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index bbe14bfd122..acf5a485ddd 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -9,7 +9,10 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat-shell-environment' -import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { + savedChatUiWithTerminalDefaultView, + stripRetiredGlobalSettings +} from '../applying-settings/terminal-settings-migrations' import { readLegacySidekickFlag } from '../applying-settings/onboarding-normalization' import { normalizeMachineName } from '../../../shared/machine-name' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -61,6 +64,10 @@ export function normalizeLoadedGlobalSettings( // old default indistinguishable from a real opt-in. Preserve stored `true`; only // the default changed. ...stripRetiredGlobalSettings(parsed.settings), + // Why: Chat UI on now opens new tabs in chat; keep users whose new tabs opened in the terminal there. + ...(savedChatUiWithTerminalDefaultView(parsed.settings) + ? { experimentalNativeChat: false } + : {}), nativeChatAppearance: normalizeNativeChatAppearanceSettings( parsed.settings?.nativeChatAppearance ), diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index e77065537d4..bed538ab50f 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -3,7 +3,11 @@ import type { OnboardingChecklistState } from '../../../shared/onboarding-state- import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultOnboardingState } from '../../../shared/onboarding-defaults' import type { FeatureInteractionId } from '../../../shared/feature-interactions' -import { parseNativeChatUpgradeTipAudience } from '../../../shared/native-chat-upgrade-tip-audience' +import { + nativeChatUpgradeTipVariant, + parseNativeChatUpgradeTipAudience, + type NativeChatUpgradeTipVariant +} from '../../../shared/native-chat-upgrade-tip-audience' import { updateSettings as updateSettingsOperation, type SettingsMutationOperations @@ -167,12 +171,13 @@ export class ProfilePreferences { return this.getOnboarding() } - /** Fails closed: a missing or damaged record reads as not in the audience. */ - isInNativeChatUpgradeTipAudience(): boolean { - const audience = parseNativeChatUpgradeTipAudience( - this[profilePreferencesContext].runtime.state.nativeChatUpgradeTipAudience + /** Fails closed: a missing or damaged record reads as no tip. */ + getNativeChatUpgradeTipVariant(): NativeChatUpgradeTipVariant { + return nativeChatUpgradeTipVariant( + parseNativeChatUpgradeTipAudience( + this[profilePreferencesContext].runtime.state.nativeChatUpgradeTipAudience + ) ) - return audience?.membership === 'eligible' } getGitHubCache(): PersistedState['githubCache'] { diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts index 70bbea87ca9..dcee79f50d5 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -305,24 +305,30 @@ describe('profile state Store authority factory', () => { it.each([ [ - { experimentalNativeChat: true, openAgentTabsInChatByDefault: false }, + { experimentalNativeChat: true, openAgentTabsInChatByDefault: true }, false, - true, + 'standard', 'chat-ui-on' ], + [ + { experimentalNativeChat: true, openAgentTabsInChatByDefault: false }, + true, + 'keep-terminal', + 'chat-ui-on-terminal-default' + ], [ { experimentalNativeChat: false, openAgentTabsInChatByDefault: false }, true, - false, + 'none', 'chat-ui-off' ] ])( 'decides the native chat upgrade tip audience once from the startup snapshot (%o)', - (settings, laterChatUi, inAudience, basis) => { + (settings, laterChatUi, variant, basis) => { const options = createOptions() writeFileSync(options.dataFile, JSON.stringify({ settings })) const first = createProfileStateStore({ ...options }) - expect(first.store.isInNativeChatUpgradeTipAudience()).toBe(inAudience) + expect(first.store.getNativeChatUpgradeTipVariant()).toBe(variant) // Why: a later Chat UI change must never move a profile into or out of the audience. first.store.updateSettings({ experimentalNativeChat: laterChatUi }) @@ -330,9 +336,12 @@ describe('profile state Store authority factory', () => { rmSync(options.dataFile) const restarted = createProfileStateStore({ ...options }) - expect(restarted.store.isInNativeChatUpgradeTipAudience()).toBe(inAudience) + expect(restarted.store.getNativeChatUpgradeTipVariant()).toBe(variant) expect(JSON.parse(restarted.store.prepareProfileStateExport().json)).toMatchObject({ - nativeChatUpgradeTipAudience: { membership: inAudience ? 'eligible' : 'excluded', basis } + nativeChatUpgradeTipAudience: { + membership: variant === 'none' ? 'excluded' : 'eligible', + basis + } }) } ) diff --git a/src/preload/api/onboarding-api.ts b/src/preload/api/onboarding-api.ts index b802290db48..e27177a3bb7 100644 --- a/src/preload/api/onboarding-api.ts +++ b/src/preload/api/onboarding-api.ts @@ -1,3 +1,4 @@ +import type { NativeChatUpgradeTipVariant } from '../../shared/native-chat-upgrade-tip-audience' import type { OnboardingState } from '../../shared/onboarding-state-types' export type OnboardingApi = { @@ -9,7 +10,7 @@ export type OnboardingApi = { } ) => Promise /** Read-only: main decides the audience once, from the profile as saved before the chat upgrade. */ - isInNativeChatUpgradeTipAudience: () => Promise + getNativeChatUpgradeTipVariant: () => Promise } export type StarNagApi = { diff --git a/src/preload/api/onboarding-bridge.ts b/src/preload/api/onboarding-bridge.ts index 1aa01f29b81..4ad1082d3cd 100644 --- a/src/preload/api/onboarding-bridge.ts +++ b/src/preload/api/onboarding-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { NativeChatUpgradeTipVariant } from '../../shared/native-chat-upgrade-tip-audience' import type { OnboardingState } from '../../shared/onboarding-state-types' import type { PreloadApi } from '../api-types' @@ -9,6 +10,6 @@ export const onboardingApi = { checklist?: Partial } ): Promise => ipcRenderer.invoke('onboarding:update', updates), - isInNativeChatUpgradeTipAudience: (): Promise => - ipcRenderer.invoke('onboarding:isInNativeChatUpgradeTipAudience') + getNativeChatUpgradeTipVariant: (): Promise => + ipcRenderer.invoke('onboarding:getNativeChatUpgradeTipVariant') } satisfies PreloadApi['onboarding'] diff --git a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts index 1afdf6b6129..6e90d38937c 100644 --- a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts +++ b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts @@ -13,6 +13,7 @@ import { import { useAppStore } from '../store' import { isWebClientLocation } from '../lib/web-client-location' import type { OnboardingState } from '../../../shared/onboarding-state-types' +import { isInNativeChatUpgradeTipAudience } from '../../../shared/native-chat-upgrade-tip-audience' export type OnboardingGate = ReturnType @@ -32,13 +33,15 @@ export function useOnboardingAndFeatureTips() { const persistedUIReady = useAppStore((s) => s.persistedUIReady) const featureTipsSeenIds = useAppStore((s) => s.featureTipsSeenIds) const featureInteractions = useAppStore((s) => s.featureInteractions) - const inNativeChatUpgradeTipAudience = useAppStore((s) => s.inNativeChatUpgradeTipAudience) + const inNativeChatUpgradeTipAudience = useAppStore((s) => + isInNativeChatUpgradeTipAudience(s.nativeChatUpgradeTipVariant) + ) const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) const actions = useAppStore( useShallow((s) => ({ openModal: s.openModal, markFeatureTipsSeen: s.markFeatureTipsSeen, - setInNativeChatUpgradeTipAudience: s.setInNativeChatUpgradeTipAudience, + setNativeChatUpgradeTipVariant: s.setNativeChatUpgradeTipVariant, setContextualToursAutoEligible: s.setContextualToursAutoEligible, setContextualToursOnboardingVisible: s.setContextualToursOnboardingVisible })) @@ -99,16 +102,16 @@ export function useOnboardingAndFeatureTips() { let cancelled = false void window.api.onboarding - .isInNativeChatUpgradeTipAudience() - .then((inAudience) => { + .getNativeChatUpgradeTipVariant() + .then((variant) => { if (!cancelled) { - actions.setInNativeChatUpgradeTipAudience(inAudience) + actions.setNativeChatUpgradeTipVariant(variant) } }) .catch(() => { // Why: fail closed, and never hold the other tips behind a failed read. if (!cancelled) { - actions.setInNativeChatUpgradeTipAudience(false) + actions.setNativeChatUpgradeTipVariant('none') } }) diff --git a/src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx b/src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx index 6891cca9e61..462dde7e519 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, JSX, ReactNode } from 'react' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent } from '@/components/ui/dialog' +import { cn } from '@/lib/utils' /** Shared two-column tip layout: copy and actions on the left, a feature visual on the right. */ export function FeatureTipDialogFrame({ @@ -8,18 +9,25 @@ export function FeatureTipDialogFrame({ onOpenChange, onOpenAutoFocus, visual, + tall = false, children }: { open: boolean onOpenChange: (open: boolean) => void onOpenAutoFocus: ComponentProps['onOpenAutoFocus'] visual: ReactNode + /** Room for an extra row of copy without scrolling. */ + tall?: boolean children: ReactNode }): JSX.Element { return ( diff --git a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx index fe7b5938aba..d1bf89d8849 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type JSX } from 'react' import { toast } from 'sonner' import { getDefaultVoiceSettings } from '../../../../shared/constants' +import { isInNativeChatUpgradeTipAudience } from '../../../../shared/native-chat-upgrade-tip-audience' import { ORCHESTRATION_ENABLED_STORAGE_KEY, ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY, @@ -34,7 +35,7 @@ export default function FeatureTipsModal(): JSX.Element | null { const updateSettings = useAppStore((s) => s.updateSettings) const seenTipIds = useAppStore((s) => s.featureTipsSeenIds) const featureInteractions = useAppStore((s) => s.featureInteractions) - const inNativeChatUpgradeTipAudience = useAppStore((s) => s.inNativeChatUpgradeTipAudience) + const nativeChatUpgradeTipVariant = useAppStore((s) => s.nativeChatUpgradeTipVariant) const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen) const modalData = useAppStore((s) => s.modalData) const showAiVaultSearch = useAppStore((s) => s.showAiVaultSearch) @@ -49,7 +50,7 @@ export default function FeatureTipsModal(): JSX.Element | null { modalData, seenTipIds, featureInteractions, - inNativeChatUpgradeTipAudience, + inNativeChatUpgradeTipAudience: isInNativeChatUpgradeTipAudience(nativeChatUpgradeTipVariant), settings, webClient: isWebClientLocation() }) @@ -300,8 +301,11 @@ export default function FeatureTipsModal(): JSX.Element | null { open={isOpen} tip={currentTip} primaryBusy={primaryBusy} + variant={nativeChatUpgradeTipVariant ?? 'none'} + chatModeOn={settings?.experimentalNativeChat === true} onOpenChange={handleOpenChange} onPrimaryAction={() => void handlePrimaryAction()} + onChatModeChange={(on) => void updateSettings({ experimentalNativeChat: on })} onSettingsClick={openNativeChatSettings} /> ) diff --git a/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.test.tsx b/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.test.tsx index 64ee14e36c2..3de14330584 100644 --- a/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.test.tsx +++ b/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.test.tsx @@ -1,13 +1,19 @@ -import type { ReactNode } from 'react' +// @vitest-environment happy-dom + +import { act, type JSX, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { FEATURE_TIPS, type FeatureTip } from '../../../../shared/feature-tips' +import type { NativeChatUpgradeTipVariant } from '../../../../shared/native-chat-upgrade-tip-audience' import { NativeChatUpgradeTipDialog } from './NativeChatUpgradeTipDialog' import { NativeChatUpgradeFeatureTipVisual } from './NativeChatUpgradeFeatureTipVisual' vi.mock('@/components/ui/dialog', () => ({ Dialog: ({ children }: { children: ReactNode }) =>
{children}
, - DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, @@ -29,20 +35,32 @@ function textOf(html: string): string { .replace(/'/g, "'") } +// Why: repo convention — React only suppresses its act() warning when this global is set. +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function dialog(variant: NativeChatUpgradeTipVariant, chatModeOn: boolean): JSX.Element { + return ( + {}} + onPrimaryAction={() => {}} + onChatModeChange={() => {}} + onSettingsClick={() => {}} + /> + ) +} + +function renderDialog(variant: NativeChatUpgradeTipVariant, chatModeOn: boolean): string { + return renderToStaticMarkup(dialog(variant, chatModeOn)) +} + describe('NativeChatUpgradeTipDialog', () => { it('shows the approved copy with one Got it button and the Experimental settings link', () => { - const text = textOf( - renderToStaticMarkup( - {}} - onPrimaryAction={() => {}} - onSettingsClick={() => {}} - /> - ) - ) + const text = textOf(renderDialog('standard', true)) expect(text).toContain('NEW') expect(text).toContain('Native chat got an upgrade') expect(text).toContain( @@ -57,6 +75,50 @@ describe('NativeChatUpgradeTipDialog', () => { expect(text).toContain('Manage Chat UI in Settings → Experimental.') expect(text).toContain('Got it') expect(text).not.toContain('Maybe Later') + expect(text).not.toContain('Turn on chat mode') + expect(renderDialog('standard', true)).toContain('27rem') + }) + + it('never offers chat mode to a chat-view member, even with Chat UI turned off', () => { + const html = renderDialog('standard', false) + const text = textOf(html) + expect(text).not.toContain('New agent tabs still open in the terminal') + expect(text).not.toContain('Turn on chat mode') + expect(html).not.toContain('role="switch"') + expect(html).toContain('27rem') + expect(html).not.toContain('33rem') + }) + + it('offers chat mode, in a taller tip, when new agent tabs still open in the terminal', () => { + const html = renderDialog('keep-terminal', false) + const text = textOf(html) + expect(text).toContain('New agent tabs still open in the terminal, as before.') + expect(text).toContain('Turn on chat mode') + expect(text).toContain('New agent tabs open as chat instead.') + expect(html).toContain('role="switch"') + expect(html).toContain('aria-checked="false"') + expect(html).toContain('33rem') + // The way back to the terminal stays in the tip. + expect(text).toContain('Resume in New CLI copies a Claude or Codex chat') + }) + + it('keeps offering chat mode, switched on, when Chat UI was already on', () => { + const html = renderDialog('keep-terminal', true) + expect(textOf(html)).toContain('Turn on chat mode') + expect(html).toContain('aria-checked="true"') + expect(html).toContain('33rem') + }) + + it('keeps the switch in place, now on, after it is flipped', () => { + const container = document.createElement('div') + const root = createRoot(container) + act(() => root.render(dialog('keep-terminal', false))) + expect(container.querySelector('[role="switch"]')?.getAttribute('aria-checked')).toBe('false') + act(() => root.render(dialog('keep-terminal', true))) + expect(container.querySelector('[role="switch"]')?.getAttribute('aria-checked')).toBe('true') + expect(container.textContent).toContain('New agent tabs still open in the terminal, as before.') + expect(container.innerHTML).toContain('33rem') + act(() => root.unmount()) }) it('pictures both real menu actions, one at a time', () => { diff --git a/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.tsx b/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.tsx index 49cdabb5eb0..29a117bd526 100644 --- a/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.tsx +++ b/src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.tsx @@ -1,7 +1,10 @@ -import { useRef, type JSX } from 'react' +import { useId, useRef, type JSX } from 'react' import { MessagesSquare, SquareTerminal, type LucideIcon } from 'lucide-react' import type { FeatureTip } from '../../../../shared/feature-tips' +import type { NativeChatUpgradeTipVariant } from '../../../../shared/native-chat-upgrade-tip-audience' import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' import { translate } from '@/i18n/i18n' import { FeatureTipActions } from './FeatureTipActions' import { @@ -51,22 +54,67 @@ function ActionLine({ ) } +/** For users whose new agent tabs still open in the terminal: say so and offer the switch. */ +function ChatModeSwitch({ + chatModeOn, + onChatModeChange +}: { + chatModeOn: boolean + onChatModeChange: (on: boolean) => void +}): JSX.Element { + const switchId = useId() + return ( +
+

+ {translate( + 'featureTips.nativeChatUpgrade.chatModeNote', + 'New agent tabs still open in the terminal, as before.' + )} +

+
+
+ +

+ {translate( + 'featureTips.nativeChatUpgrade.chatModeHint', + 'New agent tabs open as chat instead.' + )} +

+
+ +
+
+ ) +} + export function NativeChatUpgradeTipDialog({ open, tip, primaryBusy, + variant, + chatModeOn, onOpenChange, onPrimaryAction, + onChatModeChange, onSettingsClick }: { open: boolean tip: FeatureTip primaryBusy: boolean + /** From the saved tip audience, never the live setting. */ + variant: NativeChatUpgradeTipVariant + /** Live Chat UI setting. */ + chatModeOn: boolean onOpenChange: (open: boolean) => void onPrimaryAction: () => void + onChatModeChange: (on: boolean) => void onSettingsClick: () => void }): JSX.Element { const primaryButtonRef = useRef(null) + // Why: only profiles whose new agent tabs opened in the terminal before the upgrade. + const offerChatMode = variant === 'keep-terminal' return ( } + tall={offerChatMode} >
@@ -85,6 +134,9 @@ export function NativeChatUpgradeTipDialog({ {translate('featureTips.nativeChatUpgrade.title', 'Native chat got an upgrade')} + {offerChatMode ? ( + + ) : null} diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index 859e5520f8e..3670d96a4da 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' import { XIcon } from 'lucide-react' import * as DialogPrimitive from 'radix-ui/dialog' @@ -44,16 +45,30 @@ function DialogOverlay({ ) } +const dialogContentSurfaceVariants = cva('', { + variants: { + surface: { + default: '', + /** The tinted, edge-to-edge panel of the two-column feature tip dialogs. */ + 'feature-tip': + 'gap-0 bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))] p-0 dark:bg-[color-mix(in_srgb,var(--foreground)_16%,var(--background))]' + } + }, + defaultVariants: { surface: 'default' } +}) + function DialogContent({ className, children, overlayClassName, showCloseButton = true, + surface, ...props -}: React.ComponentProps & { - overlayClassName?: string - showCloseButton?: boolean -}) { +}: React.ComponentProps & + VariantProps & { + overlayClassName?: string + showCloseButton?: boolean + }) { return ( @@ -69,6 +84,7 @@ function DialogContent({ // panel and pushes justify-end footers outside the visible surface. className={cn( 'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] grid-cols-[minmax(0,1fr)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-black/14 bg-background/96 p-6 text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-2xl duration-200 outline-none dark:border-white/14 dark:bg-[rgba(23,23,23,0.96)] dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg', + dialogContentSurfaceVariants({ surface }), className )} {...props} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 396633823fb..e872836f745 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19164,6 +19164,9 @@ "nativeChatUpgrade": { "eyebrow": "New", "title": "Native chat got an upgrade", + "chatModeNote": "New agent tabs still open in the terminal, as before.", + "chatModeLabel": "Turn on chat mode", + "chatModeHint": "New agent tabs open as chat instead.", "intro": "New chats with supported agents now open in the upgraded chat view. To move between chat and CLI, open {{panel}} in the right sidebar:", "resumeInNativeChat": "{{action}} opens a supported CLI conversation in native chat.", "resumeInCli": "{{action}} copies a Claude or Codex chat into a new CLI session. The original chat stays as it is.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 19e6fbae94f..12939711bf1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19086,6 +19086,9 @@ "nativeChatUpgrade": { "eyebrow": "Nuevo", "title": "El chat nativo mejora", + "chatModeNote": "Las pestañas de agente nuevas siguen abriéndose en la terminal, como antes.", + "chatModeLabel": "Activar el modo chat", + "chatModeHint": "Las pestañas de agente nuevas se abren como chat.", "intro": "Los chats nuevos de agentes compatibles usan la vista de chat mejorada. Para alternar entre chat y CLI, abre {{panel}} (barra lateral derecha):", "resumeInNativeChat": "{{action}} abre una conversación de CLI compatible en el chat nativo.", "resumeInCli": "{{action}} copia un chat de Claude/Codex a una CLI nueva sin tocar el original.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index aaa18901062..c6140d2242c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19062,6 +19062,9 @@ "nativeChatUpgrade": { "eyebrow": "Nouveau", "title": "Le chat natif évolue", + "chatModeNote": "Les nouveaux onglets d'agent s'ouvrent toujours dans le terminal, comme avant.", + "chatModeLabel": "Activer le mode chat", + "chatModeHint": "Les nouveaux onglets d'agent s'ouvrent en chat.", "intro": "Les nouveaux chats des agents compatibles utilisent la vue améliorée. Pour alterner entre chat et CLI, ouvrez {{panel}} (barre latérale droite) :", "resumeInNativeChat": "{{action}} ouvre une conversation CLI compatible dans le chat natif.", "resumeInCli": "{{action}} copie un chat Claude/Codex dans une CLI neuve ; l'original reste intact.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 24b8e4f8f1d..baa12209fe7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19114,6 +19114,9 @@ "nativeChatUpgrade": { "eyebrow": "新機能", "title": "ネイティブチャットを刷新", + "chatModeNote": "新しい Agent タブは、これまでどおりターミナルで開きます。", + "chatModeLabel": "チャットモードをオンにする", + "chatModeHint": "新しい Agent タブをチャットで開きます。", "intro": "対応 Agent の新しいチャットはアップグレード版のチャットビューで開きます。チャットと CLI の切り替えは右サイドバーの{{panel}}から:", "resumeInNativeChat": "{{action}}: 対応する CLI の会話をネイティブチャットで開きます。", "resumeInCli": "{{action}}: Claude/Codex のチャットを新しい CLI セッションに複製します。元のチャットは残ります。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 59816d48138..1063e77c522 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19114,6 +19114,9 @@ "nativeChatUpgrade": { "eyebrow": "새 기능", "title": "기본 채팅이 업그레이드되었습니다", + "chatModeNote": "새 Agent 탭은 이전처럼 터미널에서 열립니다.", + "chatModeLabel": "채팅 모드 켜기", + "chatModeHint": "새 Agent 탭을 채팅으로 엽니다.", "intro": "이제 지원되는 Agent의 새 채팅은 업그레이드된 채팅 보기에서 열립니다. 채팅과 CLI 사이를 오가려면 오른쪽 사이드바에서 {{panel}}을 여세요:", "resumeInNativeChat": "{{action}}: 지원되는 CLI 대화를 기본 채팅에서 엽니다.", "resumeInCli": "{{action}}: Claude 또는 Codex 채팅을 새 CLI 세션으로 복사합니다. 원래 채팅은 그대로 유지됩니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index be88f169d3a..3b7726c0845 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -19114,6 +19114,9 @@ "nativeChatUpgrade": { "eyebrow": "新功能", "title": "本机聊天已升级", + "chatModeNote": "新的智能体标签页仍像以前一样在终端中打开。", + "chatModeLabel": "开启聊天模式", + "chatModeHint": "新的智能体标签页改为以聊天方式打开。", "intro": "受支持智能体的新聊天现在会在升级后的聊天视图中打开。要在聊天和 CLI 之间切换,请打开右侧边栏中的{{panel}}:", "resumeInNativeChat": "{{action}}:在本机聊天中打开受支持的 CLI 对话。", "resumeInCli": "{{action}}:将 Claude 或 Codex 聊天复制到新的 CLI 会话中。原始聊天保持不变。", 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 142c6408981..48fa5d0f373 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 @@ -4,6 +4,7 @@ import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' import type { FeatureTipId } from '../../../../../shared/feature-tips' +import type { NativeChatUpgradeTipVariant } from '../../../../../shared/native-chat-upgrade-tip-audience' import type { ContextualTourId } from '../../../../../shared/contextual-tours' import type { OrcaHookScriptKind } from '../../../lib/orca-hook-trust' import type { SettingsNavigationTarget } from '../../../lib/settings-navigation-types' @@ -55,9 +56,9 @@ export type UISliceContextual = { closeModal: () => void featureTipsSeenIds: FeatureTipId[] markFeatureTipsSeen: (ids: FeatureTipId[]) => void - /** Main's once-decided native chat upgrade tip audience; null until read. */ - inNativeChatUpgradeTipAudience: boolean | null - setInNativeChatUpgradeTipAudience: (inAudience: boolean) => void + /** Main's once-decided native chat upgrade tip variant; null until read. */ + nativeChatUpgradeTipVariant: NativeChatUpgradeTipVariant | null + setNativeChatUpgradeTipVariant: (variant: NativeChatUpgradeTipVariant) => void featureInteractions: FeatureInteractionState recordFeatureInteraction: (id: FeatureInteractionId) => Promise contextualToursSeenIds: ContextualTourId[] diff --git a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts index 28c605e581f..d09f5731703 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts @@ -29,9 +29,8 @@ export function createUiFeatureActions(set: UISliceSet, get: UISliceGet): Partia window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error) return { featureTipsSeenIds: next } }), - inNativeChatUpgradeTipAudience: null, - setInNativeChatUpgradeTipAudience: (inAudience) => - set({ inNativeChatUpgradeTipAudience: inAudience }), + nativeChatUpgradeTipVariant: null, + setNativeChatUpgradeTipVariant: (variant) => set({ nativeChatUpgradeTipVariant: variant }), featureInteractions: {}, recordFeatureInteraction: (id) => { let tourProgression: ReturnType = diff --git a/src/renderer/src/web/preload-api/web-onboarding-api.ts b/src/renderer/src/web/preload-api/web-onboarding-api.ts index f7dc4bf5f20..0f8058e83f8 100644 --- a/src/renderer/src/web/preload-api/web-onboarding-api.ts +++ b/src/renderer/src/web/preload-api/web-onboarding-api.ts @@ -55,7 +55,7 @@ export function createWebOnboardingApi(): Partial { return next }, // Why: the browser client has no local profile from before the upgrade, so it is never in the audience. - isInNativeChatUpgradeTipAudience: () => Promise.resolve(false) + getNativeChatUpgradeTipVariant: () => Promise.resolve('none') } } } diff --git a/src/shared/native-chat-upgrade-tip-audience.test.ts b/src/shared/native-chat-upgrade-tip-audience.test.ts new file mode 100644 index 00000000000..f289000d1ff --- /dev/null +++ b/src/shared/native-chat-upgrade-tip-audience.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + isInNativeChatUpgradeTipAudience, + nativeChatUpgradeTipVariant +} from './native-chat-upgrade-tip-audience' + +describe('nativeChatUpgradeTipVariant', () => { + it.each([ + ['eligible', 'chat-ui-on', 'standard'], + ['eligible', 'chat-ui-on-terminal-default', 'keep-terminal'], + ['excluded', 'chat-ui-on-terminal-default', 'none'], + ['excluded', 'chat-ui-off', 'none'], + ['excluded', 'new-profile', 'none'] + ] as const)('gives %s/%s the %s tip', (membership, basis, variant) => { + expect(nativeChatUpgradeTipVariant({ version: 1, membership, basis })).toBe(variant) + }) + + it('shows no tip without a readable record', () => { + expect(nativeChatUpgradeTipVariant(null)).toBe('none') + }) +}) + +describe('isInNativeChatUpgradeTipAudience', () => { + it.each([ + [null, null], + ['none', false], + ['standard', true], + ['keep-terminal', true] + ] as const)('reads variant %s as %s', (variant, inAudience) => { + expect(isInNativeChatUpgradeTipAudience(variant)).toBe(inAudience) + }) +}) diff --git a/src/shared/native-chat-upgrade-tip-audience.ts b/src/shared/native-chat-upgrade-tip-audience.ts index 410768387e1..4dbfb339aee 100644 --- a/src/shared/native-chat-upgrade-tip-audience.ts +++ b/src/shared/native-chat-upgrade-tip-audience.ts @@ -1,10 +1,12 @@ export const NATIVE_CHAT_UPGRADE_TIP_MEMBERSHIPS = ['eligible', 'excluded'] as const export type NativeChatUpgradeTipMembership = (typeof NATIVE_CHAT_UPGRADE_TIP_MEMBERSHIPS)[number] -/** Why a profile landed in or out of the audience; provenance only, readers use `membership`. */ +/** Why a profile landed in or out of the audience; readers use `nativeChatUpgradeTipVariant`. */ export const NATIVE_CHAT_UPGRADE_TIP_AUDIENCE_BASES = [ 'chat-ui-on', 'chat-ui-on-unproven', + /** Chat UI was on with new tabs opening in the terminal; load turned Chat UI off. */ + 'chat-ui-on-terminal-default', 'chat-ui-off', 'chat-ui-unset', 'new-profile', @@ -23,6 +25,25 @@ export type NativeChatUpgradeTipAudience = { basis: NativeChatUpgradeTipAudienceBasis } +/** Which upgrade tip a profile gets: none, the standard tip, or the tip with the chat mode switch. */ +export type NativeChatUpgradeTipVariant = 'none' | 'standard' | 'keep-terminal' + +export function nativeChatUpgradeTipVariant( + audience: NativeChatUpgradeTipAudience | null +): NativeChatUpgradeTipVariant { + if (audience?.membership !== 'eligible') { + return 'none' + } + return audience.basis === 'chat-ui-on-terminal-default' ? 'keep-terminal' : 'standard' +} + +/** Whether the profile gets any upgrade tip; null while the variant is still unknown. */ +export function isInNativeChatUpgradeTipAudience( + variant: NativeChatUpgradeTipVariant | null +): boolean | null { + return variant === null ? null : variant !== 'none' +} + export function createNewProfileNativeChatUpgradeTipAudience(): NativeChatUpgradeTipAudience { return { version: 1, membership: 'excluded', basis: 'new-profile' } }