diff --git a/docs/site/content/docs/agents/native-chat.mdx b/docs/site/content/docs/agents/native-chat.mdx index 954fac9c412..ee7a83b0007 100644 --- a/docs/site/content/docs/agents/native-chat.mdx +++ b/docs/site/content/docs/agents/native-chat.mdx @@ -15,7 +15,7 @@ Existing terminal-backed chats use a transcript view over an agent running in a 1. Turn **Chat UI** on. 1. Open a new supported agent tab to start in structured chat. Turn **Chat UI** off to open new agents in the terminal instead. Existing chats stay available. -You can also choose which supported agents open as chats under **Settings → Chat → Providers**. Turning an agent off there makes its new sessions open in the terminal. +You can also choose whether each supported agent opens as a chat in **Settings → Agents**: each agent's row in the **Installed** list has a **Chat | CLI** choice. Choosing **CLI** makes its new sessions open in the terminal. Agents whose chat runs on a program Orca downloads, such as Antigravity, show that download and their chat sign-in when you expand their row. ## Continue a conversation in chat or the terminal diff --git a/src/renderer/src/components/managed-runtime/managed-runtime-install-card.test.ts b/src/renderer/src/components/managed-runtime/managed-runtime-install-card.test.ts index 59d69d9f529..2d69889a62b 100644 --- a/src/renderer/src/components/managed-runtime/managed-runtime-install-card.test.ts +++ b/src/renderer/src/components/managed-runtime/managed-runtime-install-card.test.ts @@ -33,7 +33,7 @@ describe('managedRuntimeInstallCard', () => { title: 'Install Fake to start chatting', detail: "One-time 112 MB download. It's shared by Fake chats on the computer running this chat.", - hint: 'Also in Settings › Chat › Providers', + hint: 'Also in Settings › Agents', action: { kind: 'install', label: 'Install Fake' } }) expect(managedRuntimeInstallCard(idle, 'Fake', 'strip')).toMatchObject({ diff --git a/src/renderer/src/components/managed-runtime/managed-runtime-install-card.ts b/src/renderer/src/components/managed-runtime/managed-runtime-install-card.ts index 8f837f4bd16..03ce58dd71a 100644 --- a/src/renderer/src/components/managed-runtime/managed-runtime-install-card.ts +++ b/src/renderer/src/components/managed-runtime/managed-runtime-install-card.ts @@ -140,10 +140,7 @@ function offerCard( const label = hero ? translate('components.managed-runtime.install', 'Install {{agent}}', { agent }) : translate('components.managed-runtime.installShort', 'Install') - const hint = translate( - 'components.managed-runtime.settingsHint', - 'Also in Settings › Chat › Providers' - ) + const hint = translate('components.managed-runtime.settingsHint', 'Also in Settings › Agents') return { tone: 'offer', title, diff --git a/src/renderer/src/components/settings/AgentCatalogRow.tsx b/src/renderer/src/components/settings/AgentCatalogRow.tsx index 3fa42403a84..a86cff95d7e 100644 --- a/src/renderer/src/components/settings/AgentCatalogRow.tsx +++ b/src/renderer/src/components/settings/AgentCatalogRow.tsx @@ -27,6 +27,8 @@ import { type AgentPermissionChoiceRow } from './AgentPermissionChoiceControls' import { openAppHttpLinkFromAnchor } from '@/lib/app-http-link-open' +import type { AgentOpensAs } from './native-chat-provider-choice' +import { AgentOpensAsControl } from './AgentChatChoiceControls' type AgentAvailability = 'enabled' | 'disabled' @@ -96,6 +98,10 @@ export type AgentCatalogRowProps = { /** The text a terminal launch gets, when it differs from the editable Arguments. */ launchArgs?: string environmentEditor?: React.ReactNode + /** Whether new sessions open as a chat or the CLI; absent where this computer offers no choice. */ + opensAs?: AgentOpensAs + /** The downloaded chat program's install and sign-in, shown in the row's details. */ + chatRuntime?: React.ReactNode } export function AgentCatalogRow({ @@ -122,7 +128,9 @@ export function AgentCatalogRow({ environmentEditor, permission, permissionChoice, - launchArgs = argsOverride + launchArgs = argsOverride, + opensAs, + chatRuntime }: AgentCatalogRowProps): React.JSX.Element { const envSummary = environmentNames?.join(' ') ?? stringifyAgentDefaultEnvDraft(envOverride) const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) @@ -147,10 +155,14 @@ export function AgentCatalogRow({ )?.focus() }) } - const expandable = isDetected || permission !== undefined || permissionChoice !== undefined + const expandable = + isDetected || + permission !== undefined || + permissionChoice !== undefined || + chatRuntime !== undefined return ( -
+
@@ -201,6 +213,7 @@ export function AgentCatalogRow({ />
)} + {opensAs && } + {chatRuntime} {permissionChoice && (
diff --git a/src/renderer/src/components/settings/AgentChatChoiceControls.tsx b/src/renderer/src/components/settings/AgentChatChoiceControls.tsx new file mode 100644 index 00000000000..889c5a2cefa --- /dev/null +++ b/src/renderer/src/components/settings/AgentChatChoiceControls.tsx @@ -0,0 +1,97 @@ +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { translate } from '@/i18n/i18n' +import { antigravityChatSignInRunning } from '@/runtime/antigravity-chat-sign-in-status' +import { useManagedRuntimeInstall } from '@/runtime/use-managed-runtime-install' +import { ManagedRuntimeInstallRow } from '../managed-runtime/ManagedRuntimeInstallRow' +import { useAntigravityChatSignIn } from './AntigravityChatSignInRow' +import { + AntigravityProviderSignInArea, + antigravityProviderSignInShown +} from './AntigravityProviderSignInArea' +import { getHostRuntimeLabel, getRuntimeSentenceLabel } from './accounts-pane-runtime' +import type { AgentOpensAs } from './native-chat-provider-choice' +import { SettingsSegmentedControl } from './SettingsFormControls' + +// The chat choice is this computer's, so the downloaded chat programs are too. +const CHAT_HOST: RuntimeClientTarget = { kind: 'local' } + +export function AgentOpensAsControl({ + label, + opensAs +}: { + label: string + opensAs: AgentOpensAs +}): React.JSX.Element { + return ( + + value={opensAs.value} + onChange={opensAs.onChange} + ariaLabel={translate('settings.agents.defaultOpensAs', 'Open {{value0}} as', { + value0: label + })} + size="sm" + options={[ + { value: 'chat', label: translate('settings.agents.defaultOpensAsChat', 'Chat') }, + { + value: 'cli', + label: translate('settings.agents.defaultOpensAsCli', 'CLI'), + disabled: opensAs.cliMissing, + tooltip: opensAs.cliMissing + ? translate( + 'settings.agents.installCliForTerminal', + 'Install the {{value0}} CLI to open it in a terminal.', + { value0: label } + ) + : undefined + } + ]} + /> + ) +} + +/** The program Orca downloads for an agent's chats, apart from its CLI; mounted only while its row is open. */ +export function AgentChatRuntimeArea({ + agent, + label +}: { + agent: TuiAgent + label: string +}): React.JSX.Element | null { + const runtime = useManagedRuntimeInstall(CHAT_HOST, agent, true) + // Install and Remove wait for a running sign-in, which runs the installed server. + const signIn = useAntigravityChatSignIn( + agent === 'antigravity' && runtime.kind === 'active' ? CHAT_HOST : null + ) + if (runtime.kind !== 'active') { + return null + } + return ( +
+ + {(line, actions) => ( +
+
+

+ {translate('settings.agents.chatRuntime', 'Chat runtime')} +

+
{line}
+
+ {actions} +
+ )} +
+ {agent === 'antigravity' && antigravityProviderSignInShown(runtime) ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/AgentDefaultSetting.tsx b/src/renderer/src/components/settings/AgentDefaultSetting.tsx index ee46b9236dd..1b0dbf5473a 100644 --- a/src/renderer/src/components/settings/AgentDefaultSetting.tsx +++ b/src/renderer/src/components/settings/AgentDefaultSetting.tsx @@ -4,13 +4,7 @@ import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls' - -export type DefaultAgentOpensAs = { - agentLabel: string - value: 'chat' | 'cli' - onChange: (value: 'chat' | 'cli') => void -} +import { SettingsSubsectionHeader } from './SettingsFormControls' function DefaultAgentPill({ active, @@ -47,7 +41,6 @@ export function AgentDefaultSetting({ enabledDetectedAgents, catalog, description, - opensAs, onSetDefault, defaultOpensAsChat = false }: { @@ -58,8 +51,6 @@ export function AgentDefaultSetting({ enabledDetectedAgents: AgentCatalogEntry[] catalog: AgentCatalogEntry[] description: string - /** Present only when the default agent can open as a chat on this computer. */ - opensAs?: DefaultAgentOpensAs onSetDefault: (agent: TuiAgent | 'blank' | null) => void }): React.JSX.Element { const storedDefaultAgent = @@ -110,29 +101,6 @@ export function AgentDefaultSetting({ ) })}
- {opensAs ? ( -
- - value={opensAs.value} - onChange={opensAs.onChange} - ariaLabel={translate('settings.agents.defaultOpensAs', 'Open {{value0}} as', { - value0: opensAs.agentLabel - })} - size="sm" - options={[ - { value: 'chat', label: translate('settings.agents.defaultOpensAsChat', 'Chat') }, - { value: 'cli', label: translate('settings.agents.defaultOpensAsCli', 'CLI') } - ]} - /> -

- {translate( - 'settings.agents.defaultOpensAsScope', - 'Applies to new {{value0}} sessions launched from this computer, including on paired hosts. Matches its Providers switch.', - { value0: opensAs.agentLabel } - )} -

-
- ) : null} ) } diff --git a/src/renderer/src/components/settings/AgentDetectionCatalog.tsx b/src/renderer/src/components/settings/AgentDetectionCatalog.tsx index fe199cfbe8d..e170d22acd1 100644 --- a/src/renderer/src/components/settings/AgentDetectionCatalog.tsx +++ b/src/renderer/src/components/settings/AgentDetectionCatalog.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react' import { AlertTriangle, RefreshCw } from 'lucide-react' import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' @@ -5,6 +6,7 @@ import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' import { AgentCatalogRow, type AgentCatalogRowProps } from './AgentCatalogRow' import { SettingsBadge, SettingsSubsectionHeader } from './SettingsFormControls' +import { AGENTS_INSTALLED_SECTION_ID } from './agents-search' export function AgentDetectionCatalog({ detectedAgents, @@ -41,83 +43,99 @@ export function AgentDetectionCatalog({
)} - {detectedAgents.length > 0 && ( -
- - {translate('auto.components.settings.AgentsPane.02e0143be5', 'Installed')} - - {detectedAgents.length}{' '} - {translate('auto.components.settings.AgentsPane.ed3e110e61', 'detected')} - - {activeServerName ? ( - - {translate('auto.components.settings.AgentsPane.03e1a5081a', 'on {{value0}}', { - value0: activeServerName - })} - - ) : null} - - } - action={ - - } - /> -
- {detectedAgents.map((agent) => ( - - ))} -
-
- )} - - {undetectedAgents.length > 0 && ( -
- - {translate( - 'auto.components.settings.AgentsPane.e8da2af684', - 'Available to install' - )} - - {undetectedAgents.length}{' '} - {translate('auto.components.settings.AgentsPane.024bd95089', 'agents')} - - - } - /> -
- {undetectedAgents.map((agent) => ( - - ))} -
-
- )} +
+ {[ + ...detectedAgents.map((agent) => ({ agent, installed: true })), + ...undetectedAgents.map((agent) => ({ agent, installed: false })) + ].map(({ agent, installed }, index) => { + const firstInSection = index === 0 || index === detectedAgents.length + return ( + + {firstInSection ? ( +
0 && 'mt-8')}> + {installed ? ( + + {translate('auto.components.settings.AgentsPane.02e0143be5', 'Installed')} + + {detectedAgents.length}{' '} + {translate( + 'auto.components.settings.AgentsPane.ed3e110e61', + 'detected' + )} + + {activeServerName ? ( + + {translate( + 'auto.components.settings.AgentsPane.03e1a5081a', + 'on {{value0}}', + { + value0: activeServerName + } + )} + + ) : null} + + } + action={ + + } + /> + ) : ( + + {translate( + 'auto.components.settings.AgentsPane.e8da2af684', + 'Available to install' + )} + + {undetectedAgents.length}{' '} + {translate('auto.components.settings.AgentsPane.024bd95089', 'agents')} + + + } + /> + )} +
+ ) : null} +
+ +
+
+ ) + })} +
{detectionPending && !detectionFailed && (
diff --git a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx index a6383d734a4..7c8d6d340b0 100644 --- a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx +++ b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx @@ -40,17 +40,25 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { getAgentCatalog } from '@/lib/agent-catalog' -import { agentChoiceOpensAsChat } from '@/lib/host-chat-program' +import { + agentChoiceOpensAsChat, + readChatProgram, + resolveChatProgramContext +} from '@/lib/host-chat-program' import { useAppStore } from '@/store' +import { readNativeDetectedAgents } from '@/store/slices/local-detected-agent-store-state' import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { AgentDefaultSetting } from './AgentDefaultSetting' import { AgentDetectionCatalog } from './AgentDetectionCatalog' import type { AgentCatalogRowProps } from './AgentCatalogRow' import { HostAgentEnvironmentEditor } from './HostAgentEnvironmentEditor' import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' -import { buildDefaultAgentOpensAs } from './native-chat-provider-choice' -import { useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents' -import { readNativeDetectedAgents } from '@/store/slices/local-detected-agent-store-state' +import { buildAgentOpensAs } from './native-chat-provider-choice' +import { AgentChatRuntimeArea } from './AgentChatChoiceControls' +import { chatProviderAgents, useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents' +import { readHostStructuredAgents } from '@/runtime/host-structured-agents' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { translate } from '@/i18n/i18n' export type AgentPermissionsRenderer = (props: AgentPermissionsSettingProps) => React.ReactNode @@ -103,11 +111,42 @@ export function AgentLaunchSettingsCatalog({ ? agentChoiceOpensAsChat(state, chosen, workspace, detected) : false ) + // The chat-or-CLI choice is this computer's, so a paired host's page doesn't offer it. + const chatAgents = + local && isNativeChatEnabled(local.settings) + ? chatProviderAgents(LOCAL_EXECUTION_HOST_ID, undefined) + : [] + const managedChatAgents = new Set( + readHostStructuredAgents(LOCAL_EXECUTION_HOST_ID, undefined) + ?.filter((row) => row.managedRuntime) + .map((row) => row.agent) + ) + // Agents whose chat program is here without their CLI, like a downloaded chat runtime, are installed for chat. + const chatOnlyKey = useAppStore((state) => { + if (chatAgents.length === 0) { + return '' + } + const context = resolveChatProgramContext(state, workspace) + return chatAgents + .filter( + (agent) => + !installedHere?.includes(agent) && + readChatProgram(state, agent, context, installedHere) === 'available' + ) + .join(' ') + }) + const chatOnly = new Set(chatOnlyKey.split(' ')) const catalog = getAgentCatalog() const detectedAgents = detectedIds === null ? [] : catalog.filter((agent) => detectedIds.has(agent.id)) + const installedAgents = catalog.filter( + (agent) => detectedIds?.has(agent.id) || chatOnly.has(agent.id) + ) const undetectedAgents = catalog.filter( - (agent) => detectedIds !== null && !detectedIds.has(agent.id) + (agent) => + !detectedIds?.has(agent.id) && + !chatOnly.has(agent.id) && + (detectedIds !== null || chatAgents.includes(agent.id)) ) const save = (mutation: AgentLaunchSettingsMutation): void => { const scopedMutation = @@ -176,7 +215,7 @@ export function AgentLaunchSettingsCatalog({ autoSupported, save }) - const rowProps = ( + const cliRowProps = ( agent: (typeof catalog)[number], isDetected: boolean ): AgentCatalogRowProps => ({ @@ -231,6 +270,30 @@ export function AgentLaunchSettingsCatalog({ ? buildCodexSessionSourceHomeControl(local.settings, local.updateSettings) : undefined }) + const rowProps = ( + agent: (typeof catalog)[number], + listedInstalled: boolean + ): AgentCatalogRowProps => { + const isDetected = listedInstalled && detectedIds?.has(agent.id) === true + return { + ...cliRowProps(agent, isDetected), + opensAs: local + ? buildAgentOpensAs({ + agent: agent.id, + cliDetected: installedHere === null ? null : installedHere.includes(agent.id), + chatProgramHere: chatOnly.has(agent.id), + settings: local.settings, + updateSettings: local.updateSettings + }) + : undefined, + chatRuntime: + chatAgents.includes(agent.id) && + managedChatAgents.has(agent.id) && + isTuiAgentEnabled(agent.id, settings.disabledTuiAgents) ? ( + + ) : undefined + } + } return ( <> save({ type: 'default', agent })} defaultOpensAsChat={defaultOpensAsChat} /> @@ -285,7 +337,7 @@ export function AgentLaunchSettingsCatalog({ autoSupported })} ({ } ) })) +const install = vi.hoisted((): { current: ManagedRuntimeInstallObservation } => ({ + current: { kind: 'pending' } +})) +vi.mock('@/runtime/use-managed-runtime-install', () => ({ + useManagedRuntimeInstall: vi.fn(() => install.current) +})) +vi.mock('@/runtime/managed-runtime-install-status', async (importOriginal) => ({ + ...(await importOriginal()), + installManagedRuntime: vi.fn(async () => {}), + removeManagedRuntime: vi.fn(async () => {}) +})) afterEach(() => cleanup()) async function showRow(owner: RuntimeClientTarget) { @@ -193,3 +220,150 @@ it("names the account and why it's needed before the host knows its sign-in", as await act(async () => publish(owner, null, 'signedOut')) expect(screen.getByRole('status').textContent).toMatch(/^Not signed in\./) }) + +// Setup composition from the removed provider page; the shared controls own all commands and refusals. +function PreviousProviderSetup() { + const runtime = useManagedRuntimeInstall({ kind: 'local' }, 'antigravity', true) + const signIn = useAntigravityChatSignIn(runtime.kind === 'active' ? { kind: 'local' } : null) + if (runtime.kind !== 'active') { + return null + } + return ( + <> + + {(line, actions) => ( + <> + {line} + {actions} + + )} + + {antigravityProviderSignInShown(runtime) ? ( + + ) : null} + + ) +} + +it('preserves moved setup owner, actions and failure states through collapse and a late sign-in reply', async () => { + const owner = { kind: 'local' } as const + const subject = { target: owner, runtimeId: null, agent: 'antigravity' } + for (const previous of [true, false]) { + vi.clearAllMocks() + publish(owner, null, 'signedOut') + const openUrl = vi.fn(async () => undefined) + Object.assign(window, { api: { shell: { openUrl } } }) + install.current = { kind: 'active', subject, removable: true, snapshot: { kind: 'readFailed' } } + const element = (expanded = true) => ( + + {expanded ? ( + previous ? ( + + ) : ( + + ) + ) : null} + + ) + const view = render(element()) + await act(async () => undefined) + expect(useManagedRuntimeInstall).toHaveBeenCalledWith(owner, 'antigravity', true) + expect(screen.getByText('Could not check Antigravity on this host. Retrying.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Sign in' }).hasAttribute('disabled')).toBe(false) + const state = { + agent: 'antigravity', + revision: 1, + available: true, + version: '1', + installedVersion: null, + installedCurrent: false, + operation: null, + source: null + } + install.current = { + kind: 'active', + subject, + removable: true, + snapshot: { kind: 'known', state } + } + view.rerender(element()) + expect(screen.queryByText('Account')).toBeNull() + vi.mocked(installManagedRuntime).mockRejectedValueOnce( + new Error('Installation refused. Try again.') + ) + await userEvent.click(screen.getByRole('button', { name: 'Install' })) + expect(installManagedRuntime).toHaveBeenCalledExactlyOnceWith(subject) + await screen.findByText('Installation refused. Try again.') + install.current = { + kind: 'active', + subject, + removable: true, + snapshot: { + kind: 'known', + state: { + ...state, + source: 'managed', + installedVersion: '1', + installedCurrent: true, + canRemove: true + } + } + } + view.rerender(element()) + expect(screen.getByText('Account')).toBeTruthy() + await userEvent.click(screen.getByRole('button', { name: 'More actions for Antigravity' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Remove downloaded runtime' })) + vi.mocked(removeManagedRuntime).mockRejectedValueOnce(new Error('Removal refused. Try again.')) + await userEvent.click(screen.getByRole('button', { name: 'Remove' })) + expect(removeManagedRuntime).toHaveBeenCalledExactlyOnceWith(subject) + await screen.findByText('Removal refused. Try again.') + vi.mocked(callAntigravityChatSignIn).mockRejectedValueOnce( + new Error('Sign-in refused. Try again.') + ) + await userEvent.click(screen.getByRole('button', { name: 'Sign in' })) + expect(callAntigravityChatSignIn).toHaveBeenCalledWith(owner, 'Start') + await screen.findByText('Sign-in refused. Try again.') + const late = Promise.withResolvers<{ flowId: string }>() + fake.start = late.promise + await userEvent.click(screen.getByRole('button', { name: 'Retry sign-in' })) + view.rerender(element(false)) + await act(async () => { + late.resolve({ flowId: 'late-moved' }) + publish(owner, { flowId: 'late-moved', phase: 'waiting', url }) + }) + expect(openUrl).not.toHaveBeenCalled() + fake.start = null + view.rerender(element()) + await screen.findByText('Finish signing in in your browser.') + await userEvent.click(screen.getByRole('button', { name: 'More actions for Antigravity' })) + expect( + screen + .getByRole('menuitem', { name: 'Remove downloaded runtime' }) + .getAttribute('aria-disabled') + ).toBe('true') + await userEvent.keyboard('{Escape}') + await userEvent.type(screen.getByRole('textbox'), 'invalid callback') + vi.mocked(callAntigravityChatSignIn).mockRejectedValueOnce( + new Error('Paste the complete browser address.') + ) + await userEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(callAntigravityChatSignIn).toHaveBeenCalledWith(owner, 'Complete', { + flowId: 'late-moved', + callbackUrl: 'invalid callback' + }) + await screen.findByText('Paste the complete browser address.') + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(callAntigravityChatSignIn).toHaveBeenCalledWith(owner, 'Cancel', { + flowId: 'late-moved' + }) + cleanup() + } +}) diff --git a/src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx b/src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx index 7ea58cc346f..549e942d932 100644 --- a/src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx +++ b/src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx @@ -46,7 +46,7 @@ export function AntigravityProviderSignInArea(props: { // A settled failure says only why; a running sign-in keeps its step beside a copy failure. const line = alert && !ownFlow ? null : signIn.line return ( -
+

diff --git a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx index 54a1f712ff7..572b13696d2 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx @@ -1,12 +1,9 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { decodeAgentSessionAgentsResult } from '../../../../shared/agent-session/agent-session-registered-agents' import { ChatSettingsSection } from './ChatSettingsSection' -import { NativeChatProvidersSetting } from './NativeChatProvidersSetting' import { TooltipProvider } from '../ui/tooltip' import { ActiveSettingsSectionProvider } from './SettingsSection' import { getChatAppearanceSearchEntries } from './chat-appearance-search' @@ -15,26 +12,16 @@ import { getChatInlineVisualsSearchEntry } from './chat-inline-visuals-search' import { getChatBehaviorSearchEntries } from './chat-behavior-search' import { AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' -import type * as HostStructuredAgentsModule from '@/runtime/host-structured-agents' import { i18n } from '@/i18n/i18n' import { buildSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata' import { buildCmdJSettingsResults } from '../cmd-j/palette-results' import { isSettingsNavigationTarget } from '@/lib/settings-navigation-types' import { getSettingsSectionId, getSettingsScrollTarget } from './settings-navigation-foundations' -const state = vi.hoisted( - (): { - settingsSearchQuery: string - settings: GlobalSettings | null - localDetectedAgentIdsByContext: Record - } => ({ settingsSearchQuery: '', settings: null, localDetectedAgentIdsByContext: {} }) -) -vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ - ...(await importOriginal()), - readHostStructuredAgents: () => - decodeAgentSessionAgentsResult({ agents: [{ agent: 'grok', capabilities: {} }] }) ?? [] +const state = vi.hoisted((): { settingsSearchQuery: string; settings: GlobalSettings | null } => ({ + settingsSearchQuery: '', + settings: null })) -vi.mock('@/lib/host-chat-program-acquisition', () => ({ useChatProgramFacts: () => {} })) vi.mock('../../store', () => ({ useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { getState: () => state @@ -49,7 +36,6 @@ afterEach(async () => { beforeEach(() => { state.settingsSearchQuery = '' state.settings = null - state.localDetectedAgentIdsByContext = {} setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) }) @@ -89,36 +75,26 @@ function renderChat( } describe('Chat settings page', () => { - it('preserves clicked provider choices while queued saves and inventory changes settle', async () => { - let latest = getDefaultSettings('/tmp') - state.settings = latest - state.localDetectedAgentIdsByContext = { host: [] } - let finish: () => void = () => {} - const delayed = new Promise((resolve) => { - finish = resolve - }) - const updateSettings = vi.fn(async (updates: Partial) => { - await delayed - latest = { ...latest, ...updates } - state.settings = latest - }) - const element = () => ( - - ) - const { rerender } = render(element()) - const claude = screen.getByRole('switch', { name: 'Open Claude as a chat' }) - const grok = screen.getByRole('switch', { name: 'Open Grok as a chat' }) - expect(grok.getAttribute('aria-checked')).toBe('false') - fireEvent.click(claude) - await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)) - fireEvent.click(grok) - fireEvent.click(claude) - state.localDetectedAgentIdsByContext = { host: ['grok'] } - rerender(element()) - expect(grok.getAttribute('aria-checked')).toBe('true') - finish() - await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(3)) - expect(latest.nativeChatProviders).toEqual({ claude: false, grok: true }) + it('moves the chat-or-CLI choice from the Chat page to the Agents list and its search', () => { + renderChat(true) + expect(screen.queryByText('Providers')).toBeNull() + expect(screen.queryByRole('switch', { name: /as a chat/ })).toBeNull() + const results = (nativeChatEnabled: boolean) => + buildCmdJSettingsResults( + buildSettingsNavigationMetadata({ + isMac: true, + isWindows: false, + isWebClient: false, + nativeChatEnabled, + repos: [] + }) + ).filter((entry) => entry.targetSectionId === 'agents-installed') + const [choice] = results(true) + expect(choice?.sectionId).toBe('agents') + for (const term of ['chat', 'cli', 'terminal', 'provider']) { + expect(choice?.configKeywords.some((keyword) => keyword.includes(term))).toBe(true) + } + expect(results(false)).toEqual([]) }) it('retains the unsaved naming draft when search no longer matches Chat', () => { diff --git a/src/renderer/src/components/settings/ChatSettingsSection.tsx b/src/renderer/src/components/settings/ChatSettingsSection.tsx index 4e4dcdbaa38..3d188ac50bb 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.tsx @@ -3,7 +3,6 @@ import { AppearanceChatSection } from './AppearanceChatSection' import { ChatBehaviorSetting } from './ChatBehaviorSetting' import { ChatNamingSetting } from './ChatNamingSetting' import { NativeChatInlineVisualsSetting } from './NativeChatInlineVisualsSetting' -import { NativeChatProvidersSetting } from './NativeChatProvidersSetting' import { SettingsSection } from './SettingsSection' import { SettingsSubsectionHeader } from './SettingsFormControls' import { matchesSettingsSearch } from './settings-search' @@ -80,13 +79,6 @@ export function ChatSettingsSection({ ) : null} - {showDesktopOnlySettings ? ( - - ) : null} {showDesktopOnlySettings ? ( { it('shows resolved launch defaults in a collapsed legacy host row', async () => { transport.read.mockResolvedValue(projectAgentLaunchSettings({ agentDefaultArgs: {} })) render(pane(17)) - const installed = (await screen.findByText('Installed')).closest('section') - if (!installed) { - throw new Error('missing installed agents section') + const row = (await screen.findByRole('radiogroup', { name: 'Claude availability' })).closest( + '.py-3' + ) + if (!(row instanceof HTMLElement)) { + throw new Error('missing Claude settings row') } - expect(within(installed).getByText('--dangerously-skip-permissions')).toBeTruthy() + expect(within(row).getByText('--dangerously-skip-permissions')).toBeTruthy() expect(transport.mutate).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/settings/NativeChatProvidersSetting.test.tsx b/src/renderer/src/components/settings/NativeChatProvidersSetting.test.tsx deleted file mode 100644 index 931ef410e6f..00000000000 --- a/src/renderer/src/components/settings/NativeChatProvidersSetting.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// @vitest-environment happy-dom -import { render, cleanup } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../../../shared/constants' -import type { ManagedRuntimeInstallObservation } from '@/runtime/use-managed-runtime-install' -const observation = vi.hoisted((): { current: ManagedRuntimeInstallObservation } => ({ - current: { kind: 'pending' } -})) -vi.mock('@/runtime/use-managed-runtime-install', () => ({ - useManagedRuntimeInstall: () => observation.current -})) -vi.mock('@/lib/host-chat-program-acquisition', () => ({ useChatProgramFacts: () => {} })) -vi.mock('@/lib/chat-provider-agents', () => ({ - chatProviderAgents: () => ['grok'], - useHostStructuredAgentsVersion: () => 0 -})) -vi.mock('./AntigravityChatSignInRow', () => ({ useAntigravityChatSignIn: () => ({ flow: null }) })) -import { useAppStore } from '@/store' -import { NativeChatProvidersSetting } from './NativeChatProvidersSetting' -const initial = useAppStore.getState() -afterEach(() => { - cleanup() - useAppStore.setState(initial, true) -}) -it('reserves the switch footprint through unknown and failed reads, then shows managed/external installed and saved false', () => { - useAppStore.setState({ localDetectedAgentIdsByContext: { host: [] } }) - const settings = getDefaultSettings('/tmp') - const surface = () => - const view = render(surface()) - expect(view.queryByRole('switch')).toBeNull() - expect(view.container.querySelector('.h-5.w-9')).not.toBeNull() - const active = { - kind: 'active', - subject: { target: { kind: 'local' }, runtimeId: null, agent: 'grok' }, - removable: false - } as const - observation.current = { ...active, snapshot: { kind: 'readFailed' } } - view.rerender(surface()) - expect(view.queryByRole('switch')).toBeNull() - const installed = { - agent: 'grok', - revision: 1, - available: true, - version: '1', - installedVersion: '1', - installedCurrent: true, - operation: null - } as const - for (const source of ['managed', 'external'] as const) { - observation.current = { - ...active, - snapshot: { kind: 'known', state: { ...installed, source } } - } - view.rerender(surface()) - expect(view.getByRole('switch').getAttribute('aria-checked')).toBe('true') - const icon = view.getByRole('switch').parentElement?.firstElementChild - expect(icon?.classList.contains('size-7')).toBe(true) - } - settings.nativeChatProviders = { grok: false } - view.rerender(surface()) - expect(view.getByRole('switch').getAttribute('aria-checked')).toBe('false') -}) diff --git a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx deleted file mode 100644 index 6898af91632..00000000000 --- a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useChatProgramFacts } from '@/lib/host-chat-program-acquisition' -import { readHostStructuredAgents } from '@/runtime/host-structured-agents' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { isNativeChatProviderDisplayedOn } from '../../../../shared/native-chat/native-chat-providers' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { translate } from '@/i18n/i18n' -import { AgentIcon, getAgentCatalog, getAgentLabel } from '@/lib/agent-catalog' -import { chatProviderAgents, useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents' -import { useManagedRuntimeInstall } from '@/runtime/use-managed-runtime-install' -import { useAppStore } from '../../store' -import { readNativeDetectedAgents } from '@/store/slices/local-detected-agent-store-state' -import { saveNativeChatProviderChoice } from './native-chat-provider-choice' -import { Card, CardContent } from '../ui/card' -import { ManagedRuntimeInstallRow } from '../managed-runtime/ManagedRuntimeInstallRow' -import { SearchableSetting } from './SearchableSetting' -import { SettingsSubsectionHeader, SettingsSwitch } from './SettingsFormControls' -import { getChatProvidersSearchEntry } from './chat-providers-search' -import { antigravityChatSignInRunning } from '@/runtime/antigravity-chat-sign-in-status' -import { useAntigravityChatSignIn } from './AntigravityChatSignInRow' -import { - AntigravityProviderSignInArea, - antigravityProviderSignInShown -} from './AntigravityProviderSignInArea' -import { getHostRuntimeLabel, getRuntimeSentenceLabel } from './accounts-pane-runtime' -import { openAppHttpLinkFromAnchor } from '@/lib/app-http-link-open' - -// The agents listed here are this computer's, so their downloaded programs are too. -const CHAT_HOST: RuntimeClientTarget = { kind: 'local' } - -const BUILT_IN_CHAT_AGENTS: readonly TuiAgent[] = ['claude', 'codex'] - -function ProviderStatus({ - agent, - installed, - enabledInAgents -}: { - agent: TuiAgent - installed: readonly TuiAgent[] | null - enabledInAgents: boolean -}): React.JSX.Element | null { - if (!enabledInAgents) { - return ( - - {translate('settings.chat.providers.agentDisabled', 'Turned off in Agent settings')} - - ) - } - if (installed === null) { - return null - } - if (installed.includes(agent)) { - return {translate('settings.chat.providers.installed', 'Installed')} - } - const homepageUrl = getAgentCatalog().find((entry) => entry.id === agent)?.homepageUrl - return ( - - {translate('settings.chat.providers.notInstalled', 'Not installed')} - {homepageUrl ? ( - <> - {' · '} - - {translate('settings.chat.providers.install', 'Install')} - - - ) : null} - - ) -} - -/** Use managed chat-runtime status for downloaded providers; CLI installation does not establish chat readiness. */ -function ProviderDetail(props: { - agent: TuiAgent - label: string - installed: readonly TuiAgent[] | null - enabledInAgents: boolean - settings: GlobalSettings - updateSettings: (updates: Partial) => void | Promise -}): React.JSX.Element { - const runtime = useManagedRuntimeInstall(CHAT_HOST, props.agent, props.enabledInAgents) - // Install and Remove wait for a running sign-in, which runs the installed server. - const signIn = useAntigravityChatSignIn( - props.agent === 'antigravity' && props.enabledInAgents && runtime.kind === 'active' - ? CHAT_HOST - : null - ) - const state = - runtime.kind === 'active' && runtime.snapshot.kind === 'known' ? runtime.snapshot.state : null - const on = isNativeChatProviderDisplayedOn(props.settings, props.agent, [ - ...(props.installed ?? []), - ...(state?.source ? [props.agent] : []) - ]) - const unsettled = - !on && - props.settings.nativeChatProviders?.[props.agent] === undefined && - (props.installed === null || - runtime.kind === 'pending' || - (runtime.kind === 'active' && runtime.snapshot.kind !== 'known')) - const control = unsettled ? ( -