From 5ffc8d7972c7bef9788cffd6f90b1e00d1e014ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:21:41 -0700 Subject: [PATCH] Keep Chat UI users with a Terminal default on the terminal, with a chat mode switch in the upgrade tip (#26915) * Keep Chat UI users who opened new tabs in the terminal on the terminal Before the chat upgrade, Experimental Chat UI had a separate Default view choice, and it defaulted to Terminal. The upgrade retired that choice so Chat UI on now means new agent tabs open as chat, which moved people who had kept the terminal default into chat with no obvious way back. On load, a profile saved by a pre-upgrade build with Chat UI on and Default view not set to Chat now gets Chat UI turned off, and is left out of the one-time native chat upgrade tip. The old key is stripped on the first save, so the migration runs once and a later opt-in sticks. * Update the saved-settings test for Chat UI on with Default view Terminal * Show the upgrade tip to terminal-default users, with a switch to turn on chat mode People who had Chat UI on with new tabs opening in the terminal now keep the terminal on upgrade, but they still need the tip's directions for moving between chat and CLI. They now get the same upgrade tip, plus a note that new agent tabs still open in the terminal and a "Turn on chat mode" switch. The switch shows when Chat UI is off as the tip opens and stays put while it is flipped. The tip is taller in that case so nothing scrolls. * Decide the chat mode switch from the saved tip audience, not the live setting The upgrade tip's 'Turn on chat mode' switch showed whenever Chat UI was off when the tip opened, so a chat-view member who had turned Chat UI off also got it. Main now answers with one tip variant (none, standard, keep-terminal) from the saved audience, and only keep-terminal shows the note and switch. Also moves the feature tip panel styling into a DialogContent surface variant and drops the Label restyle, and tests that the switch stays put after flipping. * Let the upgrade tip decide the chat mode switch from its variant The modal now passes the saved tip variant to the dialog, which decides the switch itself, so the dialog tests cover that decision. --- src/main/ipc/onboarding.ts | 7 +- ...e-native-chat-upgrade-tip-audience.test.ts | 108 ++++++++++++------ .../terminal-settings-migrations.ts | 15 +++ .../loading-store/loaded-cohort-migrations.ts | 14 ++- .../normalize-loaded-global-settings.test.ts | 13 ++- .../normalize-loaded-global-settings.ts | 9 +- .../loading-store/profile-preferences.ts | 17 ++- .../profile-state-store-factory.test.ts | 23 ++-- src/preload/api/onboarding-api.ts | 3 +- src/preload/api/onboarding-bridge.ts | 5 +- .../use-onboarding-and-feature-tips.ts | 15 ++- .../feature-tips/FeatureTipDialogFrame.tsx | 10 +- .../feature-tips/FeatureTipsModal.tsx | 8 +- .../NativeChatUpgradeTipDialog.test.tsx | 90 ++++++++++++--- .../NativeChatUpgradeTipDialog.tsx | 54 ++++++++- src/renderer/src/components/ui/dialog.tsx | 24 +++- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + .../slices/ui/ui-slice-contract-contextual.ts | 7 +- .../slices/ui/ui-slice-feature-actions.ts | 5 +- .../src/web/preload-api/web-onboarding-api.ts | 2 +- .../native-chat-upgrade-tip-audience.test.ts | 32 ++++++ .../native-chat-upgrade-tip-audience.ts | 23 +++- 27 files changed, 410 insertions(+), 92 deletions(-) create mode 100644 src/shared/native-chat-upgrade-tip-audience.test.ts 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' } }