diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index 8bca757219d..0c7ede25b1c 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -6,7 +6,7 @@ import { type ProviderProcessExit } from '../provider-process/managed-provider-process' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' -import { AcpAuthRequiredError, AcpConnectionClosedError } from './acp-errors' +import { AcpConnectionClosedError, AcpSignInPromptError } from './acp-errors' import { resolveAcpPeerOptions, type AcpPeerOptions } from './acp-peer-limits' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' @@ -135,7 +135,7 @@ export class AcpAgentConnection extends AcpSessionRuntime { child.on('error', (error) => super.close(error)) signedOut = () => { signIn?.answered?.(false) - super.close(new AcpAuthRequiredError('The agent asked to sign in in a browser')) + super.close(new AcpSignInPromptError('The agent asked to sign in in a browser')) } // A broken sign-in line on the protocol stream is a broken agent, not a sign-in answer. unreadable = () => diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index edcc93de077..9918b8ad3b7 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -31,6 +31,20 @@ export class AcpAuthRequiredError extends AcpAgentError { } } +/** The agent printed its sign-in prompt instead of answering, so the message is Orca's, not its. */ +export class AcpSignInPromptError extends AcpAuthRequiredError {} + +/** Types an agent's sign-in answer. A sign-in prompt close stays itself: a copy would pass Orca's + * log words off as the agent's. */ +export function acpAuthRequiredError( + error: AcpAgentError, + authMethods?: AuthMethod[] +): AcpAuthRequiredError { + return error instanceof AcpSignInPromptError + ? error + : new AcpAuthRequiredError(error.message, error.data, authMethods) +} + /** Orca could not read the agent's answer; `data` keeps the raw answer, `issues` why it failed. */ export class AcpInvalidResponseError extends AcpRpcError { constructor( diff --git a/src/main/acp/acp-response-settlement.ts b/src/main/acp/acp-response-settlement.ts index e0f2c340b40..2039403b63d 100644 --- a/src/main/acp/acp-response-settlement.ts +++ b/src/main/acp/acp-response-settlement.ts @@ -1,5 +1,5 @@ import type { z } from 'zod' -import { AcpAgentError, AcpAuthRequiredError, AcpInvalidResponseError } from './acp-errors' +import { AcpAgentError, AcpInvalidResponseError, acpAuthRequiredError } from './acp-errors' import type { AcpJsonRpcPeer } from './acp-json-rpc-peer' export function callAcpSession( @@ -16,7 +16,7 @@ export function callAcpSession( const error = response.error if (error instanceof AcpAgentError && error.code === -32000) { answered?.(false) - throw new AcpAuthRequiredError(error.message, error.data) + throw acpAuthRequiredError(error) } throw error } diff --git a/src/main/acp/acp-session-setup.ts b/src/main/acp/acp-session-setup.ts index 8296315eb26..0bbcf16b354 100644 --- a/src/main/acp/acp-session-setup.ts +++ b/src/main/acp/acp-session-setup.ts @@ -1,5 +1,5 @@ import type { z } from 'zod' -import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import { AcpAuthRequiredError, AcpRpcError, acpAuthRequiredError } from './acp-errors' import { NewSessionResponseSchema, LoadSessionResponseSchema, @@ -67,7 +67,7 @@ export async function setupAcpSession( if (!(error instanceof AcpAuthRequiredError)) { throw error } - const required = new AcpAuthRequiredError(error.message, error.data, initialized.authMethods) + const required = acpAuthRequiredError(error, initialized.authMethods) const method = initialized.authMethods?.find((method) => method.id === options.authMethodId) if (!method) { throw required @@ -77,7 +77,7 @@ export async function setupAcpSession( return await setup() } catch (failure) { if (failure instanceof AcpAuthRequiredError) { - throw new AcpAuthRequiredError(failure.message, failure.data, initialized.authMethods) + throw acpAuthRequiredError(failure, initialized.authMethods) } throw failure } diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index 7f4cea61369..2343ef650de 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -5,7 +5,7 @@ import { providerStartupFailureFact } from '../native-chat/agent-session-wire/st import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { AcpAuthRequiredError } from './acp-errors' +import { AcpSignInPromptError } from './acp-errors' import { ACP_LAUNCH_SPECS } from './acp-launch-specs' import { AcpSessionRuntime } from './acp-session-runtime' import { @@ -84,7 +84,8 @@ it('ends a running turn signed out when the connection closes for sign-in', asyn await sendHello(rig, 'first') await rig.frame('session/prompt') // The protocol close the real connection makes at a sign-in prompt line on stderr. - AcpSessionRuntime.prototype.close.call(rig.child(), new AcpAuthRequiredError('Sign in')) + const internal = 'The agent asked to sign in in a browser' + AcpSessionRuntime.prototype.close.call(rig.child(), new AcpSignInPromptError(internal)) await rig.settle() expect(rig.settled).toContainEqual( expect.objectContaining({ @@ -93,6 +94,21 @@ it('ends a running turn signed out when the connection closes for sign-in', asyn rejection: expect.objectContaining({ kind: 'notSignedIn' }) }) ) + // Orca's own words for the prompt are for logs; the chat says only how to sign in. + const rejected = rig.settled.find((item) => 'state' in item && item.state === 'rejected') + expect(rejected && 'reason' in rejected ? rejected.reason : '').not.toContain(internal) +}) + +it('starts signed out without Orca words when the connection closes for sign-in', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) }) + const acquired = rig.acquire().catch((failure: unknown) => failure) + await rig.frame('session/new') + const internal = 'The agent asked to sign in in a browser' + AcpSessionRuntime.prototype.close.call(rig.child(), new AcpSignInPromptError(internal)) + const error = await acquired + // Session setup re-types sign-in answers; this close must stay Orca's, words and all. + expect(error).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(error)).toBeUndefined() }) it.each(ACP_LAUNCH_SPECS)( diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index ba87ec6a505..920937abd2a 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -7,7 +7,7 @@ import { agentSessionFailureWords } from '../../shared/agent-session-failure-wor import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' -import { AcpAgentError, AcpAuthRequiredError } from './acp-errors' +import { AcpAgentError, AcpAuthRequiredError, AcpSignInPromptError } from './acp-errors' import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ @@ -20,6 +20,10 @@ function acpStopReasonFailed(stopReason: string): boolean { /** The provider's words in its error answer to `session/prompt`. Agents often answer a generic * message ("Internal error") and keep their own words in `data`. */ export function acpPromptErrorDetail(dialect: AcpDialect, error: AcpAgentError): string { + // Empty: the sign-in sentence alone says what to do. + if (error instanceof AcpSignInPromptError) { + return '' + } return dialect.promptErrorDetail?.(error) ?? acpErrorDataWords(error.data) ?? error.message } diff --git a/src/renderer/src/components/managed-runtime/ManagedRuntimeActionsMenu.tsx b/src/renderer/src/components/managed-runtime/ManagedRuntimeActionsMenu.tsx new file mode 100644 index 00000000000..43e4afbe804 --- /dev/null +++ b/src/renderer/src/components/managed-runtime/ManagedRuntimeActionsMenu.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { MoreHorizontal } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { ManagedRuntimeRemoveDialog } from './ManagedRuntimeRemoveDialog' + +/** The ⋯ menu beside an installed runtime's switch: Update or Reinstall, and Remove behind a + * confirmation. */ +export function ManagedRuntimeActionsMenu(props: { + agentLabel: string + host: RuntimeClientTarget + /** Update or Reinstall, when the host offers one. */ + install: { label: string; onSelect: () => void } | null + onRemove: (() => void) | null + disabled: boolean +}): React.JSX.Element { + const { agentLabel, install, onRemove, disabled } = props + const [confirming, setConfirming] = useState(false) + return ( + <> + + + + + + {install === null ? null : ( + + {install.label} + + )} + {onRemove === null ? null : ( + setConfirming(true)} + > + {translate('components.managed-runtime.remove', 'Remove downloaded runtime')} + + )} + + + {onRemove === null ? null : ( + + )} + + ) +} diff --git a/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx b/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx index 564d3acf538..5ffda552d14 100644 --- a/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx +++ b/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx @@ -13,19 +13,22 @@ import { managedRuntimeInstallView, type ManagedRuntimeInstallAction } from './managed-runtime-install-view' +import { ManagedRuntimeActionsMenu } from './ManagedRuntimeActionsMenu' import { ManagedRuntimeLinkButton } from './ManagedRuntimeLinkButton' -import { ManagedRuntimeRemoveButton } from './ManagedRuntimeRemoveButton' // Parts are joined by ' · ', so a sentence followed by another part drops its full stop. const said = (text: string, last: boolean): string => (last ? text : text.replace(/[.。]$/u, '')) -/** Settings' one-line install state for the runtime Orca provides an agent, on the host it runs on, - * with its actions as links, except a first Install, which is a button the caller places at its - * row's end. A chat's install card shows the same host answer, so either one's action moves both. */ +/** Settings' one-line install state for the runtime Orca provides an agent, on the host it runs + * on, with Cancel and retries as links. A first Install button, and the ⋯ menu with Update, + * Reinstall and Remove, are what the caller places at its row's end. A chat's install card shows + * the same host answer, so either one's action moves both. */ export function ManagedRuntimeInstallRow(props: { observation: Extract agentLabel: string - children: (line: React.ReactNode, button: React.ReactNode) => React.ReactNode + children: (line: React.ReactNode, actions: React.ReactNode) => React.ReactNode + /** A sign-in runs the installed server, so Install and Remove wait for it; Cancel does not. */ + signInRunning?: boolean }): React.JSX.Element { const { observation, agentLabel } = props // A click holds its button until the host answers, so a double click starts nothing twice. @@ -55,12 +58,13 @@ export function ManagedRuntimeInstallRow(props: { const button = view.prominent ? view.action : null const action = button ? null : view.action const removable = observation.removable && view.removable + const held = pending !== null || props.signInRunning === true const primary = (chosen: ManagedRuntimeInstallAction) => () => chosen.kind === 'cancel' ? cancelManagedRuntimeInstall(subject, chosen.operationId) : installManagedRuntime(subject) const parts: React.ReactNode[] = [] - const trailing = Boolean(alert || action || removable || view.note) + const trailing = Boolean(alert || action || view.note) if (view.status !== null) { parts.push( @@ -81,7 +85,7 @@ export function ManagedRuntimeInstallRow(props: { if (alert) { parts.push( - {said(alert, !(action || removable || view.note))} + {said(alert, !(action || view.note))} ) } @@ -89,24 +93,13 @@ export function ManagedRuntimeInstallRow(props: { parts.push( act(action.kind, primary(action))} > {action.label} ) } - if (removable) { - parts.push( - act('remove', () => removeManagedRuntime(subject))} - /> - ) - } if (view.note) { parts.push({view.note}) } @@ -122,19 +115,33 @@ export function ManagedRuntimeInstallRow(props: { )} ) + const menu = view.menuAction + const install = menu && { label: menu.label, onSelect: () => act(menu.kind, primary(menu)) } + const remove = removable ? () => act('remove', () => removeManagedRuntime(subject)) : null return ( <> {props.children( line, - button ? ( - - ) : null + <> + {button ? ( + + ) : null} + {install || remove ? ( + + ) : null} + )} ) diff --git a/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveButton.tsx b/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveButton.tsx deleted file mode 100644 index 904b7a33a35..00000000000 --- a/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveButton.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from 'react' -import { translate } from '@/i18n/i18n' -import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' -import { useAppStore } from '@/store' -import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { Button } from '../ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' -import { ManagedRuntimeLinkButton } from './ManagedRuntimeLinkButton' - -/** Settings' Remove link for the runtime Orca downloaded, behind a confirmation: it stops running - * chats. */ -export function ManagedRuntimeRemoveButton(props: { - agentLabel: string - host: RuntimeClientTarget - disabled: boolean - onRemove: () => void -}): React.JSX.Element { - const { agentLabel, host, disabled, onRemove } = props - const [confirming, setConfirming] = useState(false) - const hostLabel = useAppStore((state) => - host.kind === 'environment' - ? selectExecutionHostDisplayLabel(state, toRuntimeExecutionHostId(host.environmentId)) - : translate('components.managed-runtime.thisComputer', 'this computer') - ) - return ( - <> - setConfirming(true)} - > - {translate('components.managed-runtime.removeConfirm', 'Remove')} - - - - - - {translate( - 'components.managed-runtime.removeTitle', - 'Remove the downloaded {{agent}} runtime from {{host}}?', - { agent: agentLabel, host: hostLabel } - )} - - - {translate( - 'components.managed-runtime.removeDescription', - '{{agent}} chats that are running stop. Their history is kept.', - { agent: agentLabel } - )} - - - - - - - - - - ) -} diff --git a/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx b/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx new file mode 100644 index 00000000000..f792ef249de --- /dev/null +++ b/src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx @@ -0,0 +1,66 @@ +import { translate } from '@/i18n/i18n' +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { useAppStore } from '@/store' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +/** Confirms removing the runtime Orca downloaded: it stops running chats. */ +export function ManagedRuntimeRemoveDialog(props: { + agentLabel: string + host: RuntimeClientTarget + open: boolean + onOpenChange: (open: boolean) => void + onRemove: () => void +}): React.JSX.Element { + const { agentLabel, host, open, onOpenChange, onRemove } = props + const hostLabel = useAppStore((state) => + host.kind === 'environment' + ? selectExecutionHostDisplayLabel(state, toRuntimeExecutionHostId(host.environmentId)) + : translate('components.managed-runtime.thisComputer', 'this computer') + ) + return ( + + + + + {translate( + 'components.managed-runtime.removeTitle', + 'Remove the downloaded {{agent}} runtime from {{host}}?', + { agent: agentLabel, host: hostLabel } + )} + + + {translate( + 'components.managed-runtime.removeDescription', + '{{agent}} chats that are running stop. Their history is kept.', + { agent: agentLabel } + )} + + + + + + + + + ) +} diff --git a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts index 11398c6605f..fdd18df4568 100644 --- a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts +++ b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.test.ts @@ -25,8 +25,11 @@ const op = (patch: Partial): ManagedRuntimeOperation => ...patch }) +const viewOf = (patch: Partial) => + managedRuntimeInstallView({ kind: 'known', state: { ...idle, ...patch } }, 'Fake') + function shown(patch: Partial) { - const view = managedRuntimeInstallView({ kind: 'known', state: { ...idle, ...patch } }, 'Fake') + const view = viewOf(patch) return [view.status, view.action?.label ?? null, view.error, view.note] } @@ -77,14 +80,18 @@ describe('managedRuntimeInstallView', () => { 'No space left.', null ]) - // A healthy copy offers no Reinstall; Remove is its only action. - expect(shown(installed)).toEqual(['Installed.', null, null, null]) - expect(shown({ ...installed, installedVersion: '1.2.0', installedCurrent: false })).toEqual([ - 'Installed.', - 'Update', - null, - null - ]) + // An installed copy's line says only that; Reinstall or Update and Remove sit in its menu. + expect(shown(installed)).toEqual(['Installed', null, null, null]) + expect(viewOf({ ...installed, canRemove: true })).toMatchObject({ + removable: true, + menuAction: { kind: 'install', label: 'Reinstall Fake' } + }) + const update = viewOf({ ...installed, installedVersion: '1.2.0', installedCurrent: false }) + expect(update).toMatchObject({ + status: 'Installed', + action: null, + menuAction: { label: 'Update' } + }) const removal = (patch: Partial) => managedRuntimeInstallView( { @@ -106,7 +113,7 @@ describe('managedRuntimeInstallView', () => { }) // A refused removal keeps the files, so Remove stays offered beside the host's reason. expect(removal({ phase: 'failed', error: 'Fake could not be stopped.' })).toMatchObject({ - status: 'Installed.', + status: 'Installed', error: 'Fake could not be stopped.', removable: true }) @@ -119,12 +126,12 @@ describe('managedRuntimeInstallView', () => { null, null ]) - expect(shown({ damaged: true })).toEqual([ - 'This copy of Fake is damaged.', - 'Reinstall', - null, - null - ]) + expect(viewOf({ damaged: true })).toMatchObject({ + status: 'This copy of Fake is damaged.', + action: { label: 'Reinstall' }, + prominent: true, + menuAction: null + }) const external = { source: 'external' as const } expect(shown(external)).toEqual([ 'Uses the copy of Fake found on this host.', diff --git a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts index 1dfe753fedc..5948cdebb60 100644 --- a/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts +++ b/src/renderer/src/components/managed-runtime/managed-runtime-install-view.ts @@ -13,6 +13,8 @@ export type ManagedRuntimeInstallView = { /** Why the last operation or status read failed. */ error: string | null action: ManagedRuntimeInstallAction | null + /** Update or Reinstall for a copy on disk, offered in the row's menu beside Remove. */ + menuAction: ManagedRuntimeInstallAction | null /** The action is the first step to using the agent, so Settings draws it as a button. */ prominent: boolean /** Settings may offer to delete the copy Orca installed: one is there and nothing runs. */ @@ -30,6 +32,7 @@ const EMPTY: ManagedRuntimeInstallView = { progress: null, error: null, action: null, + menuAction: null, prominent: false, removable: false, removing: false, @@ -87,12 +90,14 @@ function settledView(state: ManagedRuntimeInstallState, agent: string): ManagedR const view = ( status: string | null, label: string | null, - note: string | null = null + note: string | null = null, + menuLabel: string | null = null ): ManagedRuntimeInstallView => ({ ...EMPTY, status, error, action: label === null ? null : { kind: 'install', label }, + menuAction: menuLabel === null ? null : { kind: 'install', label: menuLabel }, removable: state.canRemove === true, note }) @@ -111,11 +116,16 @@ function settledView(state: ManagedRuntimeInstallState, agent: string): ManagedR ) } if (state.installedVersion !== null) { - // A healthy current copy needs nothing; Remove is its only action. - const label = state.installedCurrent - ? null - : translate('components.managed-runtime.update', 'Update') - return view(translate('components.managed-runtime.installed', 'Installed.'), label) + const reinstall = translate('components.managed-runtime.reinstall', 'Reinstall {{agent}}', { + agent + }) + // Reinstalling a current copy checks it and repairs what's missing, so it needs the pin. + const label = !state.installedCurrent + ? translate('components.managed-runtime.update', 'Update') + : state.available + ? reinstall + : null + return view(translate('components.managed-runtime.installed', 'Installed'), null, null, label) } if (!state.available) { return view( @@ -128,12 +138,14 @@ function settledView(state: ManagedRuntimeInstallState, agent: string): ManagedR ) } if (state.damaged) { - return view( - translate('components.managed-runtime.damaged', 'This copy of {{agent}} is damaged.', { - agent - }), - translate('components.managed-runtime.reinstall', 'Reinstall') + const status = translate( + 'components.managed-runtime.damaged', + 'This copy of {{agent}} is damaged.', + { agent } ) + // The repair is the next step, so it is the row's button rather than a menu item. + const label = translate('components.managed-runtime.reinstallShort', 'Reinstall') + return { ...view(status, label), prominent: true } } const retry = translate('components.managed-runtime.tryAgain', 'Try again') if (operation?.kind === 'install' && operation.phase === 'cancelled') { diff --git a/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts b/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts index 3e6250afda6..a5c8587e44d 100644 --- a/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts @@ -13,6 +13,7 @@ type AvailabilityCopyId = Extract< | 'claudeManagedNotSignedIn' | 'codexSystemNotSignedIn' | 'codexManagedNotSignedIn' + | 'antigravityNotSignedIn' | 'agentCommandNotSignedIn' | 'interactiveAgentNotSignedIn' | 'agentNotSignedIn' @@ -69,6 +70,12 @@ export const AVAILABILITY_PIECES: Record< COPY.codexManagedNotSignedIn, values ), + antigravityNotSignedIn: (values) => + translate( + 'components.native-chat.failureWords.antigravityNotSignedIn', + COPY.antigravityNotSignedIn, + values + ), cliMissing: (values) => translate('components.native-chat.failureWords.cliMissing', COPY.cliMissing, values), codexCliTooOld: (values) => diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx index c6596a9732a..2dbb12deb53 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx @@ -10,7 +10,9 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AntigravityAccountState } from '../../../../shared/antigravity-account-types' vi.mock('@/runtime/runtime-antigravity-accounts-client', () => ({ - callAntigravityAccounts: vi.fn() + callAntigravityAccounts: vi.fn(), + callAntigravityChatSignIn: vi.fn(), + subscribeAntigravityChatSignIn: vi.fn() })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx index ce3c36d5359..37d71c87530 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -15,6 +15,12 @@ import { Button } from '../ui/button' import { Badge } from '../ui/badge' import { ManagedRuntimeInstallRow } from '../managed-runtime/ManagedRuntimeInstallRow' import { useAppStore } from '../../store' +import { antigravityChatSignInRunning } from '@/runtime/antigravity-chat-sign-in-status' +import { + AntigravityChatSignInRow, + antigravityChatSignInBlocked, + useAntigravityChatSignIn +} from './AntigravityChatSignInRow' export function AntigravityAccountsSection({ owner, @@ -48,6 +54,9 @@ export function AntigravityAccountsSection({ const nativeChat = useAppStore((s) => s.settings?.experimentalNativeChat === true) const chats = nativeChat && runtime !== 'wsl' const chatRuntime = useManagedRuntimeInstall(owner, 'antigravity', chats) + // A remote host's sign-in finishes from the address pasted back into its row. + const chatSignIn = chats && chatRuntime.kind === 'active' + const signIn = useAntigravityChatSignIn(chatSignIn ? owner : null) useEffect(() => { mounted.current = true @@ -288,25 +297,37 @@ export function AntigravityAccountsSection({ )}

{chats && chatRuntime.kind === 'active' ? ( - - {(line, button) => ( -
-
-

- {translate( - 'settings.chat.providers.antigravityDescription', - 'Antigravity chats run a server Orca downloads to this host, separate from the agy CLI.' - )} -

- {line} +
+ + {(line, actions) => ( +
+
+

+ {translate( + 'settings.chat.providers.antigravityDescription', + 'Antigravity chats run a server Orca downloads to this host, separate from the agy CLI.' + )} +

+ {line} +
+ {actions}
- {button} -
- )} - + )} + + +
) : null} ) diff --git a/src/renderer/src/components/settings/AntigravityChatSignInRow.test.tsx b/src/renderer/src/components/settings/AntigravityChatSignInRow.test.tsx new file mode 100644 index 00000000000..8d4393bb8c0 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityChatSignInRow.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, expect, it, vi } from 'vitest' +import type { + AntigravityChatAuth, + AntigravityChatSignInState +} from '../../../../shared/antigravity-account-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { callAntigravityChatSignIn } from '@/runtime/runtime-antigravity-accounts-client' +import { AntigravityChatSignInRow } from './AntigravityChatSignInRow' +import { TooltipProvider } from '../ui/tooltip' + +const url = 'https://accounts.google.com/o/oauth2/v2/auth?state=s' +type FakeHost = { + revision: number + auth: AntigravityChatAuth + flow: AntigravityChatSignInState | null + streams: Set<(status: unknown) => void> +} +// Each host's own sign-in status, pushed to its subscribers by revision as the host does. +const fake = vi.hoisted(() => { + const hosts = new Map() + const hostOf = (owner: RuntimeClientTarget): FakeHost => { + const key = owner.kind === 'local' ? '' : owner.environmentId + let host = hosts.get(key) + if (!host) { + host = { revision: 0, auth: 'unknown', flow: null, streams: new Set() } + hosts.set(key, host) + } + return host + } + const status = (host: FakeHost) => ({ + revision: host.revision, + auth: host.auth, + flow: host.flow + }) + const publish = ( + owner: RuntimeClientTarget, + flow: AntigravityChatSignInState | null, + auth: AntigravityChatAuth = 'unknown' + ) => { + const host = hostOf(owner) + Object.assign(host, { revision: host.revision + 1, flow, auth }) + host.streams.forEach((emit) => emit(status(host))) + } + const api: { + hostOf: typeof hostOf + status: typeof status + publish: typeof publish + start: Promise<{ flowId: string }> | null + } = { hostOf, status, publish, start: null } + return api +}) +const { publish } = fake +vi.mock('@/runtime/runtime-antigravity-accounts-client', () => ({ + callAntigravityChatSignIn: vi.fn(async (owner: RuntimeClientTarget, action: string) => { + if (action === 'Start') { + if (fake.start) { + return fake.start + } + fake.publish(owner, { flowId: 'f', phase: 'starting' }) + return { flowId: 'f' } + } + return undefined + }), + subscribeAntigravityChatSignIn: vi.fn( + async (owner: RuntimeClientTarget, handlers: { onEvent: (status: unknown) => void }) => { + const host = fake.hostOf(owner) + host.streams.add(handlers.onEvent) + handlers.onEvent(fake.status(host)) + return { unsubscribe: () => host.streams.delete(handlers.onEvent) } + } + ) +})) +afterEach(() => cleanup()) + +async function showRow(owner: RuntimeClientTarget) { + const view = render( + + + + ) + // Sign in stays disabled until the host's snapshot lands. + await act(async () => undefined) + return view +} + +it('opens the link once across status answers and shows signed in', async () => { + const openUrl = vi.fn(async () => undefined) + Object.assign(window, { api: { shell: { openUrl } } }) + const owner = { kind: 'local' } as const + publish(owner, null, 'signedOut') + const view = await showRow(owner) + await screen.findByText('Not signed in on this computer.') + await userEvent.click(screen.getByRole('button', { name: 'Sign in' })) + await act(async () => publish(owner, { flowId: 'f', phase: 'waiting', url })) + await screen.findByText('Finish signing in in your browser.') + await act(async () => publish(owner, { flowId: 'f', phase: 'waiting', url })) + // The browser's sign-in is checked with a session before it counts. + await act(async () => publish(owner, { flowId: 'f', phase: 'verifying' })) + await screen.findByText('Finishing sign-in…') + await act(async () => publish(owner, { flowId: 'f', phase: 'signedIn' }, 'signedIn')) + await screen.findByText('Signed in on this computer.') + expect(screen.getByRole('button', { name: 'Change account' })).toBeTruthy() + expect(openUrl).toHaveBeenCalledExactlyOnceWith(url) + view.unmount() +}) + +it('opens nothing for a Start the host answers after the row unmounts', async () => { + const start = Promise.withResolvers<{ flowId: string }>() + fake.start = start.promise + const openUrl = vi.fn(async () => undefined) + Object.assign(window, { api: { shell: { openUrl } } }) + const owner = { kind: 'environment', environmentId: 'unmounted' } as const + const view = await showRow(owner) + await userEvent.click(view.getByRole('button', { name: 'Sign in' })) + view.unmount() + await act(async () => { + start.resolve({ flowId: 'late' }) + publish(owner, { flowId: 'late', phase: 'waiting', url }) + }) + expect(openUrl).not.toHaveBeenCalled() + fake.start = null +}) + +it("shows another client's sign-in without letting this one act on it", async () => { + const owner = { kind: 'environment', environmentId: 'idle' } as const + publish(owner, { flowId: 'done', phase: 'cancelled' }) + await showRow(owner) + await screen.findByText('Sign-in cancelled.') + // Once the host answers, its sign-in replaces the settled flow's outcome. + await act(async () => publish(owner, { flowId: 'done', phase: 'cancelled' }, 'signedOut')) + await screen.findByText('Not signed in on this computer.') + await act(async () => publish(owner, { flowId: null, phase: 'waiting' })) + await screen.findByText('Sign-in is in progress in another client.') + expect(screen.getByRole('button', { name: 'Sign in' }).hasAttribute('disabled')).toBe(true) + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull() + expect(screen.queryByRole('textbox')).toBeNull() +}) + +it('sends the address pasted from a browser on another computer to the host', async () => { + const owner = { kind: 'environment', environmentId: 'paste' } as const + await showRow(owner) + await act(async () => publish(owner, { flowId: 'remote', phase: 'waiting', url })) + const continueButton = await screen.findByRole('button', { name: 'Continue' }) + const pasted = 'http://127.0.0.1:51234/?state=s&code=c' + await userEvent.type(screen.getByRole('textbox'), pasted) + await userEvent.click(continueButton) + expect(callAntigravityChatSignIn).toHaveBeenCalledWith(owner, 'Complete', { + flowId: 'remote', + callbackUrl: pasted + }) +}) diff --git a/src/renderer/src/components/settings/AntigravityChatSignInRow.tsx b/src/renderer/src/components/settings/AntigravityChatSignInRow.tsx new file mode 100644 index 00000000000..bbfda57cfbc --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityChatSignInRow.tsx @@ -0,0 +1,336 @@ +import { useEffect, useId, useState, useSyncExternalStore } from 'react' +import { Check, Copy } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback' +import { + antigravityChatSignInRunning as running, + cancelAntigravityChatSignIn, + completeAntigravityChatSignIn, + observeAntigravityChatSignIn, + readAntigravityChatSignIn, + startAntigravityChatSignIn, + subscribeAntigravityChatSignIns, + type AntigravityChatSignInView +} from '@/runtime/antigravity-chat-sign-in-status' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { + AntigravityChatAuth, + AntigravityChatSignInState +} from '../../../../shared/antigravity-account-types' +import type { ManagedRuntimeInstallObservation } from '@/runtime/use-managed-runtime-install' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' + +type Flow = AntigravityChatSignInState + +/** A running flow another client started: the host tells only its starter the id. */ +const otherClients = (flow: Flow | null): boolean => running(flow) && flow.flowId === null + +function authLine(auth: AntigravityChatAuth, host: string, signedOut: string): string | null { + if (auth === 'signedIn') { + return translate('accounts.antigravity.chatSignedInOn', 'Signed in on {{host}}.', { host }) + } + return auth === 'signedOut' ? signedOut : null +} + +function chatSignInLine( + flow: Flow | null, + auth: AntigravityChatAuth, + openFailed: boolean, + host: string, + signedOut: string +): string | null { + if (otherClients(flow)) { + return translate( + 'accounts.antigravity.chatOtherClient', + 'Sign-in is in progress in another client.' + ) + } + switch (flow?.phase) { + case 'starting': + return translate('accounts.antigravity.chatStarting', 'Starting sign-in…') + case 'waiting': + return openFailed + ? translate( + 'accounts.antigravity.chatOpenFailed', + 'Copy the link and open it in your browser.' + ) + : translate('accounts.antigravity.chatWaiting', 'Finish signing in in your browser.') + case 'verifying': + return translate('accounts.antigravity.chatVerifying', 'Finishing sign-in…') + // A settled flow only stands in until the host answers whether it is signed in. + case 'cancelled': + return ( + authLine(auth, host, signedOut) ?? + translate('accounts.antigravity.chatCancelled', 'Sign-in cancelled.') + ) + case 'expired': + return ( + authLine(auth, host, signedOut) ?? + translate('accounts.antigravity.chatExpired', 'Sign-in timed out. Try again.') + ) + case 'failed': + case 'signedIn': + case undefined: + return authLine(auth, host, signedOut) + } +} + +type SignInAction = 'retry' | 'changeAccount' | 'signIn' + +function signInAction( + flow: Flow | null, + auth: AntigravityChatAuth, + error: string | null +): SignInAction { + if ( + error || + flow?.phase === 'failed' || + flow?.phase === 'cancelled' || + flow?.phase === 'expired' + ) { + return 'retry' + } + return auth === 'signedIn' ? 'changeAccount' : 'signIn' +} + +const SIGN_IN_ACTION_LABELS: Record string> = { + retry: () => translate('accounts.antigravity.chatRetry', 'Retry sign-in'), + changeAccount: () => translate('accounts.antigravity.chatChangeAccount', 'Change account'), + signIn: () => translate('accounts.antigravity.chatSignIn', 'Sign in') +} + +const SETTLED_OPERATION_PHASES = new Set(['succeeded', 'failed', 'cancelled']) + +/** Sign-in runs the installed server, so it waits while the host reports nothing to run or a + * Remove still runs; an Update keeps the old copy running until it lands. The install row beside + * it says why. A status the host could not report leaves it to the host. */ +export function antigravityChatSignInBlocked( + install: Extract +): boolean { + const { snapshot } = install + if (snapshot.kind !== 'known') { + return snapshot.kind === 'unknown' + } + const { source, operation } = snapshot.state + return !source || (operation?.kind === 'remove' && !SETTLED_OPERATION_PHASES.has(operation.phase)) +} + +const NOT_FOLLOWED: AntigravityChatSignInView = { + status: 'unknown', + flow: null, + auth: 'unknown', + error: null, + openFailed: false +} + +/** The host's chat sign-in, followed while `owner` is set; the install row beside it reads it too. */ +export function useAntigravityChatSignIn( + owner: RuntimeClientTarget | null +): AntigravityChatSignInView { + const shown = owner !== null + const environmentId = owner?.kind === 'environment' ? owner.environmentId : null + useEffect( + () => + shown + ? observeAntigravityChatSignIn( + environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId } + ) + : undefined, + [shown, environmentId] + ) + const read = (): AntigravityChatSignInView => + owner ? readAntigravityChatSignIn(owner) : NOT_FOLLOWED + return useSyncExternalStore(subscribeAntigravityChatSignIns, read, read) +} + +/** Google's last page points at the host, which a browser on another computer can't reach, so its + * address is pasted here and the host passes it on. */ +export function PastedAddressForm(props: { + pending: boolean + send: (callbackUrl: string, refused: (message: string) => void) => void +}): React.JSX.Element { + const [pasted, setPasted] = useState('') + // The host's refusal of this address; the form leaves once the host takes one. + const [refusal, setRefusal] = useState(null) + const helpId = useId() + const callbackUrl = pasted.trim() + // noValidate: the browser's URL check would pre-empt the host's refusal, which says what's wrong. + return ( +
{ + event.preventDefault() + if (callbackUrl) { + props.send(callbackUrl, setRefusal) + } + }} + > +

