mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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 ? (
|
||||
|
||||
+23
-18
@@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user