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.
This commit is contained in:
Brennan Benson
2026-10-09 14:21:41 -07:00
committed by GitHub
parent d358e5973c
commit 5ffc8d7972
27 changed files with 410 additions and 92 deletions
+4 -3
View File
@@ -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()
)
}
@@ -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<string, unknown> => ({
openAgentTabsInChatByDefault: false,
const preUpgradeSettings = (
experimentalNativeChat?: boolean,
openAgentTabsInChatByDefault = experimentalNativeChat === true
): Record<string, unknown> => ({
openAgentTabsInChatByDefault,
...(experimentalNativeChat === undefined ? {} : { experimentalNativeChat })
})
@@ -38,16 +44,16 @@ async function reopen(): Promise<ReturnType<typeof createStore>> {
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<typeof createStore>
): Promise<boolean[]> {
): Promise<NativeChatUpgradeTipVariant[]> {
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', () => {
@@ -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
@@ -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,
@@ -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', () => {
@@ -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
),
@@ -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'] {
@@ -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
}
})
}
)
+2 -1
View File
@@ -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<OnboardingState>
/** Read-only: main decides the audience once, from the profile as saved before the chat upgrade. */
isInNativeChatUpgradeTipAudience: () => Promise<boolean>
getNativeChatUpgradeTipVariant: () => Promise<NativeChatUpgradeTipVariant>
}
export type StarNagApi = {
+3 -2
View File
@@ -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<OnboardingState['checklist']>
}
): Promise<OnboardingState> => ipcRenderer.invoke('onboarding:update', updates),
isInNativeChatUpgradeTipAudience: (): Promise<boolean> =>
ipcRenderer.invoke('onboarding:isInNativeChatUpgradeTipAudience')
getNativeChatUpgradeTipVariant: (): Promise<NativeChatUpgradeTipVariant> =>
ipcRenderer.invoke('onboarding:getNativeChatUpgradeTipVariant')
} satisfies PreloadApi['onboarding']
@@ -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<typeof useOnboardingAndFeatureTips>
@@ -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')
}
})
@@ -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<typeof DialogContent>['onOpenAutoFocus']
visual: ReactNode
/** Room for an extra row of copy without scrolling. */
tall?: boolean
children: ReactNode
}): JSX.Element {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="!flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))] p-0 dark:bg-[color-mix(in_srgb,var(--foreground)_16%,var(--background))] sm:max-w-4xl md:!h-[min(27rem,calc(100vh-2rem))] md:!flex-row"
surface="feature-tip"
className={cn(
'!flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden sm:max-w-4xl md:!flex-row',
tall ? 'md:!h-[min(33rem,calc(100vh-2rem))]' : 'md:!h-[min(27rem,calc(100vh-2rem))]'
)}
showCloseButton
onOpenAutoFocus={onOpenAutoFocus}
>
@@ -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}
/>
)
@@ -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 }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
@@ -29,20 +35,32 @@ function textOf(html: string): string {
.replace(/&#x27;/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 (
<NativeChatUpgradeTipDialog
open
tip={getTip()}
primaryBusy={false}
variant={variant}
chatModeOn={chatModeOn}
onOpenChange={() => {}}
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(
<NativeChatUpgradeTipDialog
open
tip={getTip()}
primaryBusy={false}
onOpenChange={() => {}}
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', () => {
@@ -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 (
<div className="mt-3 space-y-2 text-sm leading-relaxed">
<p className="text-muted-foreground">
{translate(
'featureTips.nativeChatUpgrade.chatModeNote',
'New agent tabs still open in the terminal, as before.'
)}
</p>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<Label htmlFor={switchId}>
{translate('featureTips.nativeChatUpgrade.chatModeLabel', 'Turn on chat mode')}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'featureTips.nativeChatUpgrade.chatModeHint',
'New agent tabs open as chat instead.'
)}
</p>
</div>
<Switch id={switchId} checked={chatModeOn} onCheckedChange={onChatModeChange} />
</div>
</div>
)
}
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<HTMLButtonElement>(null)
// Why: only profiles whose new agent tabs opened in the terminal before the upgrade.
const offerChatMode = variant === 'keep-terminal'
return (
<FeatureTipDialogFrame
@@ -78,6 +126,7 @@ export function NativeChatUpgradeTipDialog({
primaryButtonRef.current?.focus({ preventScroll: true })
}}
visual={<NativeChatUpgradeFeatureTipVisual />}
tall={offerChatMode}
>
<DialogHeader className="text-left">
<div>
@@ -85,6 +134,9 @@ export function NativeChatUpgradeTipDialog({
<DialogTitle size="display">
{translate('featureTips.nativeChatUpgrade.title', 'Native chat got an upgrade')}
</DialogTitle>
{offerChatMode ? (
<ChatModeSwitch chatModeOn={chatModeOn} onChatModeChange={onChatModeChange} />
) : null}
<DialogDescription className="mt-3 max-w-2xl">
<span className="block space-y-3 leading-relaxed">
<span className="block">
+20 -4
View File
@@ -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<typeof DialogPrimitive.Content> & {
overlayClassName?: string
showCloseButton?: boolean
}) {
}: React.ComponentProps<typeof DialogPrimitive.Content> &
VariantProps<typeof dialogContentSurfaceVariants> & {
overlayClassName?: string
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
@@ -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}
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -19114,6 +19114,9 @@
"nativeChatUpgrade": {
"eyebrow": "新機能",
"title": "ネイティブチャットを刷新",
"chatModeNote": "新しい Agent タブは、これまでどおりターミナルで開きます。",
"chatModeLabel": "チャットモードをオンにする",
"chatModeHint": "新しい Agent タブをチャットで開きます。",
"intro": "対応 Agent の新しいチャットはアップグレード版のチャットビューで開きます。チャットと CLI の切り替えは右サイドバーの{{panel}}から:",
"resumeInNativeChat": "{{action}}: 対応する CLI の会話をネイティブチャットで開きます。",
"resumeInCli": "{{action}}: Claude/Codex のチャットを新しい CLI セッションに複製します。元のチャットは残ります。",
+3
View File
@@ -19114,6 +19114,9 @@
"nativeChatUpgrade": {
"eyebrow": "새 기능",
"title": "기본 채팅이 업그레이드되었습니다",
"chatModeNote": "새 Agent 탭은 이전처럼 터미널에서 열립니다.",
"chatModeLabel": "채팅 모드 켜기",
"chatModeHint": "새 Agent 탭을 채팅으로 엽니다.",
"intro": "이제 지원되는 Agent의 새 채팅은 업그레이드된 채팅 보기에서 열립니다. 채팅과 CLI 사이를 오가려면 오른쪽 사이드바에서 {{panel}}을 여세요:",
"resumeInNativeChat": "{{action}}: 지원되는 CLI 대화를 기본 채팅에서 엽니다.",
"resumeInCli": "{{action}}: Claude 또는 Codex 채팅을 새 CLI 세션으로 복사합니다. 원래 채팅은 그대로 유지됩니다.",
+3
View File
@@ -19114,6 +19114,9 @@
"nativeChatUpgrade": {
"eyebrow": "新功能",
"title": "本机聊天已升级",
"chatModeNote": "新的智能体标签页仍像以前一样在终端中打开。",
"chatModeLabel": "开启聊天模式",
"chatModeHint": "新的智能体标签页改为以聊天方式打开。",
"intro": "受支持智能体的新聊天现在会在升级后的聊天视图中打开。要在聊天和 CLI 之间切换,请打开右侧边栏中的{{panel}}:",
"resumeInNativeChat": "{{action}}:在本机聊天中打开受支持的 CLI 对话。",
"resumeInCli": "{{action}}:将 Claude 或 Codex 聊天复制到新的 CLI 会话中。原始聊天保持不变。",
@@ -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<void>
contextualToursSeenIds: ContextualTourId[]
@@ -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<typeof getContextualTourProgressionForFeatureInteraction> =
@@ -55,7 +55,7 @@ export function createWebOnboardingApi(): Partial<PreloadApi> {
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')
}
}
}
@@ -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)
})
})
+22 -1
View File
@@ -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' }
}