mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 = () =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "サインイン手順",
|
||||
|
||||
@@ -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": "로그인 안내",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user