+ {translate( + 'accounts.antigravity.chatPasteHelp', + "If the last page after signing in doesn't load, copy its full address from your browser and paste it here." + )} +

+
+ { + setPasted(event.target.value) + setRefusal(null) + }} + /> + +
+ {refusal !== null && ( +

+ {refusal} +

+ )} +
+ ) +} + +/** One host's chat sign-in as both Settings layouts show it. `signedOut` is the line for a host + * that answered it is not signed in. */ +export function useAntigravityChatSignInControls(props: { + owner: RuntimeClientTarget + label: string + signedOut: string + disabled: boolean +}) { + const { owner, label } = props + const { status, flow, auth, openFailed, ...view } = useAntigravityChatSignIn(owner) + // One start, cancel or paste at a time, so a double click asks the host once. + const [pending, setPending] = useState(false) + const act = (request: () => Promise): void => { + setPending(true) + void request().finally(() => setPending(false)) + } + const copy = useClipboardTextCopyFeedback(flow?.url ?? '') + const failed = translate('accounts.antigravity.chatFailed', 'Sign-in failed. Try again.') + const error = view.error === null ? null : view.error || failed + const copyFailed = translate('accounts.antigravity.chatCopyFailed', 'Could not copy the link.') + const readFailed = translate( + 'accounts.antigravity.chatReadFailed', + 'Could not check the sign-in on {{host}}.', + { host: label } + ) + const ownFlow = running(flow) && flow.flowId !== null + const action = signInAction(flow, auth, error) + return { + flow, + ownFlow, + /** The browser's link, while this client's sign-in waits on it. */ + url: ownFlow && flow?.phase === 'waiting' ? (flow.url ?? null) : null, + line: + status === 'known' ? chatSignInLine(flow, auth, openFailed, label, props.signedOut) : null, + alert: + error ?? + (status === 'readFailed' ? readFailed : null) ?? + (flow?.phase === 'failed' ? (flow.error ?? failed) : null) ?? + (copy.status === 'failed' ? copyFailed : null), + action, + actionLabel: SIGN_IN_ACTION_LABELS[action](), + pending, + copy, + copyLabel: translate('accounts.antigravity.chatCopyLink', 'Copy sign-in link'), + signInDisabled: props.disabled || pending || status !== 'known' || otherClients(flow), + start: () => act(() => startAntigravityChatSignIn(owner)), + cancel: () => act(() => cancelAntigravityChatSignIn(owner)), + send: (callbackUrl: string, refused: (message: string) => void) => + act(async () => { + const refusal = await completeAntigravityChatSignIn(owner, callbackUrl) + if (refusal !== null) { + refused(refusal || failed) + } + }) + } +} + +/** This execution host's sign-in for Antigravity chats, run by the host's own server. Every row + * for a host shows the same sign-in, a running one included. */ +export function AntigravityChatSignInRow(props: { + owner: RuntimeClientTarget + label: string + description: string + disabled: boolean +}): React.JSX.Element { + const signIn = useAntigravityChatSignInControls({ + owner: props.owner, + label: props.label, + signedOut: translate('accounts.antigravity.chatNotSignedIn', 'Not signed in on {{host}}.', { + host: props.label + }), + disabled: props.disabled + }) + const { line, alert, copy, copyLabel, url } = signIn + return ( +
+
+
+

+ {translate('accounts.antigravity.chatTitle', 'Antigravity chats')} +

+ {props.description ? ( +

{props.description}

+ ) : null} + {/* The line keeps its height until the host answers, so nothing shifts when it does. */} +

+ {line} + {line && alert ? ' ' : null} + {alert && ( + + {alert} + + )} +

+
+ {signIn.ownFlow ? ( +
+ {url && ( + + + + + {copyLabel} + + )} + +
+ ) : ( + + )} +
+ {url && } +
+ ) +} diff --git a/src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx b/src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx new file mode 100644 index 00000000000..468589cd82d --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx @@ -0,0 +1,93 @@ +import { Check, Copy } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { ManagedRuntimeInstallObservation } from '@/runtime/use-managed-runtime-install' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { LoadingSpinner } from '../LoadingSpinner' +import { Button } from '../ui/button' +import { + PastedAddressForm, + antigravityChatSignInBlocked, + useAntigravityChatSignInControls +} from './AntigravityChatSignInRow' + +type ActiveInstall = Extract + +const CHANGING_PHASES = new Set(['downloading', 'extracting', 'verifying', 'removing']) + +/** Sign-in needs a copy to run, so it stays away while there is none or one is being installed or + * removed; the install line says which. Until the host answers, it holds its place. */ +export function antigravityProviderSignInShown(install: ActiveInstall): boolean { + const { snapshot } = install + if (snapshot.kind !== 'known') { + return true + } + const { source, operation } = snapshot.state + return source !== null && !(operation && CHANGING_PHASES.has(operation.phase)) +} + +/** Settings › Chat › Providers' sign-in for Antigravity chats, under its row. */ +export function AntigravityProviderSignInArea(props: { + owner: RuntimeClientTarget + label: string + install: ActiveInstall +}): React.JSX.Element { + const signIn = useAntigravityChatSignInControls({ + owner: props.owner, + label: props.label, + signedOut: translate( + 'settings.chat.providers.antigravityNotSignedIn', + 'Not signed in. Chats need an Antigravity sign-in, separate from your agy terminal account.' + ), + disabled: antigravityChatSignInBlocked(props.install) + }) + const { alert, copy, copyLabel, ownFlow, url } = signIn + // 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 ( + // Indented to the provider's name, past its icon. +
+ {/* The line keeps its height until the host answers, so nothing shifts when it does. */} +
+
+ {ownFlow ? : null} + + {line} + {line && alert ? ' ' : null} + {alert && ( + + {alert} + + )} + +
+ {ownFlow ? ( + <> + {url && ( + + )} + + + ) : ( + + )} +
+ {url && } +
+ ) +} diff --git a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx index 8ec00a7e187..5cae155414f 100644 --- a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx @@ -25,6 +25,13 @@ import { ManagedRuntimeInstallRow } from '../managed-runtime/ManagedRuntimeInsta 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' const enqueueProviderUpdate = createAgentSettingsUpdateQueue() @@ -98,6 +105,12 @@ function ProviderDetail(props: { enabledInAgents: boolean }): 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 summary = (line: React.ReactNode): React.JSX.Element => (
{props.label}
@@ -114,17 +127,36 @@ function ProviderDetail(props: { ) } return ( - - {(line, button) => ( + + {(line, actions) => ( <> {summary(line)} - {button} + {actions} )} ) } +/** Antigravity chats sign in on this computer, directly below the program they run. */ +function AntigravityChatProviderSignIn(): React.JSX.Element | null { + const runtime = useManagedRuntimeInstall(CHAT_HOST, 'antigravity', true) + if (runtime.kind !== 'active' || !antigravityProviderSignInShown(runtime)) { + return null + } + return ( + + ) +} + export function NativeChatProvidersSetting({ settings, updateSettings, @@ -159,34 +191,41 @@ export function NativeChatProvidersSetting({ const enabledInAgents = isTuiAgentEnabled(agent, settings.disabledTuiAgents) const on = isNativeChatProviderDisplayedOn(settings, agent, detectedIds) return ( -
-
- +
+
+
+ +
+ + { + const next = !on + return enqueueProviderUpdate({ + getSettings: () => useAppStore.getState().settings, + fallbackSettings: settings, + updateSettings, + buildUpdate: (latest) => + buildNativeChatProviderUpdate(latest, agent, next) + }) + }} + />
- - { - const next = !on - return enqueueProviderUpdate({ - getSettings: () => useAppStore.getState().settings, - fallbackSettings: settings, - updateSettings, - buildUpdate: (latest) => buildNativeChatProviderUpdate(latest, agent, next) - }) - }} - /> + {/* Chats sign in on the host they run on, under the program that serves them. */} + {agent === 'antigravity' && enabledInAgents ? ( + + ) : null}
) })} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 909bb04264e..a9739ece499 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2902,6 +2902,7 @@ "agentCommandNotSignedIn": "Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.", "agentNotSignedIn": "Sign in to {{agent}}.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", + "antigravityNotSignedIn": "Antigravity isn't signed in. Sign in under Settings → Accounts → Antigravity.", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", "argumentsPositionalPrompt": "Saved Arguments include a prompt.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91bdd78b50c..bb460b5ba0d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -216,7 +216,8 @@ "notInstalled": "Not installed", "install": "Install", "toggleLabel": "Open {{value0}} as a chat", - "antigravityDescription": "Antigravity chats run a server Orca downloads to this host, separate from the agy CLI." + "antigravityDescription": "Antigravity chats run a server Orca downloads to this host, separate from the agy CLI.", + "antigravityNotSignedIn": "Not signed in. Chats need an Antigravity sign-in, separate from your agy terminal account." } }, "terminal": { @@ -18320,6 +18321,7 @@ "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", "codexSystemNotSignedIn": "Codex isn't signed in. Run `{{loginCommand}}`.", "codexManagedNotSignedIn": "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", + "antigravityNotSignedIn": "Antigravity isn't signed in. Sign in under Settings → Accounts → Antigravity.", "cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", "signInFirst": "Sign in first.", "signInThenRunCommand": "Sign in, then run /{{command}} again.", @@ -19467,12 +19469,14 @@ "cancelFailed": "Couldn't cancel the installation. Try again.", "install": "Install {{agent}}", "installManaged": "Install Orca's copy", - "installed": "Installed.", + "installed": "Installed", "notInstalled": "Not installed.", "progress": "{{agent}} download", "readFailed": "Could not check {{agent}} on this host. Retrying.", - "reinstall": "Reinstall", + "reinstall": "Reinstall {{agent}}", + "reinstallShort": "Reinstall", "remove": "Remove downloaded runtime", + "moreActions": "More actions for {{agent}}", "removeConfirm": "Remove", "removeDescription": "{{agent}} chats that are running stop. Their history is kept.", "removeKeep": "Cancel", @@ -20010,6 +20014,27 @@ "description": "Agents there use the distro's default Claude and Codex accounts until Orca in WSL is updated." }, "antigravity": { + "chatTitle": "Antigravity chats", + "chatDescription": "Chats use their own Antigravity sign-in, separate from your agy account above. Sign in once on this device.", + "chatSignIn": "Sign in", + "chatChangeAccount": "Change account", + "chatRetry": "Retry sign-in", + "chatCancel": "Cancel", + "chatCopyLink": "Copy sign-in link", + "chatCopyFailed": "Could not copy the link.", + "chatStarting": "Starting sign-in…", + "chatWaiting": "Finish signing in in your browser.", + "chatOpenFailed": "Copy the link and open it in your browser.", + "chatVerifying": "Finishing sign-in…", + "chatPasteHelp": "If the last page after signing in doesn't load, copy its full address from your browser and paste it here.", + "chatContinue": "Continue", + "chatSignedInOn": "Signed in on {{host}}.", + "chatCancelled": "Sign-in cancelled.", + "chatExpired": "Sign-in timed out. Try again.", + "chatFailed": "Sign-in failed. Try again.", + "chatNotSignedIn": "Not signed in on {{host}}.", + "chatOtherClient": "Sign-in is in progress in another client.", + "chatReadFailed": "Could not check the sign-in on {{host}}.", "scope": "Manage the native agy account on {{host}}.", "signIn": "Start agy on this host and complete its browser sign-in, then save the current account. To add a different account, use /logout in agy and sign in again.", "docs": "Sign-in instructions", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 30127b10cfd..089da9f8916 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -39,7 +39,8 @@ }, "providers": { "title": "Proveedores", - "antigravityDescription": "Los chats de Antigravity usan un servidor que Orca descarga en este host, independiente de la CLI de agy." + "antigravityDescription": "Los chats de Antigravity usan un servidor que Orca descarga en este host, independiente de la CLI de agy.", + "antigravityNotSignedIn": "No has iniciado sesión. Los chats necesitan un inicio de sesión de Antigravity, independiente de tu cuenta de agy en la terminal." } }, "terminal": { @@ -18060,6 +18061,7 @@ "claudeManagedNotSignedIn": "Esta cuenta de Claude no ha iniciado sesión. Vuelve a iniciar sesión en la configuración de Cuentas de Claude.", "codexSystemNotSignedIn": "Codex no ha iniciado sesión. Ejecuta `{{loginCommand}}`.", "codexManagedNotSignedIn": "Esta cuenta de Codex no ha iniciado sesión. Vuelve a iniciar sesión en la configuración de Cuentas de Codex.", + "antigravityNotSignedIn": "Antigravity no ha iniciado sesión. Inicia sesión en Ajustes → Cuentas → Antigravity.", "cliMissing": "No se encontró {{agent}} en el equipo que ejecuta este chat. Instálelo o revise su Comando en Ajustes → Agentes.", "signInFirst": "Inicie sesión primero.", "signInThenRunCommand": "Inicie sesión y vuelva a ejecutar /{{command}}.", @@ -19232,12 +19234,14 @@ "cancelFailed": "No se pudo cancelar la instalación. Inténtalo de nuevo.", "install": "Instalar {{agent}}", "installManaged": "Instalar la copia de Orca", - "installed": "Instalado.", + "installed": "Instalado", "notInstalled": "No instalado.", "progress": "Descarga de {{agent}}", "readFailed": "No se pudo comprobar {{agent}} en este host. Reintentando.", - "reinstall": "Reinstalar", + "reinstall": "Reinstalar {{agent}}", + "reinstallShort": "Reinstalar", "remove": "Quitar el runtime descargado", + "moreActions": "Más acciones para {{agent}}", "removeConfirm": "Quitar", "removeDescription": "Los chats de {{agent}} en curso se detienen. Su historial se conserva.", "removeKeep": "Cancelar", @@ -19675,6 +19679,27 @@ "cancel": "Cancelar" }, "antigravity": { + "chatTitle": "Chats de Antigravity", + "chatDescription": "Los chats usan su propio inicio de sesión de Antigravity, independiente de tu cuenta de agy de arriba. Inicia sesión una vez en este dispositivo.", + "chatSignIn": "Iniciar sesión", + "chatChangeAccount": "Cambiar de cuenta", + "chatRetry": "Reintentar inicio de sesión", + "chatCancel": "Cancelar", + "chatCopyLink": "Copiar enlace de inicio de sesión", + "chatCopyFailed": "No se pudo copiar el enlace.", + "chatStarting": "Iniciando sesión…", + "chatWaiting": "Termina de iniciar sesión en tu navegador.", + "chatOpenFailed": "Copia el enlace y ábrelo en tu navegador.", + "chatVerifying": "Finalizando el inicio de sesión…", + "chatPasteHelp": "Si la última página después de iniciar sesión no carga, copia su dirección completa desde el navegador y pégala aquí.", + "chatContinue": "Continuar", + "chatSignedInOn": "Sesión iniciada en {{host}}.", + "chatCancelled": "Inicio de sesión cancelado.", + "chatExpired": "El inicio de sesión caducó. Inténtalo de nuevo.", + "chatFailed": "No se pudo iniciar sesión. Inténtalo de nuevo.", + "chatNotSignedIn": "No has iniciado sesión en {{host}}.", + "chatOtherClient": "Hay un inicio de sesión en curso en otro cliente.", + "chatReadFailed": "No se pudo comprobar el inicio de sesión en {{host}}.", "retry": "Reintentar", "remove": "Eliminar", "usage": "Actualizar uso", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 727e00d12f5..5e7994b4737 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -143,7 +143,8 @@ }, "providers": { "title": "Fournisseurs", - "antigravityDescription": "Les chats Antigravity utilisent un serveur qu’Orca télécharge sur cet hôte, distinct de la CLI agy." + "antigravityDescription": "Les chats Antigravity utilisent un serveur qu’Orca télécharge sur cet hôte, distinct de la CLI agy.", + "antigravityNotSignedIn": "Non connecté. Les discussions nécessitent une connexion Antigravity, distincte de votre compte agy du terminal." } }, "terminal": { @@ -18169,6 +18170,7 @@ "claudeManagedNotSignedIn": "Ce compte Claude n’est pas connecté. Reconnectez-vous dans les paramètres des Comptes Claude.", "codexSystemNotSignedIn": "Codex n’est pas connecté. Exécutez `{{loginCommand}}`.", "codexManagedNotSignedIn": "Ce compte Codex n’est pas connecté. Reconnectez-vous dans les paramètres des Comptes Codex.", + "antigravityNotSignedIn": "Antigravity n’est pas connecté. Connectez-vous dans Paramètres → Comptes → Antigravity.", "cliMissing": "{{agent}} est introuvable sur l’ordinateur qui exécute cette conversation. Installez-le ou vérifiez sa Commande dans Paramètres → Agents.", "signInFirst": "Connectez-vous d'abord.", "signInThenRunCommand": "Connectez-vous, puis relancez /{{command}}.", @@ -19348,12 +19350,14 @@ "cancelFailed": "Impossible d'annuler l'installation. Réessayez.", "install": "Installer {{agent}}", "installManaged": "Installer la copie d'Orca", - "installed": "Installé.", + "installed": "Installé", "notInstalled": "Non installé.", "progress": "Téléchargement de {{agent}}", "readFailed": "Impossible de vérifier {{agent}} sur cet hôte. Nouvel essai en cours.", - "reinstall": "Réinstaller", + "reinstall": "Réinstaller {{agent}}", + "reinstallShort": "Réinstaller", "remove": "Supprimer le runtime téléchargé", + "moreActions": "Plus d'actions pour {{agent}}", "removeConfirm": "Supprimer", "removeDescription": "Les chats {{agent}} en cours s'arrêtent. Leur historique est conservé.", "removeKeep": "Annuler", @@ -19828,6 +19832,27 @@ "cancel": "Annuler" }, "antigravity": { + "chatTitle": "Discussions Antigravity", + "chatDescription": "Les discussions utilisent leur propre connexion Antigravity, distincte de votre compte agy ci-dessus. Connectez-vous une seule fois sur cet appareil.", + "chatSignIn": "Se connecter", + "chatChangeAccount": "Changer de compte", + "chatRetry": "Réessayer la connexion", + "chatCancel": "Annuler", + "chatCopyLink": "Copier le lien de connexion", + "chatCopyFailed": "Impossible de copier le lien.", + "chatStarting": "Démarrage de la connexion…", + "chatWaiting": "Terminez la connexion dans votre navigateur.", + "chatOpenFailed": "Copiez le lien et ouvrez-le dans votre navigateur.", + "chatVerifying": "Finalisation de la connexion…", + "chatPasteHelp": "Si la dernière page après la connexion ne se charge pas, copiez son adresse complète depuis votre navigateur et collez-la ici.", + "chatContinue": "Continuer", + "chatSignedInOn": "Connecté sur {{host}}.", + "chatCancelled": "Connexion annulée.", + "chatExpired": "La connexion a expiré. Réessayez.", + "chatFailed": "La connexion a échoué. Réessayez.", + "chatNotSignedIn": "Non connecté sur {{host}}.", + "chatOtherClient": "Une connexion est en cours dans un autre client.", + "chatReadFailed": "Impossible de vérifier la connexion sur {{host}}.", "scope": "Gérez le compte agy natif sur {{host}}.", "signIn": "Démarrez agy sur cet hôte et complétez la connexion au navigateur, puis enregistrez le compte actuel. Pour ajouter un autre compte, utilisez /logout dans agy et reconnectez-vous.", "docs": "Instructions de connexion", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6e241475cf3..3c0675ee201 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -39,7 +39,8 @@ }, "providers": { "title": "プロバイダー", - "antigravityDescription": "Antigravity チャットは、Orca がこのホストにダウンロードするサーバーで動作します。agy CLI とは別です。" + "antigravityDescription": "Antigravity チャットは、Orca がこのホストにダウンロードするサーバーで動作します。agy CLI とは別です。", + "antigravityNotSignedIn": "サインインしていません。チャットには、ターミナルの agy アカウントとは別の Antigravity サインインが必要です。" } }, "terminal": { @@ -18100,6 +18101,7 @@ "claudeManagedNotSignedIn": "この Claude アカウントにサインインしていません。設定の Claude アカウントで再度サインインしてください。", "codexSystemNotSignedIn": "Codex にサインインしていません。`{{loginCommand}}` を実行してください。", "codexManagedNotSignedIn": "この Codex アカウントにサインインしていません。設定の Codex アカウントで再度サインインしてください。", + "antigravityNotSignedIn": "Antigravity にサインインしていません。[設定] → [アカウント] → [Antigravity] でサインインしてください。", "cliMissing": "このチャットを実行しているコンピューターで {{agent}} が見つかりませんでした。インストールするか、[設定] → [Agent] の [コマンド] を確認してください。", "signInFirst": "先にサインインしてください。", "signInThenRunCommand": "サインインしてから、/{{command}} をもう一度実行してください。", @@ -19244,12 +19246,14 @@ "cancelFailed": "インストールをキャンセルできませんでした。もう一度お試しください。", "install": "{{agent}} をインストール", "installManaged": "Orca のコピーをインストール", - "installed": "インストール済みです。", + "installed": "インストール済み", "notInstalled": "インストールされていません。", "progress": "{{agent}} のダウンロード", "readFailed": "このホストの {{agent}} を確認できませんでした。再試行しています。", - "reinstall": "再インストール", + "reinstall": "{{agent}} を再インストール", + "reinstallShort": "再インストール", "remove": "ダウンロードしたランタイムを削除", + "moreActions": "{{agent}}の追加操作", "removeConfirm": "削除", "removeDescription": "実行中の {{agent}} チャットは停止します。履歴は保持されます。", "removeKeep": "キャンセル", @@ -19828,6 +19832,27 @@ "cancel": "キャンセル" }, "antigravity": { + "chatTitle": "Antigravity チャット", + "chatDescription": "チャットは上の agy アカウントとは別の、独自の Antigravity サインインを使用します。このデバイスで一度サインインしてください。", + "chatSignIn": "サインイン", + "chatChangeAccount": "アカウントを変更", + "chatRetry": "サインインを再試行", + "chatCancel": "キャンセル", + "chatCopyLink": "サインインリンクをコピー", + "chatCopyFailed": "リンクをコピーできませんでした。", + "chatStarting": "サインインを開始しています…", + "chatWaiting": "ブラウザーでサインインを完了してください。", + "chatOpenFailed": "リンクをコピーしてブラウザーで開いてください。", + "chatVerifying": "サインインを完了しています…", + "chatPasteHelp": "サインイン後の最後のページが読み込まれない場合は、ブラウザーからそのページの完全なアドレスをコピーしてここに貼り付けてください。", + "chatContinue": "続行", + "chatSignedInOn": "{{host}} でサインイン済みです。", + "chatCancelled": "サインインをキャンセルしました。", + "chatExpired": "サインインがタイムアウトしました。もう一度お試しください。", + "chatFailed": "サインインに失敗しました。もう一度お試しください。", + "chatNotSignedIn": "{{host}} でサインインしていません。", + "chatOtherClient": "別のクライアントでサインインが進行中です。", + "chatReadFailed": "{{host}} のサインインを確認できませんでした。", "scope": "{{host}} でネイティブ agy アカウントを管理します。", "signIn": "このホストで agy を起動し、ブラウザのサインインを完了して、現在のアカウントを保存します。別のアカウントを追加するには、agy で /logout を使用し、再度サインインします。", "docs": "サインイン手順", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3e3ac2c90ed..a710e5c2751 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -39,7 +39,8 @@ }, "providers": { "title": "공급자", - "antigravityDescription": "Antigravity 채팅은 Orca가 이 호스트에 다운로드하는 서버에서 실행되며, agy CLI와는 별개입니다." + "antigravityDescription": "Antigravity 채팅은 Orca가 이 호스트에 다운로드하는 서버에서 실행되며, agy CLI와는 별개입니다.", + "antigravityNotSignedIn": "로그인되어 있지 않습니다. 채팅에는 터미널 agy 계정과 별개인 Antigravity 로그인이 필요합니다." } }, "terminal": { @@ -18100,6 +18101,7 @@ "claudeManagedNotSignedIn": "이 Claude 계정에 로그인하지 않았습니다. Claude 계정 설정에서 다시 로그인하세요.", "codexSystemNotSignedIn": "Codex에 로그인하지 않았습니다. `{{loginCommand}}`을 실행하세요.", "codexManagedNotSignedIn": "이 Codex 계정에 로그인하지 않았습니다. Codex 계정 설정에서 다시 로그인하세요.", + "antigravityNotSignedIn": "Antigravity에 로그인되어 있지 않습니다. 설정 → 계정 → Antigravity에서 로그인하세요.", "cliMissing": "이 채팅을 실행하는 컴퓨터에서 {{agent}}를 찾을 수 없습니다. 설치하거나 설정 → 에이전트에서 명령을 확인하세요.", "signInFirst": "먼저 로그인하세요.", "signInThenRunCommand": "로그인한 다음 /{{command}}을(를) 다시 실행하세요.", @@ -19244,12 +19246,14 @@ "cancelFailed": "설치를 취소하지 못했습니다. 다시 시도하세요.", "install": "{{agent}} 설치", "installManaged": "Orca 사본 설치", - "installed": "설치되었습니다.", + "installed": "설치됨", "notInstalled": "설치되지 않았습니다.", "progress": "{{agent}} 다운로드", "readFailed": "이 호스트에서 {{agent}}을(를) 확인할 수 없습니다. 다시 시도하는 중입니다.", - "reinstall": "재설치", + "reinstall": "{{agent}} 재설치", + "reinstallShort": "재설치", "remove": "다운로드한 런타임 제거", + "moreActions": "{{agent}}의 더 많은 작업", "removeConfirm": "제거", "removeDescription": "실행 중인 {{agent}} 채팅이 중지됩니다. 기록은 유지됩니다.", "removeKeep": "취소", @@ -19828,6 +19832,27 @@ "cancel": "취소" }, "antigravity": { + "chatTitle": "Antigravity 채팅", + "chatDescription": "채팅은 위의 agy 계정과 별개인 자체 Antigravity 로그인을 사용합니다. 이 기기에서 한 번 로그인하세요.", + "chatSignIn": "로그인", + "chatChangeAccount": "계정 변경", + "chatRetry": "로그인 다시 시도", + "chatCancel": "취소", + "chatCopyLink": "로그인 링크 복사", + "chatCopyFailed": "링크를 복사할 수 없습니다.", + "chatStarting": "로그인을 시작하는 중…", + "chatWaiting": "브라우저에서 로그인을 완료하세요.", + "chatOpenFailed": "링크를 복사해 브라우저에서 여세요.", + "chatVerifying": "로그인을 마무리하는 중…", + "chatPasteHelp": "로그인 후 마지막 페이지가 열리지 않으면 브라우저에서 그 페이지의 전체 주소를 복사해 여기에 붙여 넣으세요.", + "chatContinue": "계속", + "chatSignedInOn": "{{host}}에 로그인되어 있습니다.", + "chatCancelled": "로그인이 취소되었습니다.", + "chatExpired": "로그인 시간이 초과되었습니다. 다시 시도하세요.", + "chatFailed": "로그인하지 못했습니다. 다시 시도하세요.", + "chatNotSignedIn": "{{host}}에 로그인되어 있지 않습니다.", + "chatOtherClient": "다른 클라이언트에서 로그인이 진행 중입니다.", + "chatReadFailed": "{{host}}의 로그인을 확인할 수 없습니다.", "scope": "{{host}}의 agy 자체 계정을 관리합니다.", "signIn": "이 호스트에서 agy를 실행하고 브라우저 로그인을 완료한 후 현재 계정을 저장하세요. 다른 계정을 추가하려면 agy에서 /logout 명령을 실행하고 다시 로그인하세요.", "docs": "로그인 안내", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 64eab50aaee..6f9b51f6e05 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -39,7 +39,8 @@ }, "providers": { "title": "提供方", - "antigravityDescription": "Antigravity 聊天运行在 Orca 下载到此主机的服务器上,与 agy CLI 相互独立。" + "antigravityDescription": "Antigravity 聊天运行在 Orca 下载到此主机的服务器上,与 agy CLI 相互独立。", + "antigravityNotSignedIn": "未登录。聊天需要单独的 Antigravity 登录,与终端中的 agy 帐户分开。" } }, "terminal": { @@ -18065,6 +18066,7 @@ "claudeManagedNotSignedIn": "此 Claude 账户尚未登录。请在 Claude 账户设置中重新登录。", "codexSystemNotSignedIn": "Codex 尚未登录。请运行 `{{loginCommand}}`。", "codexManagedNotSignedIn": "此 Codex 账户尚未登录。请在 Codex 账户设置中重新登录。", + "antigravityNotSignedIn": "Antigravity 尚未登录。请在设置 → 账户 → Antigravity 中登录。", "cliMissing": "在运行此聊天的计算机上找不到 {{agent}}。请安装它,或检查设置 → 智能体中的命令。", "signInFirst": "请先登录。", "signInThenRunCommand": "请登录,然后重新运行 /{{command}}。", @@ -19244,12 +19246,14 @@ "cancelFailed": "无法取消安装。请重试。", "install": "安装 {{agent}}", "installManaged": "安装 Orca 的副本", - "installed": "已安装。", + "installed": "已安装", "notInstalled": "未安装。", "progress": "{{agent}} 下载", "readFailed": "无法检查此主机上的 {{agent}}。正在重试。", - "reinstall": "重新安装", + "reinstall": "重新安装 {{agent}}", + "reinstallShort": "重新安装", "remove": "移除已下载的运行时", + "moreActions": "{{agent}} 的更多操作", "removeConfirm": "移除", "removeDescription": "正在运行的 {{agent}} 聊天会停止。其历史记录会保留。", "removeKeep": "取消", @@ -19828,6 +19832,27 @@ "cancel": "取消" }, "antigravity": { + "chatTitle": "Antigravity 聊天", + "chatDescription": "聊天使用自己的 Antigravity 登录,与上方的 agy 帐户分开。在此设备上登录一次即可。", + "chatSignIn": "登录", + "chatChangeAccount": "更换帐户", + "chatRetry": "重试登录", + "chatCancel": "取消", + "chatCopyLink": "复制登录链接", + "chatCopyFailed": "无法复制链接。", + "chatStarting": "正在开始登录…", + "chatWaiting": "请在浏览器中完成登录。", + "chatOpenFailed": "复制链接并在浏览器中打开。", + "chatVerifying": "正在完成登录…", + "chatPasteHelp": "如果登录后的最后一个页面无法加载,请从浏览器复制它的完整地址并粘贴到这里。", + "chatContinue": "继续", + "chatSignedInOn": "已在 {{host}} 上登录。", + "chatCancelled": "已取消登录。", + "chatExpired": "登录超时。请重试。", + "chatFailed": "登录失败。请重试。", + "chatNotSignedIn": "未在 {{host}} 上登录。", + "chatOtherClient": "另一个客户端正在登录。", + "chatReadFailed": "无法检查 {{host}} 上的登录。", "scope": "管理 {{host}} 上的本机 agy 帐户。", "signIn": "在此主机上启动 agy 并完成其浏览器登录,然后保存当前帐户。要添加其他帐户,请在 agy 中使用 /logout 并再次登录。", "docs": "登录说明", diff --git a/src/renderer/src/runtime/antigravity-chat-sign-in-status.ts b/src/renderer/src/runtime/antigravity-chat-sign-in-status.ts new file mode 100644 index 00000000000..8be8b2efe0b --- /dev/null +++ b/src/renderer/src/runtime/antigravity-chat-sign-in-status.ts @@ -0,0 +1,189 @@ +import type { + AntigravityChatAuth, + AntigravityChatSignInState, + AntigravityChatSignInStatus +} from '../../../shared/antigravity-account-types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { + createRevisionedHostStateStore, + type RevisionedHostSnapshot +} from './revisioned-host-state-store' +import { + callAntigravityChatSignIn, + subscribeAntigravityChatSignIn +} from './runtime-antigravity-accounts-client' + +/** + * Each host's Antigravity chat sign-in, followed like an install's status: every Settings row for + * a host shows the same flow and the server's last sign-in answer, from another client's flow too. + */ + +export type AntigravityChatSignInView = { + /** Whether the host has answered; `readFailed` says nothing about the sign-in. */ + status: RevisionedHostSnapshot['kind'] + flow: AntigravityChatSignInState | null + auth: AntigravityChatAuth + /** Why this client's last request failed; empty when it gave no reason. */ + error: string | null + /** The browser could not open the link this client asked for. */ + openFailed: boolean +} + +type Notes = Pick +type Local = { + owner: RuntimeClientTarget + observers: number + /** The flow this client started while shown, whose link it opens once. */ + opens: string | null + starts: number + notes: Notes +} + +const NO_NOTES: Notes = { error: null, openFailed: false } +const hostKey = (owner: RuntimeClientTarget): string => + owner.kind === 'local' ? '' : owner.environmentId + +const signIns = createRevisionedHostStateStore({ + key: hostKey, + hostKey, + subscribe: subscribeAntigravityChatSignIn +}) +const locals = new Map() +type Shown = { + snapshot: RevisionedHostSnapshot + notes: Notes + view: AntigravityChatSignInView +} +const views = new Map() +const listeners = new Set<() => void>() + +export const antigravityChatSignInRunning = ( + flow: AntigravityChatSignInState | null +): flow is AntigravityChatSignInState => + flow?.phase === 'starting' || flow?.phase === 'waiting' || flow?.phase === 'verifying' + +const flowOf = (owner: RuntimeClientTarget): AntigravityChatSignInState | null => { + const snapshot = signIns.read(owner) + return snapshot.kind === 'known' ? snapshot.state.flow : null +} + +function localFor(owner: RuntimeClientTarget): Local { + let local = locals.get(hostKey(owner)) + if (!local) { + local = { owner, observers: 0, opens: null, starts: 0, notes: NO_NOTES } + locals.set(hostKey(owner), local) + } + return local +} + +function note(local: Local, patch: Partial): void { + local.notes = { ...local.notes, ...patch } + listeners.forEach((listener) => listener()) +} + +const message = (cause: unknown): string => (cause instanceof Error ? cause.message : '') + +/** Opens the link of a flow this client started, once, when the host first shows it. */ +function openStartedLinks(): void { + for (const local of locals.values()) { + const flow = flowOf(local.owner) + if (local.opens && flow?.flowId === local.opens && flow.phase === 'waiting' && flow.url) { + const { flowId } = flow + local.opens = null + void window.api.shell.openUrl(flow.url).catch(() => { + if (flowOf(local.owner)?.flowId === flowId) { + note(local, { openFailed: true }) + } + }) + } + } +} +signIns.subscribe(openStartedLinks) + +/** Starts a sign-in on the host, or rejoins this client's running one; the host's status shows it. */ +export async function startAntigravityChatSignIn(owner: RuntimeClientTarget): Promise { + const local = localFor(owner) + const ticket = ++local.starts + note(local, NO_NOTES) + try { + const { flowId } = await callAntigravityChatSignIn<{ flowId: string }>(owner, 'Start') + // A row that left before the host answered opens nothing; one shown later shows the flow. + if (ticket === local.starts) { + local.opens = local.observers > 0 ? flowId : null + openStartedLinks() + } + } catch (cause) { + if (ticket === local.starts) { + note(local, { error: message(cause) }) + } + } +} + +/** Only the client that started a flow is told its id, so only it can cancel or complete it. */ +const ownFlowId = (owner: RuntimeClientTarget): string | null => flowOf(owner)?.flowId ?? null + +export async function cancelAntigravityChatSignIn(owner: RuntimeClientTarget): Promise { + const flowId = ownFlowId(owner) + if (flowId) { + await callAntigravityChatSignIn(owner, 'Cancel', { flowId }).catch( + (cause: unknown) => note(localFor(owner), { error: message(cause) }) + ) + } +} + +/** Sends the address the browser ended on to the host, for a browser on another computer. + * Resolves to the host's refusal, empty when it gave no reason, or null once it took it. */ +export async function completeAntigravityChatSignIn( + owner: RuntimeClientTarget, + callbackUrl: string +): Promise { + const flowId = ownFlowId(owner) + if (!flowId) { + return '' + } + return callAntigravityChatSignIn(owner, 'Complete', { flowId, callbackUrl }).then( + () => null, + message + ) +} + +/** Follows the host's sign-in while a row shows it. Returns the release. */ +export function observeAntigravityChatSignIn(owner: RuntimeClientTarget): () => void { + const local = localFor(owner) + local.observers += 1 + const release = signIns.observe(owner) + return () => { + release() + local.observers -= 1 + if (local.observers === 0) { + local.opens = null + } + } +} + +export function readAntigravityChatSignIn(owner: RuntimeClientTarget): AntigravityChatSignInView { + const snapshot = signIns.read(owner) + const notes = locals.get(hostKey(owner))?.notes ?? NO_NOTES + const cached = views.get(hostKey(owner)) + if (cached?.snapshot === snapshot && cached.notes === notes) { + return cached.view + } + const known = snapshot.kind === 'known' ? snapshot.state : null + const view = { + status: snapshot.kind, + flow: known?.flow ?? null, + auth: known?.auth ?? 'unknown', + ...notes + } + views.set(hostKey(owner), { snapshot, notes, view }) + return view +} + +export function subscribeAntigravityChatSignIns(listener: () => void): () => void { + listeners.add(listener) + const unsubscribe = signIns.subscribe(listener) + return () => { + listeners.delete(listener) + unsubscribe() + } +} diff --git a/src/renderer/src/runtime/managed-runtime-install-status.ts b/src/renderer/src/runtime/managed-runtime-install-status.ts index 2285e4520e5..03b4458526b 100644 --- a/src/renderer/src/runtime/managed-runtime-install-status.ts +++ b/src/renderer/src/runtime/managed-runtime-install-status.ts @@ -1,13 +1,15 @@ import type { ManagedRuntimeInstallState } from '../../../shared/managed-runtime-install' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { + createRevisionedHostStateStore, + type RevisionedHostSnapshot +} from './revisioned-host-state-store' import { callRuntimeRpc } from './runtime-rpc-client' import { subscribeRuntimeRpc } from './runtime-rpc-subscribe' /** * What each host says about the runtime it installs for an agent, one entry per host runtime - * instance and agent, so Settings and a chat read the same answer. While any surface observes - * an entry, one subscription carries the host's pushed state, idle included, so another client's - * change shows up too; the last surface leaving ends it. + * instance and agent, so Settings and a chat read the same answer. */ export type ManagedRuntimeInstallSubject = { @@ -17,163 +19,27 @@ export type ManagedRuntimeInstallSubject = { agent: string } -export type ManagedRuntimeInstallSnapshot = - | { kind: 'unknown' } - /** No answer yet and the last read failed: says nothing about whether it is installed. */ - | { kind: 'readFailed' } - | { kind: 'known'; state: ManagedRuntimeInstallState } - -type Entry = { - subject: ManagedRuntimeInstallSubject - snapshot: ManagedRuntimeInstallSnapshot - observers: number - /** Bumped by every subscribe and every loss; a frame from another generation is stale. */ - generation: number - /** The generation the shown state came from: revisions only order one subscription's frames. */ - shownGeneration: number - stream: { unsubscribe: () => void } | null - retry: ReturnType | null - failures: number -} - -const UNKNOWN: ManagedRuntimeInstallSnapshot = { kind: 'unknown' } - -const entries = new Map() -const listeners = new Set<() => void>() +export type ManagedRuntimeInstallSnapshot = RevisionedHostSnapshot const hostAgentKey = ({ target, agent }: ManagedRuntimeInstallSubject): string => `${target.kind === 'local' ? '' : target.environmentId}\u0000${agent}` -const subjectKey = (subject: ManagedRuntimeInstallSubject): string => - `${hostAgentKey(subject)}\u0000${subject.runtimeId ?? ''}` -function setSnapshot(entry: Entry, snapshot: ManagedRuntimeInstallSnapshot): void { - entry.snapshot = snapshot - listeners.forEach((listener) => listener()) -} - -function entryFor(subject: ManagedRuntimeInstallSubject): Entry { - const key = subjectKey(subject) - let entry = entries.get(key) - if (!entry) { - // A restarted host runtime answers under a new key; the old one's answer is stale. - for (const [otherKey, other] of entries) { - if (other.observers === 0 && hostAgentKey(other.subject) === hostAgentKey(subject)) { - entries.delete(otherKey) - } - } - entry = { - subject, - snapshot: UNKNOWN, - observers: 0, - generation: 0, - shownGeneration: 0, - stream: null, - retry: null, - failures: 0 - } - entries.set(key, entry) - } - return entry -} - -function accept(entry: Entry, state: ManagedRuntimeInstallState, generation: number): void { - // A new subscription may reach a restarted host whose revisions start again at 0. - if ( - entry.snapshot.kind !== 'known' || - generation !== entry.shownGeneration || - entry.snapshot.state.revision < state.revision - ) { - entry.shownGeneration = generation - setSnapshot(entry, { kind: 'known', state }) - } -} - -const isInstallState = (value: unknown): value is ManagedRuntimeInstallState => - typeof value === 'object' && - value !== null && - 'revision' in value && - typeof value.revision === 'number' - -function close(entry: Entry): void { - entry.generation += 1 - entry.stream?.unsubscribe() - entry.stream = null - if (entry.retry) { - clearTimeout(entry.retry) - entry.retry = null - } -} - -function open(entry: Entry): void { - close(entry) - const { generation, subject } = entry - const current = (): boolean => entry.observers > 0 && entry.generation === generation - const lost = (): void => { - if (!current()) { - return - } - close(entry) - // A known answer stays through a blip; only a first read failing changes what is shown. - if (entry.snapshot.kind !== 'known') { - setSnapshot(entry, { kind: 'readFailed' }) - } - entry.failures += 1 - const delay = Math.min(30_000, 1000 * 2 ** (entry.failures - 1)) - entry.retry = setTimeout(() => open(entry), delay) - } - void subscribeRuntimeRpc( - subject.target, - 'managedRuntime.subscribe', - { agent: subject.agent }, - { - onEvent: (result) => { - if (current() && isInstallState(result)) { - entry.failures = 0 - accept(entry, result, generation) - } - }, - onError: lost, - onClose: lost - } - ).then((opened) => { - if (current()) { - entry.stream = opened - } else { - opened.unsubscribe() - } - }, lost) -} - -export function readManagedRuntimeInstall( - subject: ManagedRuntimeInstallSubject -): ManagedRuntimeInstallSnapshot { - return entries.get(subjectKey(subject))?.snapshot ?? UNKNOWN -} +const installs = createRevisionedHostStateStore< + ManagedRuntimeInstallSubject, + ManagedRuntimeInstallState +>({ + key: (subject) => `${hostAgentKey(subject)}\u0000${subject.runtimeId ?? ''}`, + hostKey: hostAgentKey, + subscribe: ({ target, agent }, handlers) => + subscribeRuntimeRpc(target, 'managedRuntime.subscribe', { agent }, handlers) +}) +export const readManagedRuntimeInstall = installs.read /** Subscribes while at least one shown surface observes this subject. Returns the release. */ -export function observeManagedRuntimeInstall(subject: ManagedRuntimeInstallSubject): () => void { - const entry = entryFor(subject) - entry.observers += 1 - if (entry.observers === 1) { - entry.failures = 0 - open(entry) - } - return () => { - entry.observers -= 1 - if (entry.observers === 0) { - close(entry) - } - } -} - +export const observeManagedRuntimeInstall = installs.observe /** Resubscribes a lost stream at once, e.g. when the host answers again after an outage. */ -export function wakeManagedRuntimeInstall(subject: ManagedRuntimeInstallSubject): void { - const entry = entries.get(subjectKey(subject)) - if (entry?.retry) { - entry.failures = 0 - open(entry) - } -} +export const wakeManagedRuntimeInstall = installs.wake +export const subscribeManagedRuntimeInstalls = installs.subscribe function callHost( subject: ManagedRuntimeInstallSubject, @@ -188,22 +54,11 @@ function callHost( ) } -async function startOperation( - subject: ManagedRuntimeInstallSubject, - method: 'managedRuntime.install' | 'managedRuntime.remove' -): Promise { - const entry = entryFor(subject) - const { generation } = entry - const state = await callHost(subject, method, {}) - // A reply that crossed a resubscribe is stale; the new subscription's snapshot governs. - if (entry.generation === generation) { - accept(entry, state, generation) - } -} - /** Starts, or joins, the host's install; rejects with the host's refusal. */ export function installManagedRuntime(subject: ManagedRuntimeInstallSubject): Promise { - return startOperation(subject, 'managedRuntime.install') + return installs.applyReply(subject, () => + callHost(subject, 'managedRuntime.install', {}) + ) } /** Resolves once the run has unwound; the subscription shows its outcome. */ @@ -216,12 +71,7 @@ export async function cancelManagedRuntimeInstall( /** Starts, or joins, the host's removal; it fails, keeping the files, while anything may run them. */ export function removeManagedRuntime(subject: ManagedRuntimeInstallSubject): Promise { - return startOperation(subject, 'managedRuntime.remove') -} - -export function subscribeManagedRuntimeInstalls(listener: () => void): () => void { - listeners.add(listener) - return () => { - listeners.delete(listener) - } + return installs.applyReply(subject, () => + callHost(subject, 'managedRuntime.remove', {}) + ) } diff --git a/src/renderer/src/runtime/revisioned-host-state-store.ts b/src/renderer/src/runtime/revisioned-host-state-store.ts new file mode 100644 index 00000000000..8df9618d90c --- /dev/null +++ b/src/renderer/src/runtime/revisioned-host-state-store.ts @@ -0,0 +1,181 @@ +import type { RuntimeRpcSubscriptionHandlers } from './runtime-rpc-subscribe' + +/** + * A host-owned state per subject, pushed by revision. While any surface observes a subject, one + * subscription carries the host's state, idle included, so another client's change shows up too; + * the last surface leaving ends it. + */ + +export type RevisionedHostSnapshot = + | { kind: 'unknown' } + /** No answer yet and the last read failed: says nothing about the host's state. */ + | { kind: 'readFailed' } + | { kind: 'known'; state: S } + +type Entry = { + subject: Subject + snapshot: RevisionedHostSnapshot + observers: number + /** Bumped by every subscribe and every loss; a frame from another generation is stale. */ + generation: number + /** The generation the shown state came from: revisions only order one subscription's frames. */ + shownGeneration: number + stream: { unsubscribe: () => void } | null + retry: ReturnType | null + failures: number +} + +const UNKNOWN = { kind: 'unknown' } as const + +export function createRevisionedHostStateStore(options: { + key: (subject: Subject) => string + /** Subjects with the same host key and a different key answer for a restarted host. */ + hostKey: (subject: Subject) => string + /** A full state on subscribe, then one per newer revision, until unsubscribed or lost. */ + subscribe: ( + subject: Subject, + handlers: RuntimeRpcSubscriptionHandlers + ) => Promise<{ unsubscribe: () => void }> +}) { + const entries = new Map>() + const listeners = new Set<() => void>() + + const setSnapshot = (entry: Entry, snapshot: RevisionedHostSnapshot): void => { + entry.snapshot = snapshot + listeners.forEach((listener) => listener()) + } + + const entryFor = (subject: Subject): Entry => { + const key = options.key(subject) + let entry = entries.get(key) + if (!entry) { + // A restarted host runtime answers under a new key; the old one's answer is stale. + for (const [otherKey, other] of entries) { + if (other.observers === 0 && options.hostKey(other.subject) === options.hostKey(subject)) { + entries.delete(otherKey) + } + } + entry = { + subject, + snapshot: UNKNOWN, + observers: 0, + generation: 0, + shownGeneration: 0, + stream: null, + retry: null, + failures: 0 + } + entries.set(key, entry) + } + return entry + } + + const accept = (entry: Entry, state: S, generation: number): void => { + // A new subscription may reach a restarted host whose revisions start again at 0. + if ( + entry.snapshot.kind !== 'known' || + generation !== entry.shownGeneration || + entry.snapshot.state.revision < state.revision + ) { + entry.shownGeneration = generation + setSnapshot(entry, { kind: 'known', state }) + } + } + + const isState = (value: unknown): value is S => + typeof value === 'object' && + value !== null && + 'revision' in value && + typeof value.revision === 'number' + + const close = (entry: Entry): void => { + entry.generation += 1 + entry.stream?.unsubscribe() + entry.stream = null + if (entry.retry) { + clearTimeout(entry.retry) + entry.retry = null + } + } + + const open = (entry: Entry): void => { + close(entry) + const { generation } = entry + const current = (): boolean => entry.observers > 0 && entry.generation === generation + const lost = (): void => { + if (!current()) { + return + } + close(entry) + // A known answer stays through a blip; only a first read failing changes what is shown. + if (entry.snapshot.kind !== 'known') { + setSnapshot(entry, { kind: 'readFailed' }) + } + entry.failures += 1 + const delay = Math.min(30_000, 1000 * 2 ** (entry.failures - 1)) + entry.retry = setTimeout(() => open(entry), delay) + } + void options + .subscribe(entry.subject, { + onEvent: (result) => { + if (current() && isState(result)) { + entry.failures = 0 + accept(entry, result, generation) + } + }, + onError: lost, + onClose: lost + }) + .then((opened) => { + if (current()) { + entry.stream = opened + } else { + opened.unsubscribe() + } + }, lost) + } + + return { + read: (subject: Subject): RevisionedHostSnapshot => + entries.get(options.key(subject))?.snapshot ?? UNKNOWN, + /** Subscribes while at least one shown surface observes `subject`. Returns the release. */ + observe: (subject: Subject): (() => void) => { + const entry = entryFor(subject) + entry.observers += 1 + if (entry.observers === 1) { + entry.failures = 0 + open(entry) + } + return () => { + entry.observers -= 1 + if (entry.observers === 0) { + close(entry) + } + } + }, + /** Resubscribes a lost stream at once, e.g. when the host answers again after an outage. */ + wake: (subject: Subject): void => { + const entry = entries.get(options.key(subject)) + if (entry?.retry) { + entry.failures = 0 + open(entry) + } + }, + /** Shows an action's reply, ordered by revision with the subscription's frames. */ + applyReply: async (subject: Subject, request: () => Promise): Promise => { + const entry = entryFor(subject) + const { generation } = entry + const state = await request() + // A reply that crossed a resubscribe is stale; the new subscription's snapshot governs. + if (entry.generation === generation) { + accept(entry, state, generation) + } + }, + subscribe: (listener: () => void): (() => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + } +} diff --git a/src/renderer/src/runtime/runtime-antigravity-accounts-client.ts b/src/renderer/src/runtime/runtime-antigravity-accounts-client.ts index c115770c494..215a3a639d2 100644 --- a/src/renderer/src/runtime/runtime-antigravity-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-antigravity-accounts-client.ts @@ -2,9 +2,13 @@ import type { AntigravityAccountState, AntigravityAccountTarget } from '../../../shared/antigravity-account-types' -import { ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY } from '../../../shared/account-runtime-capabilities' +import { + ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY, + ANTIGRAVITY_CHAT_SIGN_IN_RUNTIME_CAPABILITY +} from '../../../shared/account-runtime-capabilities' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client' +import { subscribeRuntimeRpc, type RuntimeRpcSubscriptionHandlers } from './runtime-rpc-subscribe' export async function callAntigravityAccounts( owner: RuntimeClientTarget, @@ -26,3 +30,33 @@ export async function callAntigravityAccounts( { timeoutMs: 20_000 } ) } + +async function assertChatSignInHost(owner: RuntimeClientTarget): Promise { + if (owner.kind === 'environment') { + await assertRuntimeEnvironmentCapability( + owner.environmentId, + ANTIGRAVITY_CHAT_SIGN_IN_RUNTIME_CAPABILITY, + 'This execution host does not support Antigravity chat sign-in yet. Update Orca on that host.' + ) + } +} + +export async function callAntigravityChatSignIn( + owner: RuntimeClientTarget, + action: 'Start' | 'Cancel' | 'Complete', + params?: { flowId: string; callbackUrl?: string } +): Promise { + await assertChatSignInHost(owner) + return callRuntimeRpc(owner, `accounts.antigravityChatSignIn${action}`, params, { + timeoutMs: 20_000 + }) +} + +/** The host's sign-in status: a snapshot, then one frame per newer revision. */ +export async function subscribeAntigravityChatSignIn( + owner: RuntimeClientTarget, + handlers: RuntimeRpcSubscriptionHandlers +): Promise<{ unsubscribe: () => void }> { + await assertChatSignInHost(owner) + return subscribeRuntimeRpc(owner, 'accounts.antigravityChatSignInSubscribe', null, handlers) +} diff --git a/src/shared/agent-session-availability.test.ts b/src/shared/agent-session-availability.test.ts index 2c81374072d..446aeb393c6 100644 --- a/src/shared/agent-session-availability.test.ts +++ b/src/shared/agent-session-availability.test.ts @@ -42,4 +42,9 @@ describe("the host's sign-in and CLI verdict", () => { ) ) }) + it('sends Antigravity to its sign-in in Settings', () => { + expect( + agentSessionFailureSentence({ kind: 'notSignedIn' }, 'row', { agentName: 'antigravity' }) + ).toBe(AGENT_SESSION_FAILURE_COPY.antigravityNotSignedIn) + }) }) diff --git a/src/shared/agent-session-availability.ts b/src/shared/agent-session-availability.ts index 78a072d0c25..e2e7496b54a 100644 --- a/src/shared/agent-session-availability.ts +++ b/src/shared/agent-session-availability.ts @@ -36,9 +36,11 @@ export function agentSessionSignInCopyId(provider: string, account?: AgentSessio ? account === 'managed' ? 'codexManagedNotSignedIn' : 'codexSystemNotSignedIn' - : signIn?.agent === 'pi' - ? 'interactiveAgentNotSignedIn' - : signIn?.loginCommand.length - ? 'agentCommandNotSignedIn' - : 'agentNotSignedIn' + : signIn?.agent === 'antigravity' + ? 'antigravityNotSignedIn' + : signIn?.agent === 'pi' + ? 'interactiveAgentNotSignedIn' + : signIn?.loginCommand.length + ? 'agentCommandNotSignedIn' + : 'agentNotSignedIn' } diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 83f80a1f390..bd7523db85c 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -46,6 +46,8 @@ export const AGENT_SESSION_FAILURE_COPY = { codexSystemNotSignedIn: "Codex isn't signed in. Run `{{loginCommand}}`.", codexManagedNotSignedIn: "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", + antigravityNotSignedIn: + "Antigravity isn't signed in. Sign in under Settings → Accounts → Antigravity.", agentCommandNotSignedIn: 'Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.', interactiveAgentNotSignedIn: