feat(settings): sign Antigravity chats in from Accounts (#28705)

* 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 <host>." or "Signed in on <host>.". 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.
This commit is contained in:
Brennan Benson
2026-10-18 21:28:43 -07:00
committed by GitHub
parent 24e218c101
commit 46be7e23ee
33 changed files with 1580 additions and 391 deletions
+2 -2
View File
@@ -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 = () =>
+14
View File
@@ -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(
+2 -2
View File
@@ -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<T>(
@@ -16,7 +16,7 @@ export function callAcpSession<T>(
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
}
+3 -3
View File
@@ -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
}
+18 -2
View File
@@ -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)(
+5 -1
View File
@@ -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
}
@@ -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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate(
'components.managed-runtime.moreActions',
'More actions for {{agent}}',
{ agent: agentLabel }
)}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{install === null ? null : (
<DropdownMenuItem disabled={disabled} onSelect={install.onSelect}>
{install.label}
</DropdownMenuItem>
)}
{onRemove === null ? null : (
<DropdownMenuItem
variant="destructive"
disabled={disabled}
onSelect={() => setConfirming(true)}
>
{translate('components.managed-runtime.remove', 'Remove downloaded runtime')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{onRemove === null ? null : (
<ManagedRuntimeRemoveDialog
agentLabel={agentLabel}
host={props.host}
open={confirming}
onOpenChange={setConfirming}
onRemove={onRemove}
/>
)}
</>
)
}
@@ -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<ManagedRuntimeInstallObservation, { kind: 'active' }>
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(
<span key="status" className="inline-flex items-center gap-1.5">
@@ -81,7 +85,7 @@ export function ManagedRuntimeInstallRow(props: {
if (alert) {
parts.push(
<span key="alert" role="alert" className="break-words text-destructive">
{said(alert, !(action || removable || view.note))}
{said(alert, !(action || view.note))}
</span>
)
}
@@ -89,24 +93,13 @@ export function ManagedRuntimeInstallRow(props: {
parts.push(
<ManagedRuntimeLinkButton
key="action"
disabled={pending !== null}
disabled={action.kind === 'cancel' ? pending !== null : held}
onClick={() => act(action.kind, primary(action))}
>
{action.label}
</ManagedRuntimeLinkButton>
)
}
if (removable) {
parts.push(
<ManagedRuntimeRemoveButton
key="remove"
agentLabel={agentLabel}
host={subject.target}
disabled={pending !== null}
onRemove={() => act('remove', () => removeManagedRuntime(subject))}
/>
)
}
if (view.note) {
parts.push(<span key="note">{view.note}</span>)
}
@@ -122,19 +115,33 @@ export function ManagedRuntimeInstallRow(props: {
)}
</div>
)
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 ? (
<Button
size="xs"
disabled={pending !== null}
onClick={() => act(button.kind, primary(button))}
>
{button.label}
</Button>
) : null
<>
{button ? (
<Button
size="xs"
disabled={button.kind === 'cancel' ? pending !== null : held}
onClick={() => act(button.kind, primary(button))}
>
{button.label}
</Button>
) : null}
{install || remove ? (
<ManagedRuntimeActionsMenu
agentLabel={agentLabel}
host={subject.target}
install={install}
onRemove={remove}
disabled={held}
/>
) : 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 (
<>
<ManagedRuntimeLinkButton
disabled={disabled}
aria-label={translate('components.managed-runtime.remove', 'Remove downloaded runtime')}
onClick={() => setConfirming(true)}
>
{translate('components.managed-runtime.removeConfirm', 'Remove')}
</ManagedRuntimeLinkButton>
<Dialog open={confirming} onOpenChange={setConfirming}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{translate(
'components.managed-runtime.removeTitle',
'Remove the downloaded {{agent}} runtime from {{host}}?',
{ agent: agentLabel, host: hostLabel }
)}
</DialogTitle>
<DialogDescription>
{translate(
'components.managed-runtime.removeDescription',
'{{agent}} chats that are running stop. Their history is kept.',
{ agent: agentLabel }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirming(false)}>
{translate('components.managed-runtime.removeKeep', 'Cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirming(false)
onRemove()
}}
>
{translate('components.managed-runtime.removeConfirm', 'Remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{translate(
'components.managed-runtime.removeTitle',
'Remove the downloaded {{agent}} runtime from {{host}}?',
{ agent: agentLabel, host: hostLabel }
)}
</DialogTitle>
<DialogDescription>
{translate(
'components.managed-runtime.removeDescription',
'{{agent}} chats that are running stop. Their history is kept.',
{ agent: agentLabel }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{translate('components.managed-runtime.removeKeep', 'Cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
onOpenChange(false)
onRemove()
}}
>
{translate('components.managed-runtime.removeConfirm', 'Remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -25,8 +25,11 @@ const op = (patch: Partial<ManagedRuntimeOperation>): ManagedRuntimeOperation =>
...patch
})
const viewOf = (patch: Partial<ManagedRuntimeInstallState>) =>
managedRuntimeInstallView({ kind: 'known', state: { ...idle, ...patch } }, 'Fake')
function shown(patch: Partial<ManagedRuntimeInstallState>) {
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<ManagedRuntimeOperation>) =>
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.',
@@ -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') {
@@ -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) =>
@@ -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 }))
@@ -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({
)}
</p>
{chats && chatRuntime.kind === 'active' ? (
<ManagedRuntimeInstallRow
observation={chatRuntime}
agentLabel={translate('accounts.antigravity.title', 'Antigravity')}
>
{(line, button) => (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<p className="text-xs text-muted-foreground">
{translate(
'settings.chat.providers.antigravityDescription',
'Antigravity chats run a server Orca downloads to this host, separate from the agy CLI.'
)}
</p>
{line}
<div className="space-y-4">
<ManagedRuntimeInstallRow
observation={chatRuntime}
agentLabel={translate('accounts.antigravity.title', 'Antigravity')}
signInRunning={antigravityChatSignInRunning(signIn.flow)}
>
{(line, actions) => (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<p className="text-xs text-muted-foreground">
{translate(
'settings.chat.providers.antigravityDescription',
'Antigravity chats run a server Orca downloads to this host, separate from the agy CLI.'
)}
</p>
{line}
</div>
{actions}
</div>
{button}
</div>
)}
</ManagedRuntimeInstallRow>
)}
</ManagedRuntimeInstallRow>
<AntigravityChatSignInRow
owner={owner}
label={label}
description={translate(
'accounts.antigravity.chatDescription',
'Chats use their own Antigravity sign-in, separate from your agy account above. Sign in once on this device.'
)}
disabled={antigravityChatSignInBlocked(chatRuntime)}
/>
</div>
) : null}
</section>
)
@@ -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<string, FakeHost>()
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(
<TooltipProvider>
<AntigravityChatSignInRow
owner={owner}
label="this computer"
description=""
disabled={false}
/>
</TooltipProvider>
)
// 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
})
})
@@ -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<SignInAction, () => 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<ManagedRuntimeInstallObservation, { kind: 'active' }>
): 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<string | null>(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 (
<form
noValidate
className="space-y-1"
onSubmit={(event) => {
event.preventDefault()
if (callbackUrl) {
props.send(callbackUrl, setRefusal)
}
}}
>
<p id={helpId} className="text-xs text-muted-foreground">
{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."
)}
</p>
<div className="flex items-center gap-2">
<Input
type="url"
autoComplete="off"
spellCheck={false}
maxLength={16_384}
aria-labelledby={helpId}
aria-invalid={refusal !== null}
value={pasted}
disabled={props.pending}
onChange={(event) => {
setPasted(event.target.value)
setRefusal(null)
}}
/>
<Button type="submit" size="sm" variant="outline" disabled={props.pending || !callbackUrl}>
{translate('accounts.antigravity.chatContinue', 'Continue')}
</Button>
</div>
{refusal !== null && (
<p role="alert" className="text-xs text-destructive">
{refusal}
</p>
)}
</form>
)
}
/** 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>): 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 (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-none">
{translate('accounts.antigravity.chatTitle', 'Antigravity chats')}
</p>
{props.description ? (
<p className="mt-1 text-xs text-muted-foreground">{props.description}</p>
) : null}
{/* The line keeps its height until the host answers, so nothing shifts when it does. */}
<p role="status" className="mt-1 min-h-4 break-words text-xs text-muted-foreground">
{line}
{line && alert ? ' ' : null}
{alert && (
<span role="alert" className="text-destructive">
{alert}
</span>
)}
</p>
</div>
{signIn.ownFlow ? (
<div className="flex shrink-0 items-center gap-2">
{url && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-xs"
variant="ghost"
aria-label={copyLabel}
onClick={() => void copy.copyText()}
>
{copy.status === 'copied' ? <Check /> : <Copy />}
</Button>
</TooltipTrigger>
<TooltipContent>{copyLabel}</TooltipContent>
</Tooltip>
)}
<Button size="xs" variant="ghost" disabled={signIn.pending} onClick={signIn.cancel}>
{translate('accounts.antigravity.chatCancel', 'Cancel')}
</Button>
</div>
) : (
<Button
size="xs"
// Signed in, the button only switches account, so it steps back from the primary.
variant={signIn.action === 'changeAccount' ? 'outline' : 'default'}
disabled={signIn.signInDisabled}
onClick={signIn.start}
>
{signIn.actionLabel}
</Button>
)}
</div>
{url && <PastedAddressForm pending={signIn.pending} send={signIn.send} />}
</div>
)
}
@@ -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<ManagedRuntimeInstallObservation, { kind: 'active' }>
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.
<div className="ml-10 space-y-2 rounded-md bg-muted/40 px-3 py-2.5">
{/* The line keeps its height until the host answers, so nothing shifts when it does. */}
<div className="flex min-h-6 items-center gap-2">
<div
role="status"
className="flex min-w-0 flex-1 items-center gap-1.5 text-xs text-muted-foreground"
>
{ownFlow ? <LoadingSpinner className="size-3 shrink-0" /> : null}
<span className="min-w-0 break-words">
{line}
{line && alert ? ' ' : null}
{alert && (
<span role="alert" className="text-destructive">
{alert}
</span>
)}
</span>
</div>
{ownFlow ? (
<>
{url && (
<Button size="xs" variant="ghost" onClick={() => void copy.copyText()}>
{copy.status === 'copied' ? <Check /> : <Copy />}
{copyLabel}
</Button>
)}
<Button size="xs" variant="ghost" disabled={signIn.pending} onClick={signIn.cancel}>
{translate('accounts.antigravity.chatCancel', 'Cancel')}
</Button>
</>
) : (
<Button
size="xs"
// Signed in, the button only switches account, so it steps back from the primary.
variant={signIn.action === 'changeAccount' ? 'ghost' : 'default'}
disabled={signIn.signInDisabled}
onClick={signIn.start}
>
{signIn.actionLabel}
</Button>
)}
</div>
{url && <PastedAddressForm pending={signIn.pending} send={signIn.send} />}
</div>
)
}
@@ -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 => (
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-none">{props.label}</div>
@@ -114,17 +127,36 @@ function ProviderDetail(props: {
)
}
return (
<ManagedRuntimeInstallRow observation={runtime} agentLabel={props.label}>
{(line, button) => (
<ManagedRuntimeInstallRow
observation={runtime}
agentLabel={props.label}
signInRunning={antigravityChatSignInRunning(signIn.flow)}
>
{(line, actions) => (
<>
{summary(line)}
{button}
{actions}
</>
)}
</ManagedRuntimeInstallRow>
)
}
/** 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 (
<AntigravityProviderSignInArea
owner={CHAT_HOST}
label={getRuntimeSentenceLabel({ runtime: 'host', label: getHostRuntimeLabel() })}
install={runtime}
/>
)
}
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 (
<div key={agent} className="flex items-center gap-3 py-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/50 bg-background/50">
<AgentIcon agent={agent} size={16} />
<div key={agent} className="space-y-3 py-3">
<div className="flex items-center gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/50 bg-background/50">
<AgentIcon agent={agent} size={16} />
</div>
<ProviderDetail
agent={agent}
label={label}
installed={detectedIds}
enabledInAgents={enabledInAgents}
/>
<SettingsSwitch
checked={on}
disabled={!enabledInAgents}
ariaLabel={translate(
'settings.chat.providers.toggleLabel',
'Open {{value0}} as a chat',
{ value0: label }
)}
onChange={() => {
const next = !on
return enqueueProviderUpdate({
getSettings: () => useAppStore.getState().settings,
fallbackSettings: settings,
updateSettings,
buildUpdate: (latest) =>
buildNativeChatProviderUpdate(latest, agent, next)
})
}}
/>
</div>
<ProviderDetail
agent={agent}
label={label}
installed={detectedIds}
enabledInAgents={enabledInAgents}
/>
<SettingsSwitch
checked={on}
disabled={!enabledInAgents}
ariaLabel={translate(
'settings.chat.providers.toggleLabel',
'Open {{value0}} as a chat',
{ value0: label }
)}
onChange={() => {
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 ? (
<AntigravityChatProviderSignIn />
) : null}
</div>
)
})}
+1
View File
@@ -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.",
+28 -3
View File
@@ -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",
+28 -3
View File
@@ -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",
+28 -3
View File
@@ -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",
+28 -3
View File
@@ -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": "サインイン手順",
+28 -3
View File
@@ -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": "로그인 안내",
+28 -3
View File
@@ -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": "登录说明",
@@ -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<AntigravityChatSignInStatus>['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<AntigravityChatSignInView, 'error' | 'openFailed'>
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<RuntimeClientTarget, AntigravityChatSignInStatus>({
key: hostKey,
hostKey,
subscribe: subscribeAntigravityChatSignIn
})
const locals = new Map<string, Local>()
type Shown = {
snapshot: RevisionedHostSnapshot<AntigravityChatSignInStatus>
notes: Notes
view: AntigravityChatSignInView
}
const views = new Map<string, Shown>()
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<Notes>): 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<void> {
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<void> {
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<string | null> {
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()
}
}
@@ -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<typeof setTimeout> | null
failures: number
}
const UNKNOWN: ManagedRuntimeInstallSnapshot = { kind: 'unknown' }
const entries = new Map<string, Entry>()
const listeners = new Set<() => void>()
export type ManagedRuntimeInstallSnapshot = RevisionedHostSnapshot<ManagedRuntimeInstallState>
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<T>(
subject: ManagedRuntimeInstallSubject,
@@ -188,22 +54,11 @@ function callHost<T>(
)
}
async function startOperation(
subject: ManagedRuntimeInstallSubject,
method: 'managedRuntime.install' | 'managedRuntime.remove'
): Promise<void> {
const entry = entryFor(subject)
const { generation } = entry
const state = await callHost<ManagedRuntimeInstallState>(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<void> {
return startOperation(subject, 'managedRuntime.install')
return installs.applyReply(subject, () =>
callHost<ManagedRuntimeInstallState>(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<void> {
return startOperation(subject, 'managedRuntime.remove')
}
export function subscribeManagedRuntimeInstalls(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
return installs.applyReply(subject, () =>
callHost<ManagedRuntimeInstallState>(subject, 'managedRuntime.remove', {})
)
}
@@ -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<S> =
| { 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, S> = {
subject: Subject
snapshot: RevisionedHostSnapshot<S>
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<typeof setTimeout> | null
failures: number
}
const UNKNOWN = { kind: 'unknown' } as const
export function createRevisionedHostStateStore<Subject, S extends { revision: number }>(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<string, Entry<Subject, S>>()
const listeners = new Set<() => void>()
const setSnapshot = (entry: Entry<Subject, S>, snapshot: RevisionedHostSnapshot<S>): void => {
entry.snapshot = snapshot
listeners.forEach((listener) => listener())
}
const entryFor = (subject: Subject): Entry<Subject, S> => {
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<Subject, S>, 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<Subject, S>): void => {
entry.generation += 1
entry.stream?.unsubscribe()
entry.stream = null
if (entry.retry) {
clearTimeout(entry.retry)
entry.retry = null
}
}
const open = (entry: Entry<Subject, S>): 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<S> =>
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<S>): Promise<void> => {
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)
}
}
}
}
@@ -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<void> {
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<TResult>(
owner: RuntimeClientTarget,
action: 'Start' | 'Cancel' | 'Complete',
params?: { flowId: string; callbackUrl?: string }
): Promise<TResult> {
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)
}
@@ -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)
})
})
+7 -5
View File
@@ -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'
}
+2
View File
@@ -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: