Merge pull request #458 from nwparker/cloud/n03-scoped-toasts

Cloud: no away toast for a held cloud agent; the sidebar row marks it (N-03, my half)
This commit is contained in:
Neil
2026-11-11 12:12:48 -08:00
committed by GitHub
11 changed files with 270 additions and 607 deletions
@@ -31,22 +31,10 @@ import {
orcaCloudNeedsGitHubRepo,
type CloudStartView
} from './cloud-start-view'
import { showCloudAgentNotStartedToast } from '@/lib/orca-cloud/cloud-agent-not-started-toast'
import { orcaCloudWallNotice, useRunOnPreflightEnabled } from '@/lib/orca-cloud/run-on-preflight'
import { isOrcaCloudAccountAgent } from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
export type { CloudStartView } from './cloud-start-view'
/**
* The agent isn't set up: its pane holds on the "Set up once" strip (PC-3b), so the toast only
* names the workspace once the user has moved to another one (design-squad journey).
*/
function showAgentNotStartedToast(agent: string | null, workspaceName: string): void {
if (isOrcaCloudAccountAgent(agent)) {
showCloudAgentNotStartedToast(agent, workspaceName, { onlyWhenAway: true })
}
}
export function useCloudStartComposer({
active,
availability,
@@ -121,12 +109,10 @@ export function useCloudStartComposer({
})
const { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, cloudSelected)
const setupNeeded = cloudSetupRequired(steps, { teamSwitch: switchTeam !== null })
// The agent has no Orca Cloud account (the user skipped setting it up). It is still created with
// the workspace: its launch holds on the pane's "Set up once" strip and never reaches the agent's
// own /login (PC-3b, #385), and its first prompt waits for it.
const agentUnset =
cloudSelected && steps.some((step) => step.id === 'agent' && step.state === 'optional')
const onCreateRaw = useCloudOnlyCreate({
// An agent with no Orca Cloud account is still created with the workspace: its launch holds on
// the pane's "Set up once" strip and never reaches the agent's own /login (PC-3b, #385). In the
// workspace the strip asks; away, the sidebar row's mark does (N-03: no toast follows the user).
const onCreate = useCloudOnlyCreate({
cloudOnly,
createWorkspace,
prompt,
@@ -134,12 +120,6 @@ export function useCloudStartComposer({
profileId,
onClose
})
const onCreate = useCallback(() => {
onCreateRaw()
if (agentUnset) {
showAgentNotStartedToast(agent, prompt)
}
}, [agent, agentUnset, onCreateRaw, prompt])
const { markTouched } = useRunOnDefault({
active,
@@ -0,0 +1,65 @@
import { Cloud } from 'lucide-react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import { useAppStore } from '@/store'
import { holdCloudAgentLaunch } from '@/lib/orca-cloud/cloud-agent-launch-hold'
import { setOrcaCloudAgentAccountsStateForTest } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
import { CloudAgentHeldMark } from './CloudAgentHeldMark'
// A cloud workspace row whose Claude waits on its pane's "Set up once" strip (N-03).
function seed(): void {
setOrcaCloudAgentAccountsStateForTest({
load: {
kind: 'ready',
status: {
credentials: {
claude: { state: 'none' },
codex: { state: 'none' },
providerEnabled: { claude: true, codex: true }
},
sources: { defaults: {}, coveredByKey: { claude: false, codex: false } }
}
},
setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } },
keyKinds: { claude: null, codex: null },
queue: { agents: [], index: 0 }
})
useAppStore.setState({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mark reads only tab ids
tabsByWorktree: { gudgeon: [{ id: 'web-terminal-g' }] } as never
})
void holdCloudAgentLaunch({
decided: true,
paneKey: 'tab:web-terminal-g',
agent: 'claude',
prompt: null
})
}
function Row(): React.JSX.Element {
return (
<div className="flex w-[300px] items-center gap-1.5 rounded-md bg-accent/40 px-2 py-1.5 text-[13px]">
<Cloud className="size-3 text-muted-foreground" aria-hidden />
<span>gudgeon</span>
<CloudAgentHeldMark worktreeId="gudgeon" />
</div>
)
}
export const sidebarRowMark = defineFixture({
width: 340,
render: () => {
seed()
return (
<div className="h-[110px] p-3">
<Row />
</div>
)
},
interact: async (frame: HTMLElement) => {
await new Promise((resolve) => setTimeout(resolve, 50))
const mark = frame.querySelector<HTMLElement>('[data-cloud-agent-held]')
mark?.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
mark?.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
await new Promise((resolve) => setTimeout(resolve, 900))
}
})
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store/index'
import {
holdCloudAgentLaunch,
releaseCloudAgentHold
} from '@/lib/orca-cloud/cloud-agent-launch-hold'
import {
setOrcaCloudAgentAccountsStateForTest,
type OrcaCloudAgentAccountsState
} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
import { CloudAgentHeldMark } from './CloudAgentHeldMark'
function accountsNotSetUp(): OrcaCloudAgentAccountsState {
return {
load: {
kind: 'ready',
status: {
credentials: {
claude: { state: 'none' },
codex: { state: 'none' },
providerEnabled: { claude: true, codex: true }
},
sources: { defaults: {}, coveredByKey: { claude: false, codex: false } }
}
},
setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } },
keyKinds: { claude: null, codex: null },
queue: { agents: [], index: 0 }
}
}
function renderMark(worktreeId: string) {
return render(
<TooltipProvider>
<CloudAgentHeldMark worktreeId={worktreeId} />
</TooltipProvider>
)
}
beforeEach(() => {
setOrcaCloudAgentAccountsStateForTest(accountsNotSetUp())
useAppStore.setState({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mark reads only tab ids
tabsByWorktree: { wt1: [{ id: 'web-terminal-1' }], wt2: [{ id: 'web-terminal-2' }] } as never
})
})
afterEach(() => {
act(() => releaseCloudAgentHold('tab:web-terminal-1', 'cancel'))
cleanup()
})
describe('sidebar mark for a held cloud agent (N-03)', () => {
it('marks the workspace whose pane holds Claude, with the tooltip text as its label', async () => {
await act(async () => {
void holdCloudAgentLaunch({
decided: true,
paneKey: 'tab:web-terminal-1',
agent: 'claude',
prompt: null
})
})
const { getByRole } = renderMark('wt1')
expect(getByRole('img').getAttribute('aria-label')).toBe('Claude isn’t set up for Orca Cloud')
})
it('marks only that workspace, and clears once the hold is released (Start)', async () => {
await act(async () => {
void holdCloudAgentLaunch({
decided: true,
paneKey: 'tab:web-terminal-1',
agent: 'claude',
prompt: null
})
})
expect(renderMark('wt2').queryByRole('img')).toBeNull()
cleanup()
const { queryByRole } = renderMark('wt1')
expect(queryByRole('img')).not.toBeNull()
act(() => releaseCloudAgentHold('tab:web-terminal-1', 'proceed'))
expect(queryByRole('img')).toBeNull()
})
})
@@ -0,0 +1,61 @@
import React from 'react'
import { KeyRound } from 'lucide-react'
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { agentName } from '@/components/settings/agent-accounts/orca-cloud-accounts-copy'
import {
cloudAgentHoldTabId,
useCloudAgentHolds,
type CloudAgentHold
} from '@/lib/orca-cloud/cloud-agent-launch-hold'
/** The hold waiting in one of this workspace's tabs, if any. */
export function useWorktreeCloudAgentHold(worktreeId: string): CloudAgentHold | null {
const holds = useCloudAgentHolds()
const tabIds = useAppStore((state) => state.tabsByWorktree[worktreeId])
if (holds.length === 0 || !tabIds?.length) {
return null
}
const ids = new Set(tabIds.map((tab) => tab.id))
return holds.find((hold) => ids.has(cloudAgentHoldTabId(hold.paneKey))) ?? null
}
/**
* N-03 (design-squad): the sidebar row's mark for a workspace whose agent waits on its pane's strip
* ("Set up once" or a sign-in renewal). It replaces the toast that followed the user around: away
* from the workspace this mark is the signal; in it, the strip asks.
*/
export function CloudAgentHeldMark({
worktreeId
}: {
worktreeId: string
}): React.JSX.Element | null {
const t = useTranslate()
const hold = useWorktreeCloudAgentHold(worktreeId)
if (!hold) {
return null
}
const agent = agentName(hold.agent, t)
const label =
hold.reason === 'accountExpired'
? t('orcaCloud.agentHold.expired', '{{agent}} sign-in expired', { agent })
: t('orcaCloud.agentHold.notSetUp', '{{agent}} isn’t set up for Orca Cloud', { agent })
return (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label={label}
data-cloud-agent-held={hold.reason}
className="inline-flex size-3.5 shrink-0 items-center justify-center text-status-warning-text"
>
<KeyRound className="size-3" aria-hidden />
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -13,6 +13,7 @@ import {
} from '../identity/worktree-card-header-items'
import { WorktreeCardSshHostControl } from '../host/WorktreeCardSshHostControl'
import { WorktreeCardHostMark } from '../host/WorktreeCardHostMark'
import { CloudAgentHeldMark } from '@/components/orca-cloud/CloudAgentHeldMark'
import { WorktreeTitleInlineRename } from '../identity/WorktreeTitleInlineRename'
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
@@ -112,6 +113,9 @@ export function WorktreeCardHeader({
onBeginEditingConsumed={affiliateListMode ? undefined : () => setRenamingWorktreeId(null)}
/>
{/* N-03: the agent waits on its pane's strip (Orca Cloud): this mark is the signal away. */}
{!titleRenaming ? <CloudAgentHeldMark worktreeId={worktree.id} /> : null}
{typeof worktree.firstAgentMessageRenameError === 'string' &&
worktree.firstAgentMessageRenameError.length > 0 &&
!titleRenaming ? (
@@ -24,6 +24,7 @@ import { WorktreeCardSshHostControl } from '../host/WorktreeCardSshHostControl'
import { WorktreeHostContextBadge } from '../host/WorktreeHostContextBadge'
import { WorktreeCardHostMark, WorktreeCardHostStateWord } from '../host/WorktreeCardHostMark'
import { WorktreeTitleInlineRename } from '../identity/WorktreeTitleInlineRename'
import { CloudAgentHeldMark } from '@/components/orca-cloud/CloudAgentHeldMark'
import { cardElement } from './card-template-compiler'
type Card = WorktreeCardController
@@ -35,24 +36,28 @@ const withoutTitleTooltip = (title: React.ReactElement): React.ReactElement => t
function title(props: Readonly<Record<string, unknown>>, card: Card): React.ReactNode {
const { affiliateListMode, worktree } = card
return (
<WorktreeTitleInlineRename
displayName={card.visibleCardTitle}
disabled={card.isDeleting || affiliateListMode}
showUnreadEmphasis={card.showUnreadEmphasis}
dimReadTitle={props.dimWhenRead === true}
className="text-[13px] leading-5"
editingClassName="flex-1"
titleWrapper={props.tooltip === false ? withoutTitleTooltip : undefined}
onEditingChange={affiliateListMode ? undefined : card.setTitleRenaming}
onRename={card.handleRenameTitle}
beginEditing={
!affiliateListMode &&
shouldBeginWorktreeRename(card.renamingWorktreeId, worktree.id, card.renameRowKey)
}
onBeginEditingConsumed={
affiliateListMode ? undefined : () => card.setRenamingWorktreeId(null)
}
/>
<>
<WorktreeTitleInlineRename
displayName={card.visibleCardTitle}
disabled={card.isDeleting || affiliateListMode}
showUnreadEmphasis={card.showUnreadEmphasis}
dimReadTitle={props.dimWhenRead === true}
className="text-[13px] leading-5"
editingClassName="flex-1"
titleWrapper={props.tooltip === false ? withoutTitleTooltip : undefined}
onEditingChange={affiliateListMode ? undefined : card.setTitleRenaming}
onRename={card.handleRenameTitle}
beginEditing={
!affiliateListMode &&
shouldBeginWorktreeRename(card.renamingWorktreeId, worktree.id, card.renameRowKey)
}
onBeginEditingConsumed={
affiliateListMode ? undefined : () => card.setRenamingWorktreeId(null)
}
/>
{/* N-03: the agent waits on its pane's strip (Orca Cloud): this mark is the signal away. */}
{card.titleRenaming ? null : <CloudAgentHeldMark worktreeId={worktree.id} />}
</>
)
}
+4 -13
View File
@@ -23313,19 +23313,6 @@
"showMore": "Show more repositories…",
"folderDetail": "Orca Cloud starts from a GitHub repository. This project is a folder on this computer."
},
"cloudStart": {
"setUpAgentOnce": "Set up {{agent}} once",
"agentNotStartedShort": "{{agent}} isn’t set up for Orca Cloud, so it didn’t start.",
"agentNotStartedIn": "{{agent}} isn’t set up for Orca Cloud, so it didn’t start in {{workspace}}.",
"copyCode": "Copy code",
"openSettings": "Open Settings",
"signInBrowser": "Finish signing in to {{agent}} in your browser…",
"signInDevice": "Enter {{code}} at {{host}} to sign in to {{agent}}.",
"signInPaste": "Paste the {{agent}} setup token in Settings to finish.",
"signInSaving": "Saving…",
"agentWillNotStartShort": "{{agent}} isn’t set up for Orca Cloud, so it won’t start.",
"agentWillNotStartIn": "{{agent}} isn’t set up for Orca Cloud, so it won’t start in {{workspace}}."
},
"agentLabel": "Agent"
},
"JiraIssueWorkspace": {
@@ -27550,6 +27537,10 @@
"failedLocalNextV2": "Use Move to this computer from the sandbox panel to bring them over. Your files are safe in the cloud workspace."
},
"orcaCloud": {
"agentHold": {
"notSetUp": "{{agent}} isn’t set up for Orca Cloud",
"expired": "{{agent}} sign-in expired"
},
"continueOffer": {
"title": "An agent was interrupted by the restart",
"action": "Continue"
@@ -97,7 +97,16 @@ type Entry = CloudAgentHold & { release: (outcome: 'proceed' | 'cancel') => void
const holds = new Map<string, Entry>()
const listeners = new Set<() => void>()
/** Every current hold, as one stable snapshot for subscribers (rebuilt on each change). */
let holdsSnapshot: readonly CloudAgentHold[] = []
function notify(): void {
holdsSnapshot = [...holds.values()].map(({ paneKey, agent, prompt, reason }) => ({
paneKey,
agent,
prompt,
reason
}))
for (const listener of listeners) {
listener()
}
@@ -116,6 +125,20 @@ export function useCloudAgentHold(paneKey: string): CloudAgentHold | null {
)
}
/** All held launches (N-03: the sidebar row marks a workspace whose agent waits on its strip). */
export function useCloudAgentHolds(): readonly CloudAgentHold[] {
return useSyncExternalStore(
subscribe,
() => holdsSnapshot,
() => holdsSnapshot
)
}
/** The tab a hold's pane key belongs to: `tab:<id>` for a host tab, else `<tabId>:<leaf>`. */
export function cloudAgentHoldTabId(paneKey: string): string {
return paneKey.startsWith('tab:') ? paneKey.slice(4) : (paneKey.split(':')[0] ?? paneKey)
}
export function readCloudAgentHold(paneKey: string): CloudAgentHold | null {
return holds.get(paneKey) ?? null
}
@@ -1,195 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CloudToastView } from '@/components/orca-cloud/cloud-toast-stack'
const stack = vi.hoisted(() => ({ show: vi.fn(), hide: vi.fn() }))
vi.mock('@/components/orca-cloud/cloud-toast-stack', () => ({
showCloudToast: stack.show,
hideCloudToast: stack.hide
}))
vi.mock('@/components/new-workspace/OrcaCloudAgentSetupRow', () => ({
openOrcaCloudAgentAccounts: vi.fn()
}))
import { useAppStore } from '@/store/index'
import type { OrcaCloudAgentsStatus } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
import {
setOrcaCloudAgentAccountsStateForTest,
type OrcaCloudAgentAccountsState
} from './orca-cloud-agent-accounts-store'
import {
cloudAgentNotStartedMessage,
showCloudAgentNotStartedToast
} from './cloud-agent-not-started-toast'
const TOAST_ID = 'orca-cloud-agent-not-started'
function accounts(
claude: OrcaCloudAgentsStatus['credentials']['claude']
): OrcaCloudAgentAccountsState {
return {
load: {
kind: 'ready',
status: {
credentials: {
claude,
codex: { state: 'none' },
providerEnabled: { claude: true, codex: true }
},
sources: { defaults: {}, coveredByKey: { claude: false, codex: false } }
}
},
setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } },
keyKinds: { claude: null, codex: null },
queue: { agents: [], index: 0 }
}
}
/** The last view shown for the agent toast. */
function lastView(): CloudToastView | undefined {
const call = stack.show.mock.calls.findLast((args) => args[0] === TOAST_ID)
return call?.[1]
}
function lastText(): string | undefined {
return lastView()?.lines[0]?.text
}
const NOW = 1_800_000_000_000
/** The create for `name` runs (`phase`) and then finishes: the sandbox is up, the prompt may show. */
function creation(name: string, phase: string, status = 'creating'): void {
useAppStore.setState({
pendingWorktreeCreations: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the toast reads only these fields
c1: { creationId: 'c1', startedAt: NOW, status, phase, request: { name } } as never
}
})
}
function created(name: string): void {
creation(name, 'creating')
useAppStore.setState({ pendingWorktreeCreations: {} })
}
describe('cloud agent not-started toast', () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW })
stack.show.mockClear()
stack.hide.mockClear()
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' }))
useAppStore.setState({ activeWorktreeId: null, pendingWorktreeCreations: {} })
})
afterEach(() => {
vi.useRealTimers()
})
it('says it once, with no extra sentence, and names the workspace only after a switch', () => {
expect(cloudAgentNotStartedMessage('Claude', null)).toBe(
'Claude isn’t set up for Orca Cloud, so it didn’t start.'
)
expect(cloudAgentNotStartedMessage('Claude', 'fix-readme-typo')).toBe(
'Claude isn’t set up for Orca Cloud, so it didn’t start in fix-readme-typo.'
)
showCloudAgentNotStartedToast('claude', 'fix-readme-typo')
created('fix-readme-typo')
expect(lastText()).toBe('Claude isn’t set up for Orca Cloud, so it didn’t start.')
// DS-q: an action-required prompt, with "Set up Claude once" as the one primary button.
expect(lastView()?.priority).toBe('action')
expect(lastView()?.lines[0]?.action).toMatchObject({
label: 'Set up Claude once',
primary: true
})
// The create activates the new workspace, then the user moves elsewhere.
stack.show.mockClear()
useAppStore.setState({ activeWorktreeId: 'repo::new' })
expect(stack.show).not.toHaveBeenCalled()
useAppStore.setState({ activeWorktreeId: 'repo::other' })
expect(lastText()).toBe(
'Claude isn’t set up for Orca Cloud, so it didn’t start in fix-readme-typo.'
)
})
it('clears itself once the agent account turns active', () => {
showCloudAgentNotStartedToast('claude', 'w')
created('w')
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' }))
expect(stack.hide).not.toHaveBeenCalled()
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' }))
expect(stack.hide).toHaveBeenCalledWith(TOAST_ID)
})
it('"Set up Claude once" starts the sign-in right from the toast, which stays up', () => {
const start = vi.fn(async () => undefined)
Object.assign(window, {
api: {
orcaCloudAgents: {
start,
setups: vi.fn(async () => ({ claude: { phase: 'idle' }, codex: { phase: 'idle' } })),
onSetupChanged: vi.fn(() => () => undefined)
}
}
})
showCloudAgentNotStartedToast('claude', 'w')
created('w')
lastView()?.lines[0]?.action?.onClick()
expect(start).toHaveBeenCalledWith({ agent: 'claude' })
expect(stack.hide).not.toHaveBeenCalled()
})
it('a close by the user (by its logical id) ends the prompt for good', () => {
showCloudAgentNotStartedToast('claude', 'w')
created('w')
const options = stack.show.mock.calls.findLast((args) => args[0] === TOAST_ID)?.[2]
options?.onDismiss?.()
stack.show.mockClear()
// Nothing brings it back afterwards: a switch away no longer re-shows it.
useAppStore.setState({ activeWorktreeId: 'repo::new' })
useAppStore.setState({ activeWorktreeId: 'repo::other' })
expect(stack.show).not.toHaveBeenCalled()
})
it('in the workspace itself it stays hidden (its pane strip asks); away, it names it', () => {
showCloudAgentNotStartedToast('claude', 'fix-readme-typo', { onlyWhenAway: true })
created('fix-readme-typo')
expect(stack.show).not.toHaveBeenCalled()
useAppStore.setState({ activeWorktreeId: 'repo::new' })
useAppStore.setState({ activeWorktreeId: 'repo::other' })
expect(lastText()).toBe(
'Claude isn’t set up for Orca Cloud, so it didn’t start in fix-readme-typo.'
)
})
it('says it won’t start while the sandbox is starting, then didn’t start once ready', () => {
expect(cloudAgentNotStartedMessage('Claude', null, { starting: true })).toBe(
'Claude isn’t set up for Orca Cloud, so it won’t start.'
)
showCloudAgentNotStartedToast('claude', 'w')
// Still provisioning: nothing has started yet, so no prompt (DS-p).
creation('w', 'provisioning-vm')
expect(stack.show).not.toHaveBeenCalled()
creation('w', 'fetching')
expect(lastText()).toBe('Claude isn’t set up for Orca Cloud, so it won’t start.')
useAppStore.setState({ pendingWorktreeCreations: {} })
expect(lastText()).toBe('Claude isn’t set up for Orca Cloud, so it didn’t start.')
})
it('a refused create (the running limit) never shows the agent prompt (DS-p)', () => {
showCloudAgentNotStartedToast('claude', 'w')
creation('w', 'provisioning-vm')
creation('w', 'provisioning-vm', 'error')
useAppStore.setState({ pendingWorktreeCreations: {} })
expect(stack.show).not.toHaveBeenCalled()
})
it('follows the create it was asked for even when its name is generated later (DS-p)', () => {
// An empty name field: the create names the workspace itself ("chinook").
showCloudAgentNotStartedToast('claude', '')
creation('chinook', 'provisioning-vm')
expect(stack.show).not.toHaveBeenCalled()
useAppStore.setState({ pendingWorktreeCreations: {} })
expect(lastText()).toBe('Claude isn’t set up for Orca Cloud, so it didn’t start.')
// Away from it, the toast names the real workspace, not the empty field.
useAppStore.setState({ activeWorktreeId: 'chinook-id' })
useAppStore.setState({ activeWorktreeId: 'other' })
expect(lastText()).toBe('Claude isn’t set up for Orca Cloud, so it didn’t start in chinook.')
})
})
@@ -1,247 +0,0 @@
// After a cloud create whose agent has no Orca Cloud account, the workspace starts with a terminal
// (never the agent's /login). This one toast is the only sign-in prompt (design-squad journey rule;
// PC-1, PC-3).
// - It stays until the user acts on it or dismisses it.
// - It names the workspace once the user has moved to another one.
// - "Set up Claude once" runs the S0 sign-in right from the toast: Claude via setup-token in the
// browser, Codex via a device code. The toast shows progress and errors, with Try again.
// - It clears itself as soon as the account turns active.
import { hideCloudToast, showCloudToast } from '@/components/orca-cloud/cloud-toast-stack'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store/index'
import { agentName } from '@/components/settings/agent-accounts/orca-cloud-accounts-copy'
import type { OrcaCloudAccountAgent } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
import { cloudAgentSignInView, type ToastView } from './cloud-agent-sign-in-view'
import { orcaCloudAgentCoverageFromAccounts } from './orca-cloud-agent-accounts-model'
import {
readOrcaCloudAgentAccounts,
subscribeOrcaCloudAgentAccounts
} from './orca-cloud-agent-accounts-store'
const TOAST_ID = 'orca-cloud-agent-not-started'
let teardown: (() => void) | null = null
export function cloudAgentNotStartedMessage(
agent: string,
workspaceName: string | null,
{ starting = false }: { starting?: boolean } = {}
): string {
// While the sandbox is still starting nothing has failed yet: say it won't start (design-squad).
if (starting) {
return workspaceName
? translate(
'components.newWorkspace.cloudStart.agentWillNotStartIn',
'{{agent}} isn’t set up for Orca Cloud, so it won’t start in {{workspace}}.',
{ agent, workspace: workspaceName }
)
: translate(
'components.newWorkspace.cloudStart.agentWillNotStartShort',
'{{agent}} isn’t set up for Orca Cloud, so it won’t start.',
{ agent }
)
}
return workspaceName
? translate(
'components.newWorkspace.cloudStart.agentNotStartedIn',
'{{agent}} isn’t set up for Orca Cloud, so it didn’t start in {{workspace}}.',
{ agent, workspace: workspaceName }
)
: translate(
'components.newWorkspace.cloudStart.agentNotStartedShort',
'{{agent}} isn’t set up for Orca Cloud, so it didn’t start.',
{ agent }
)
}
/** True once the agent signs in from the Orca account or a key covers it. */
export function isCloudAgentCovered(agent: OrcaCloudAccountAgent): boolean {
const coverage = orcaCloudAgentCoverageFromAccounts(agent, readOrcaCloudAgentAccounts())
return coverage?.kind === 'account' || coverage?.kind === 'key'
}
/** A create counts as this toast's when it started at most this long before the toast was asked for. */
const CREATION_MATCH_SLACK_MS = 5_000
export function showCloudAgentNotStartedToast(
agent: OrcaCloudAccountAgent,
requestedName: string,
{
onlyWhenAway = false
}: {
/**
* The workspace's agent pane shows its own held strip (PC-3), so the toast appears only while
* the user is in another workspace, naming this one.
*/
onlyWhenAway?: boolean
} = {}
): void {
teardown?.()
const name = agentName(agent, translate)
const askedAt = Date.now()
// The create this prompt is about: the one with the requested name, else the newest create started
// around this call (the name is often generated later, e.g. an empty name becomes "chinook").
// Once found it is followed by id, and its real name is the one the toast shows.
let creationId: string | null = null
let workspaceName = requestedName.trim()
const creationFor = (state: ReturnType<typeof useAppStore.getState>) => {
const creations = Object.values(state.pendingWorktreeCreations ?? {})
if (creationId) {
return creations.find((creation) => creation.creationId === creationId)
}
const found =
creations.find((creation) => workspaceName && creation.request.name === workspaceName) ??
creations
.filter((creation) => creation.startedAt >= askedAt - CREATION_MATCH_SLACK_MS)
.sort((a, b) => b.startedAt - a.startedAt)[0]
if (found) {
creationId = found.creationId
workspaceName = found.request.name.trim() || workspaceName
}
return found
}
// The workspace this prompt is about is still being created (its sandbox is starting).
const isStarting = (state: ReturnType<typeof useAppStore.getState>): boolean =>
creationFor(state)?.status === 'creating'
// DS-p: the prompt is about a workspace that exists. It waits until the sandbox is up (the create
// moved past provisioning, or finished); a refused create (e.g. the team's running limit) never
// shows it, only its own error.
let sandboxUp = false
let sawCreation = false
const settle = (state: ReturnType<typeof useAppStore.getState>): 'up' | 'refused' | 'waiting' => {
const creation = creationFor(state)
if (creation) {
sawCreation = true
if (creation.status === 'error') {
return 'refused'
}
return creation.phase === 'fetching' || creation.phase === 'creating' ? 'up' : 'waiting'
}
// Gone from the pending list without an error: the workspace was created.
return sawCreation ? 'up' : 'waiting'
}
let switchedAway = false
const render = (): void => {
const signIn = cloudAgentSignInView(agent, readOrcaCloudAgentAccounts().setups[agent])
const view: ToastView = signIn ?? {
message: cloudAgentNotStartedMessage(
name,
switchedAway && workspaceName ? workspaceName : null,
{ starting: isStarting(useAppStore.getState()) }
),
action: {
label: translate(
'components.newWorkspace.cloudStart.setUpAgentOnce',
'Set up {{agent}} once',
{ agent: name }
),
primary: true,
run: () => void globalThis.window?.api?.orcaCloudAgents?.start({ agent })
}
}
// The shared cloud toast (DS-q): same chrome as the push notice, and as an action-required
// prompt it sits on top of the stack. Updating the same id keeps its place and state.
showCloudToast(
TOAST_ID,
{
priority: 'action',
lines: [
{
key: 'agent',
text: view.message,
...(view.description ? { description: view.description } : {}),
...(view.action
? {
action: {
label: view.action.label,
primary: view.action.primary === true,
// Keeps the toast: it turns into the sign-in's progress.
onClick: () => view.action?.run()
}
}
: {})
}
]
},
{
// Only a close by the user ends it; a hide on returning to the workspace doesn't.
onDismiss: () => {
if (!onlyWhenAway || switchedAway) {
teardown?.()
}
}
}
)
}
const update = (): void => {
if (!sandboxUp) {
return
}
if (onlyWhenAway && !switchedAway) {
hideCloudToast(TOAST_ID)
return
}
render()
}
const checkCreation = (state: ReturnType<typeof useAppStore.getState>): boolean => {
if (sandboxUp) {
return true
}
const result = settle(state)
if (result === 'refused') {
hideCloudToast(TOAST_ID)
teardown?.()
return false
}
if (result === 'up') {
sandboxUp = true
update()
}
return true
}
checkCreation(useAppStore.getState())
// The create activates the new workspace (first change); any later change means the user moved.
let createdOn: string | null | undefined
let starting = isStarting(useAppStore.getState())
const stopWorkspace = useAppStore.subscribe((state, previous) => {
if (!checkCreation(state)) {
return
}
const nowStarting = isStarting(state)
if (nowStarting !== starting) {
starting = nowStarting
update()
}
if (state.activeWorktreeId === previous.activeWorktreeId) {
return
}
if (createdOn === undefined) {
createdOn = state.activeWorktreeId ?? null
return
}
const away = state.activeWorktreeId !== createdOn
if (away !== switchedAway) {
switchedAway = away
update()
}
})
let lastPhase = readOrcaCloudAgentAccounts().setups[agent].phase
const stopAccounts = subscribeOrcaCloudAgentAccounts(() => {
if (isCloudAgentCovered(agent)) {
hideCloudToast(TOAST_ID)
teardown?.()
return
}
const phase = readOrcaCloudAgentAccounts().setups[agent].phase
if (phase !== lastPhase) {
lastPhase = phase
update()
}
})
teardown = () => {
stopWorkspace()
stopAccounts()
teardown = null
}
}
@@ -1,110 +0,0 @@
// What the agent prompt toast says while the S0 sign-in runs (split from
// cloud-agent-not-started-toast so that file stays within its size budget).
import { translate } from '@/i18n/i18n'
import { openOrcaCloudAgentAccounts } from '@/components/new-workspace/OrcaCloudAgentSetupRow'
import {
agentName,
setupFailureCopy
} from '@/components/settings/agent-accounts/orca-cloud-accounts-copy'
import type {
OrcaCloudAccountAgent,
OrcaCloudAgentSetup
} from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
export type ToastView = {
message: string
description?: string
/** `primary`: the next required step (design-squad's toast chrome, DS-q). */
action?: { label: string; run: () => void; primary?: boolean }
}
function deviceHost(url: string): string {
try {
return new URL(url).hostname
} catch {
return url
}
}
/** What the toast says while the S0 sign-in runs; null while it is idle (the plain prompt). */
export function cloudAgentSignInView(
agent: OrcaCloudAccountAgent,
setup: OrcaCloudAgentSetup
): ToastView | null {
const api = globalThis.window?.api?.orcaCloudAgents
const name = agentName(agent, translate)
switch (setup.phase) {
case 'idle':
case 'saved':
return null
case 'browser':
return {
message: translate(
'components.newWorkspace.cloudStart.signInBrowser',
'Finish signing in to {{agent}} in your browser…',
{ agent: name }
),
// Every phase has a button: an updated toast keeps the last one otherwise.
action: setup.canReopen
? {
label: translate('orcaCloudAccounts.openAgain', 'Open again'),
run: () => void api?.reopen({ agent })
}
: {
label: translate('orcaCloudAccounts.cancel', 'Cancel'),
run: () => void api?.cancel({ agent })
}
}
case 'preparing':
return {
message: translate('orcaCloudAccounts.preparing', 'Getting ChatGPT sign-in ready'),
action: {
label: translate('orcaCloudAccounts.cancel', 'Cancel'),
run: () => void api?.cancel({ agent })
}
}
case 'device':
return {
message: translate(
'components.newWorkspace.cloudStart.signInDevice',
'Enter {{code}} at {{host}} to sign in to {{agent}}.',
{ code: setup.userCode, host: deviceHost(setup.verificationUrl), agent: name }
),
action: {
label: translate('components.newWorkspace.cloudStart.copyCode', 'Copy code'),
primary: true,
run: () => void window.api.ui.writeClipboardText(setup.userCode).catch(() => {})
}
}
case 'paste':
// The token goes in Settings' masked field, never in a toast.
return {
message: translate(
'components.newWorkspace.cloudStart.signInPaste',
'Paste the {{agent}} setup token in Settings to finish.',
{ agent: name }
),
action: {
label: translate('components.newWorkspace.cloudStart.openSettings', 'Open Settings'),
primary: true,
run: openOrcaCloudAgentAccounts
}
}
case 'saving':
return {
message: translate('components.newWorkspace.cloudStart.signInSaving', 'Saving…')
}
case 'failed': {
const copy = setupFailureCopy(agent, setup.error, translate)
return {
message: copy.title,
...(copy.detail ? { description: copy.detail } : {}),
action: {
label: translate('orcaCloudAccounts.tryAgain', 'Try again'),
primary: true,
run: () => void api?.start({ agent })
}
}
}
}
}