From 46be7e23ee8ebe2ff8937b16f51b2b7de88b58e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:28:43 -0700 Subject: [PATCH] feat(settings): sign Antigravity chats in from Accounts (#28705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(accounts): sign Antigravity chats in from the Accounts page Adds an 'Antigravity chats' row to the Antigravity section of Accounts (behind the native chat setting, this device only): Sign in starts the host's chat sign-in, follows it with the Link poll, opens the Google page once, offers Copy link and Cancel, and shows one status line per phase. The saved line reflects the sign-in file only, never that it works. * Keep Antigravity chat sign-in bound to the current view * feat(settings): put Antigravity chat sign-in under its install row in Chat > Providers and Accounts The chats' sign-in row now sits directly below the Antigravity install row, in Settings > Chat > Providers and under the agy section of Accounts, with copy that says it is separate from the agy account. Its button reads Sign in, Change account or Retry sign-in by state, and stays visible but disabled while the host has no runnable server; the install row above says why. * fix(settings): both Antigravity chat sign-in rows share one sign-in per host and pick up one already running Settings shows the sign-in row under Chat > Providers and under Accounts at once; each kept its own flow, so starting one silently cancelled the other and a sign-in nobody watched could not be seen or cancelled. One entry per host now holds the flow and the saved sign-in for every row, follows the host's Link poll while a row shows it, and on mount adopts the running flow the host names. Sign in also waits while an install or Remove runs, read from the same install status. * fix(settings): a failed sign-in poll re-reads the host so a running sign-in stays cancellable, and only a Remove (not an Update) holds Sign in * fix(settings): the sign-in rows follow the host's sign-in status through the same loop as install, so an idle page sees another device's sign-in * refactor(settings): install and sign-in rows share one subscription store; Install and Remove wait for a running sign-in, and Sign in waits for the host's answer Both stores now follow the host through one generic subscription store (snapshot, then a frame per newer revision, resubscribe with backoff on loss), replacing the sign-in store's long-poll of a method the host no longer has. While a sign-in runs, Install, Update and Remove are disabled (Cancel stays). The sign-in row keeps Sign in disabled until the host answers, says when the status could not be read instead of showing signed out, and sends one start or cancel at a time. * feat(settings): paste the last sign-in page's address to finish an Antigravity chat sign-in While a chat sign-in waits on the browser, its row shows a field for the address of the last page after signing in, for when that page doesn't load because the browser is on another computer than the host. Continue sends it to the host, which passes it on to Antigravity; the host's refusal shows under the field. With that, remote hosts offer chat sign-in on the Accounts page too, and the row reads "Finishing sign-in…" while the host completes it. * test(settings): give each host its own fake sign-in status and stub the new subscription export The sign-in row tests shared one fake host across every owner, so the first test's finished sign-in made the next row show "Change account" instead of "Sign in", and rows left mounted after that failure doubled the later tests' matches. Each owner now has its own fake host behind the subscription, and every test cleans up its render. The Antigravity accounts section test's client mock also lacked the sign-in exports the status store now reads at import. * feat(settings): the Antigravity chat sign-in row follows the host's sign-in answer The host no longer reports whether a sign-in file exists; it reports what the server last answered (signed in, signed out, or not known yet), and a running sign-in started by another client arrives without its id. The row now reads that answer: - "Signed in on {host}." or "Not signed in on {host}."; no line until the server has answered, with Sign in still enabled. Change account shows only when the server said signed in. - A sign-in another client is running reads "Sign-in is in progress in another client.", with Sign in disabled and no Cancel, copy link or paste field, since only the client that started it can act on it. - "Finishing sign-in..." also follows a browser sign-in while the host checks it. - Cancel and paste send only this client's own sign-in; the host's refusals show under the row. * fix(chat): a signed-out Antigravity chat says to sign in under Settings, not what the agent printed The notice read "Sign in to Antigravity. The agent asked to sign in in a browser": the second sentence was Orca's own log text for the sign-in prompt the agent printed, shown as if it were the agent's words, and the notice never said where to sign in. The connection now closes with a sign-in prompt error whose message stays in the logs, so the chat shows only Orca's sign-in sentence. Antigravity gets its own sentence, in the shape Claude's already has: "Antigravity isn't signed in. Sign in under Settings → Accounts → Antigravity." Other agents' sign-in words, and the agent's own words in a sign-in answer, are unchanged. * fix(chat): a sign-in prompt at chat start keeps Orca's words out of the signed-out notice A signed-out Antigravity chat still read "... The agent asked to sign in in a browser". The connection closes with a sign-in prompt error whose message is Orca's log text, but the request wrapper and session setup re-typed every sign-in answer by copying its message into a new error. The copy lost the sign-in prompt type, so its message was quoted as the agent's own words. Re-typing now keeps a sign-in prompt close as it is, at both the request wrapper and session setup, so the notice says only where to sign in. * fix(settings): the Antigravity sign-in row shows the host's answer once a sign-in is cancelled or timed out After a cancelled or timed-out sign-in the row kept reading "Sign-in cancelled." even when the host had answered that this computer is not signed in. A finished sign-in now only stands in until the host answers; then the row reads "Not signed in on ." or "Signed in on .". A running sign-in still shows its step. * fix(settings): a wrong pasted sign-in address shows Orca's reason, not the browser's URL bubble The paste field is a URL input, so the browser's own "Please enter a URL." check ran before Orca could explain what was wrong with the address. The form now skips the browser's check and shows the host's refusal sentence. * style(settings): the Antigravity chat sign-in row reads as a plain row like the install row The sign-in row sat in its own bordered box, the same bright outline the install row lost when it became a single grey status line. It now has no box: a medium-weight "Antigravity chats" title, the description and the grey status line under it (errors inline in the destructive colour), and its action at the row's right end. Sign in and Retry are primary buttons, so they stand out like Install; Change account stays outline and Cancel stays ghost. The paste-the- address form sits under the row without a box. Copy, keys and behaviour are unchanged. * ci: run the PR checks against main after the retarget * fix(settings): list every sign-in phase in the status line switch * test(chat): name Antigravity as the chat's agent in its signed-out sentence test * feat(settings): Antigravity provider row with a ⋯ menu and its own sign-in area The Providers row's status line now says only the install state. Update, Reinstall and Remove move into a ⋯ menu beside the switch, shown for a copy on disk; Remove keeps its confirmation. Sign-in moves into a quiet inset area under the row, shown once a copy runs, with one sentence and one action per state. Accounts keeps its sign-in row; its install row gets the same menu. * fix(settings): a damaged runtime offers Reinstall as its row's button A damaged copy's repair is the next step, so it sits where Install does instead of inside the ⋯ menu, which then offers only Remove. --- src/main/acp/acp-agent-connection.ts | 4 +- src/main/acp/acp-errors.ts | 14 + src/main/acp/acp-response-settlement.ts | 4 +- src/main/acp/acp-session-setup.ts | 6 +- src/main/acp/acp-structured-sign-in.test.ts | 20 +- src/main/acp/acp-turn-failures.ts | 6 +- .../ManagedRuntimeActionsMenu.tsx | 70 ++++ .../ManagedRuntimeInstallRow.tsx | 63 ++-- .../ManagedRuntimeRemoveButton.tsx | 78 ---- .../ManagedRuntimeRemoveDialog.tsx | 66 ++++ .../managed-runtime-install-view.test.ts | 39 +- .../managed-runtime-install-view.ts | 34 +- .../agent-session-availability-words-text.ts | 7 + .../AntigravityAccountsSection.test.tsx | 4 +- .../settings/AntigravityAccountsSection.tsx | 57 ++- .../AntigravityChatSignInRow.test.tsx | 159 +++++++++ .../settings/AntigravityChatSignInRow.tsx | 336 ++++++++++++++++++ .../AntigravityProviderSignInArea.tsx | 93 +++++ .../settings/NativeChatProvidersSetting.tsx | 99 ++++-- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 31 +- src/renderer/src/i18n/locales/es.json | 31 +- src/renderer/src/i18n/locales/fr.json | 31 +- src/renderer/src/i18n/locales/ja.json | 31 +- src/renderer/src/i18n/locales/ko.json | 31 +- src/renderer/src/i18n/locales/zh.json | 31 +- .../antigravity-chat-sign-in-status.ts | 189 ++++++++++ .../runtime/managed-runtime-install-status.ts | 200 ++--------- .../runtime/revisioned-host-state-store.ts | 181 ++++++++++ .../runtime-antigravity-accounts-client.ts | 36 +- src/shared/agent-session-availability.test.ts | 5 + src/shared/agent-session-availability.ts | 12 +- src/shared/agent-session-failure-copy.ts | 2 + 33 files changed, 1580 insertions(+), 391 deletions(-) create mode 100644 src/renderer/src/components/managed-runtime/ManagedRuntimeActionsMenu.tsx delete mode 100644 src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveButton.tsx create mode 100644 src/renderer/src/components/managed-runtime/ManagedRuntimeRemoveDialog.tsx create mode 100644 src/renderer/src/components/settings/AntigravityChatSignInRow.test.tsx create mode 100644 src/renderer/src/components/settings/AntigravityChatSignInRow.tsx create mode 100644 src/renderer/src/components/settings/AntigravityProviderSignInArea.tsx create mode 100644 src/renderer/src/runtime/antigravity-chat-sign-in-status.ts create mode 100644 src/renderer/src/runtime/revisioned-host-state-store.ts 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: