mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): RA-11 strip over a running Orca Cloud agent whose sign-in expires; strip reserves its height
- When the Orca account goes from active/expiring to expired/revoked, every running Orca Cloud Claude/Codex pane shows the strip (same component, place, height as #315): 'Your Claude sign-in expired' / 'Renew to keep working. Your conversation is kept.' / Renew (compact: 'Claude sign-in expired' + Renew). The process keeps running; nothing is parsed from terminal output. - One Renew clears state 1 everywhere; the panes that showed it move to state 2: 'Claude sign-in renewed' / 'Restart Claude to pick up where you left off.' (or 'Starts a new conversation.') / Restart Claude / Dismiss. Restart reuses the account-restart path (queuePaneAccountRestarts), which resumes the pane's own session. Never automatic; a turn that ends cleanly after the renewal clears it. - Kill switch / never offered: nothing new on a running pane. A held launch keeps #315's strip. - Account reads while a running cloud agent pane is mounted: on window focus and at most once a minute (one timer per window). - #315 P2: the strip is a pane-top banner (useReservePaneTopSpace), so the terminal refits below it and no row is covered; a spacer keeps it under a chromeless pane's title-row buttons. - Spec: terminal starts below the strip and its last row stays visible (held and running); state 2's Dismiss sits a full title row below the pane's ×. Removes the debug screenshot left in #315's spec and polls the resize assertion (it raced the layout).
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
type CloudStripActions
|
||||
} from './CloudAgentSignInStrip'
|
||||
import { cloudAgentStripModel } from './cloud-agent-strip-model'
|
||||
import { cloudRunningStripModel } from './cloud-running-strip-model'
|
||||
import type { CloudRunningStrip } from '@/lib/orca-cloud/cloud-running-agent-signout'
|
||||
|
||||
const noop = (): void => {}
|
||||
const ACTIONS: CloudStripActions = {
|
||||
@@ -24,29 +26,34 @@ const ACTIONS: CloudStripActions = {
|
||||
const PROMPT = 'Fix the flaky auth test and open a PR'
|
||||
|
||||
function Pane({
|
||||
reason,
|
||||
reason = 'accountExpired',
|
||||
view = null,
|
||||
agent = 'claude',
|
||||
width = 900
|
||||
width = 900,
|
||||
running
|
||||
}: {
|
||||
reason: CloudAgentStripReason
|
||||
reason?: CloudAgentStripReason
|
||||
view?: AgentSignInGateView | null
|
||||
agent?: HostAgentSignInAgent
|
||||
width?: number
|
||||
/** RA-11: the strip over a running agent instead of a held launch. */
|
||||
running?: CloudRunningStrip
|
||||
}): React.JSX.Element {
|
||||
const model = cloudAgentStripModel({
|
||||
agent,
|
||||
reason,
|
||||
view,
|
||||
hostLabel: translate('agentSignInStrip.sandbox', 'the sandbox'),
|
||||
t: translate
|
||||
})
|
||||
const model = running
|
||||
? cloudRunningStripModel(running, translate)
|
||||
: cloudAgentStripModel({
|
||||
agent,
|
||||
reason,
|
||||
view,
|
||||
hostLabel: translate('agentSignInStrip.sandbox', 'the sandbox'),
|
||||
t: translate
|
||||
})
|
||||
return (
|
||||
<div className="bg-background p-4">
|
||||
{/* Stands in for a terminal pane: its title row (split, close) stays above the strip. */}
|
||||
<div
|
||||
data-strip-pane
|
||||
className="relative h-[220px] overflow-hidden rounded-md border border-border bg-background"
|
||||
className="pane relative h-[220px] overflow-hidden rounded-md border border-border bg-background"
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="pane-title-overlay-layer">
|
||||
@@ -64,14 +71,21 @@ function Pane({
|
||||
<CloudStripLayer>
|
||||
<CloudAgentSignInStrip
|
||||
model={model}
|
||||
prompt={PROMPT}
|
||||
prompt={running ? null : PROMPT}
|
||||
width={cloudStripWidth(width)}
|
||||
actions={ACTIONS}
|
||||
/>
|
||||
</CloudStripLayer>
|
||||
<pre className="absolute inset-x-0 bottom-0 top-20 p-3 font-mono text-xs text-muted-foreground">
|
||||
~/acme/web $
|
||||
</pre>
|
||||
{/* The terminal: it starts below the strip (reserved, never covered) and keeps its rows. */}
|
||||
<div className="xterm-container overflow-hidden font-mono text-xs leading-4 text-muted-foreground">
|
||||
{running ? (
|
||||
<>
|
||||
<div>⏺ Reading src/auth/session.ts…</div>
|
||||
<div>⏺ API Error: 401 · Not logged in · Run /login</div>
|
||||
</>
|
||||
) : null}
|
||||
<div data-terminal-last-row>~/acme/web $</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -128,3 +142,22 @@ export const waitingNarrow = fixture({
|
||||
export const needExpiredNarrow = fixture({ reason: 'accountExpired', width: 520 })
|
||||
/** Below 360 px: one line and Renew; the detail stays the region's description. */
|
||||
export const needExpiredCompact = fixture({ reason: 'accountExpired', width: 320 })
|
||||
|
||||
/** RA-11 state 1: Claude was running when its Orca-account sign-in expired. */
|
||||
export const runningExpired = fixture({ running: { phase: 'expired', agent: 'claude' } })
|
||||
export const runningExpiredCompact = fixture({
|
||||
running: { phase: 'expired', agent: 'claude' },
|
||||
width: 320
|
||||
})
|
||||
/** RA-11 state 2: renewed; this pane's agent still needs a restart (resumes its conversation). */
|
||||
export const runningRenewed = fixture({
|
||||
running: { phase: 'renewed', agent: 'claude', resumable: true }
|
||||
})
|
||||
/** RA-11 state 2 when the session can't be resumed. */
|
||||
export const runningRenewedFresh = fixture({
|
||||
running: { phase: 'renewed', agent: 'claude', resumable: false }
|
||||
})
|
||||
export const runningRenewedNarrow = fixture({
|
||||
running: { phase: 'renewed', agent: 'claude', resumable: true },
|
||||
width: 520
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useReservePaneTopSpace } from '../terminal-pane/appearance/pane-top-banner/PaneTopWarningBanner'
|
||||
import type {
|
||||
CloudStripModel,
|
||||
CloudStripPrimary,
|
||||
@@ -69,11 +70,15 @@ function useLiveStripWidth(
|
||||
* buttons stay visible and clickable while it shows (RA-1). The pane and its fixture share it.
|
||||
*/
|
||||
export function CloudStripLayer({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
// A pane-top banner (RA-11): the pane reserves its height and the terminal refits below it, so
|
||||
// no row sits under the strip. The spacer keeps it below a chromeless pane's title-row buttons.
|
||||
const ref = useReservePaneTopSpace()
|
||||
return (
|
||||
<div
|
||||
data-cloud-strip-layer
|
||||
className="absolute inset-x-0 top-[var(--orca-pane-title-height,24px)] z-20"
|
||||
>
|
||||
<div ref={ref} data-cloud-strip-layer className="pane-top-banner">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none h-[var(--orca-pane-title-height,24px)] [.pane[data-has-title]_&]:hidden"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -282,12 +287,12 @@ export function CloudAgentSignInStrip({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{/* Renew is the whole action and the strip leaves on its own; Escape still cancels. */}
|
||||
{model.status === 'done' || primary?.action.kind === 'renew' ? null : (
|
||||
{!model.dismiss && (model.status === 'done' || primary?.action.kind === 'renew') ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t('agentSignInStrip.cancel', 'Cancel sign-in (Esc)')}
|
||||
aria-label={model.dismiss ?? t('agentSignInStrip.cancel', 'Cancel sign-in (Esc)')}
|
||||
onClick={actions.cancel}
|
||||
>
|
||||
<X aria-hidden />
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type CloudStripActions
|
||||
} from './CloudAgentSignInStrip'
|
||||
import { cloudAgentStripModel } from './cloud-agent-strip-model'
|
||||
import { CloudRunningStripPortal } from './CloudRunningStripPortal'
|
||||
|
||||
const ABANDON_CHECK_MS = 1_000
|
||||
|
||||
@@ -117,6 +118,10 @@ function CloudStripPortal({
|
||||
const gateActions = agentSignInCardActions(paneKey)
|
||||
const actions: CloudStripActions = {
|
||||
primary: (action) => {
|
||||
// RA-11's restart belongs to the running-pane strip; a held launch never offers it.
|
||||
if (action.kind === 'restart') {
|
||||
return
|
||||
}
|
||||
switch (action.kind) {
|
||||
case 'renew':
|
||||
openSettings('accounts', 'orca-cloud')
|
||||
@@ -167,7 +172,14 @@ export function CloudAgentSignInStripPortals({
|
||||
return (
|
||||
<>
|
||||
{controller.managedPanes.map((pane, index) => (
|
||||
<CloudStripPortal key={pane.id} pane={pane} tabId={controller.tabId} first={index === 0} />
|
||||
<React.Fragment key={pane.id}>
|
||||
<CloudStripPortal pane={pane} tabId={controller.tabId} first={index === 0} />
|
||||
<CloudRunningStripPortal
|
||||
pane={pane}
|
||||
tabId={controller.tabId}
|
||||
worktreeId={controller.worktreeId}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// RA-11: the strip over an Orca Cloud pane whose agent is already running when its Orca-account
|
||||
// sign-in expires. Same component, place and height as #315's pre-launch strip; the terminal keeps
|
||||
// running and refits below it, so its scrollback stays readable and selectable.
|
||||
import type React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { useAgentSignInGate } from '@/lib/agent-sign-in/agent-sign-in-gate-store'
|
||||
import { useCloudAgentHold } from '@/lib/orca-cloud/cloud-agent-launch-hold'
|
||||
import {
|
||||
orcaCloudAgentAccountsLoadedAt,
|
||||
useOrcaCloudAgentAccounts
|
||||
} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
|
||||
import { isOrcaCloudEnvironmentId } from '@/lib/orca-cloud/orca-cloud-environment-id'
|
||||
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import {
|
||||
clearCloudRunningSignout,
|
||||
cloudRunningStrip,
|
||||
useCloudRunningAccountReads,
|
||||
useCloudRunningSignoutVersion
|
||||
} from '@/lib/orca-cloud/cloud-running-agent-signout'
|
||||
import {
|
||||
CloudAgentSignInStrip,
|
||||
CloudStripLayer,
|
||||
cloudStripWidth,
|
||||
type CloudStripActions
|
||||
} from './CloudAgentSignInStrip'
|
||||
import { cloudRunningStripModel } from './cloud-running-strip-model'
|
||||
|
||||
function openAccounts(): void {
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
|
||||
store.openSettingsPage()
|
||||
}
|
||||
|
||||
export function CloudRunningStripPortal({
|
||||
pane,
|
||||
tabId,
|
||||
worktreeId
|
||||
}: {
|
||||
pane: ManagedPane
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
}): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const paneKey = makePaneKey(tabId, pane.leafId)
|
||||
useCloudRunningSignoutVersion()
|
||||
const ptyId = useAppStore(
|
||||
(state) => state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[pane.leafId] ?? null
|
||||
)
|
||||
const environmentId = useAppStore(
|
||||
(state) =>
|
||||
(ptyId ? parseRemoteRuntimePtyId(ptyId)?.environmentId : null) ??
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId)
|
||||
)
|
||||
const cloud = environmentId !== null && isOrcaCloudEnvironmentId(environmentId)
|
||||
const entry = useAppStore((state) =>
|
||||
cloud ? (state.agentStatusByPaneKey[paneKey] ?? null) : null
|
||||
)
|
||||
const running = cloud && (entry?.agentType === 'claude' || entry?.agentType === 'codex')
|
||||
useCloudRunningAccountReads(running)
|
||||
const accounts = useOrcaCloudAgentAccounts(running)
|
||||
const hold = useCloudAgentHold(paneKey)
|
||||
const gate = useAgentSignInGate(paneKey)
|
||||
const held = hold !== null || gate !== null
|
||||
if (!running) {
|
||||
return null
|
||||
}
|
||||
// The read that showed the renewal dates it (a pure read, never the render clock).
|
||||
const strip = cloudRunningStrip({
|
||||
paneKey,
|
||||
entry,
|
||||
accounts,
|
||||
held,
|
||||
now: orcaCloudAgentAccountsLoadedAt()
|
||||
})
|
||||
if (!strip) {
|
||||
return null
|
||||
}
|
||||
const actions: CloudStripActions = {
|
||||
primary: (action) => {
|
||||
if (action.kind === 'renew') {
|
||||
openAccounts()
|
||||
} else if (action.kind === 'restart' && ptyId) {
|
||||
// Never automatic: the person asked. The pane's own session resumes when it has one.
|
||||
useAppStore.getState().queuePaneAccountRestarts([ptyId], strip.agent)
|
||||
clearCloudRunningSignout(paneKey)
|
||||
}
|
||||
},
|
||||
submitCode: () => undefined,
|
||||
pasteCode: () => undefined,
|
||||
useCode: () => undefined,
|
||||
useApiKey: () => undefined,
|
||||
// State 2's ×; state 1 has none (Renew is the action) and Escape leaves it showing.
|
||||
cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(paneKey) : undefined)
|
||||
}
|
||||
return createPortal(
|
||||
<CloudStripLayer>
|
||||
<CloudAgentSignInStrip
|
||||
model={cloudRunningStripModel(strip, t)}
|
||||
prompt={null}
|
||||
width={cloudStripWidth(pane.container.clientWidth)}
|
||||
actions={actions}
|
||||
/>
|
||||
</CloudStripLayer>,
|
||||
pane.container,
|
||||
`cloud-running-strip-${pane.id}`
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export type CloudStripPrimary =
|
||||
| { kind: 'submitCode' }
|
||||
| { kind: 'retry' }
|
||||
| { kind: 'useDeviceCode' }
|
||||
/** RA-11: restart the running agent in its pane, resuming its conversation when it can. */
|
||||
| { kind: 'restart' }
|
||||
|
||||
export type CloudStripModel = {
|
||||
status: CloudStripStatus
|
||||
@@ -35,6 +37,8 @@ export type CloudStripModel = {
|
||||
otherWays: boolean
|
||||
/** Already on a code: ⋯ offers the paste fallback and Cancel instead of "Use a code". */
|
||||
codeStep?: boolean
|
||||
/** RA-11 state 2: a dismiss × with this label (the strip otherwise drops its × on Renew). */
|
||||
dismiss?: string
|
||||
/** One sentence for the polite region. */
|
||||
announcement: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// RA-11 copy (design-squad): the strip over an Orca Cloud pane whose agent was already running when
|
||||
// its Orca-account sign-in expired. Same words as #315's pre-launch strip, then the per-pane restart.
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import type { CloudRunningStrip } from '@/lib/orca-cloud/cloud-running-agent-signout'
|
||||
import { HOST_AGENT_SIGN_IN_AGENT_NAME } from '../../../../shared/host-agent-sign-in-status'
|
||||
import type { CloudStripModel } from './cloud-agent-strip-model'
|
||||
|
||||
export function cloudRunningStripModel(strip: CloudRunningStrip, t: Translate): CloudStripModel {
|
||||
const agent = HOST_AGENT_SIGN_IN_AGENT_NAME[strip.agent]
|
||||
const base = { code: null, paste: null, otherWays: false, description: null }
|
||||
if (strip.phase === 'expired') {
|
||||
return {
|
||||
...base,
|
||||
status: 'warning',
|
||||
title: t('agentSignInStrip.expired', 'Your {{agent}} sign-in expired', { agent }),
|
||||
compactTitle: t('agentSignInStrip.expiredCompact', '{{agent}} sign-in expired', { agent }),
|
||||
detail: t(
|
||||
'agentSignInStrip.runningExpiredDetail',
|
||||
'Renew to keep working. Your conversation is kept.'
|
||||
),
|
||||
primary: { label: t('agentSignInStrip.renew', 'Renew'), action: { kind: 'renew' } },
|
||||
announcement: t(
|
||||
'agentSignInStrip.runningExpiredAnnounce',
|
||||
'Your {{agent}} sign-in expired. Renew to keep working. Your conversation is kept.',
|
||||
{ agent }
|
||||
)
|
||||
}
|
||||
}
|
||||
const restart = t('agentSignInStrip.restartAgent', 'Restart {{agent}}', { agent })
|
||||
return {
|
||||
...base,
|
||||
status: 'done',
|
||||
title: t('agentSignInStrip.renewed', '{{agent}} sign-in renewed', { agent }),
|
||||
compactTitle: t('agentSignInStrip.renewed', '{{agent}} sign-in renewed', { agent }),
|
||||
detail: strip.resumable
|
||||
? t('agentSignInStrip.renewedResume', 'Restart {{agent}} to pick up where you left off.', {
|
||||
agent
|
||||
})
|
||||
: t('agentSignInStrip.renewedFresh', 'Starts a new conversation.'),
|
||||
primary: {
|
||||
label: restart,
|
||||
narrowLabel: t('agentSignInStrip.restart', 'Restart'),
|
||||
action: { kind: 'restart' }
|
||||
},
|
||||
dismiss: t('agentSignInStrip.dismiss', 'Dismiss'),
|
||||
announcement: t(
|
||||
'agentSignInStrip.renewedAnnounce',
|
||||
'{{agent}} sign-in renewed. Restart {{agent}} to use it.',
|
||||
{ agent }
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ function layoutPaneTopBanners(pane: HTMLElement, leaving?: HTMLElement): void {
|
||||
}
|
||||
|
||||
/** Reserves the banner's height at the top of its pane so the terminal refits below it. */
|
||||
function useReservePaneTopSpace(): React.RefObject<HTMLDivElement | null> {
|
||||
export function useReservePaneTopSpace(): React.RefObject<HTMLDivElement | null> {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const banner = ref.current
|
||||
|
||||
@@ -28048,7 +28048,16 @@
|
||||
"cancelSignIn": "Cancel sign-in",
|
||||
"copyAndOpen": "Copy code and open",
|
||||
"cancelledToast": "The agent didn’t start. The shell is still open.",
|
||||
"expiredCompact": "{{agent}} sign-in expired"
|
||||
"expiredCompact": "{{agent}} sign-in expired",
|
||||
"runningExpiredDetail": "Renew to keep working. Your conversation is kept.",
|
||||
"runningExpiredAnnounce": "Your {{agent}} sign-in expired. Renew to keep working. Your conversation is kept.",
|
||||
"restartAgent": "Restart {{agent}}",
|
||||
"renewed": "{{agent}} sign-in renewed",
|
||||
"renewedResume": "Restart {{agent}} to pick up where you left off.",
|
||||
"renewedFresh": "Starts a new conversation.",
|
||||
"restart": "Restart",
|
||||
"dismiss": "Dismiss",
|
||||
"renewedAnnounce": "{{agent}} sign-in renewed. Restart {{agent}} to use it."
|
||||
},
|
||||
"cloudAgentCheck": {
|
||||
"keyAgentSignedOut": "{{agent}} isn’t signed in.",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { OrcaCloudAgentsStatus } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
|
||||
import type { OrcaCloudAgentAccountsState } from './orca-cloud-agent-accounts-store'
|
||||
import {
|
||||
clearCloudRunningSignout,
|
||||
cloudRunningStrip,
|
||||
resetCloudRunningSignoutForTest
|
||||
} from './cloud-running-agent-signout'
|
||||
|
||||
function accounts(
|
||||
claude: OrcaCloudAgentsStatus['credentials']['claude'],
|
||||
extra: Partial<OrcaCloudAgentsStatus['credentials']> = {}
|
||||
): Pick<OrcaCloudAgentAccountsState, 'load' | 'keyKinds'> {
|
||||
return {
|
||||
load: {
|
||||
kind: 'ready',
|
||||
status: {
|
||||
credentials: {
|
||||
claude,
|
||||
codex: { state: 'none' },
|
||||
providerEnabled: { claude: true, codex: true },
|
||||
...extra
|
||||
},
|
||||
sources: { defaults: {}, coveredByKey: { claude: false, codex: false } }
|
||||
}
|
||||
},
|
||||
keyKinds: { claude: null, codex: null }
|
||||
}
|
||||
}
|
||||
|
||||
const RUNNING = { agentType: 'claude', state: 'working' as const, providerSession: { id: 's1' } }
|
||||
const strip = (
|
||||
account: ReturnType<typeof accounts>,
|
||||
entry: Parameters<typeof cloudRunningStrip>[0]['entry'] = RUNNING,
|
||||
now = 1_000,
|
||||
paneKey = 'tab:a'
|
||||
) => cloudRunningStrip({ paneKey, entry, accounts: account, held: false, now })
|
||||
|
||||
describe('cloudRunningStrip (RA-11)', () => {
|
||||
beforeEach(resetCloudRunningSignoutForTest)
|
||||
|
||||
it('shows nothing while the account works, and state 1 once it expires or is revoked', () => {
|
||||
expect(strip(accounts({ state: 'active' }))).toBeNull()
|
||||
expect(strip(accounts({ state: 'revoked' }))).toEqual({ phase: 'expired', agent: 'claude' })
|
||||
expect(strip(accounts({ state: 'expired' }), RUNNING, 1_000, 'tab:b')).toEqual({
|
||||
phase: 'expired',
|
||||
agent: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it('after Renew the panes that showed state 1 move to state 2, resumable when the pane has a session', () => {
|
||||
strip(accounts({ state: 'revoked' }))
|
||||
expect(strip(accounts({ state: 'active' }))).toEqual({
|
||||
phase: 'renewed',
|
||||
agent: 'claude',
|
||||
resumable: true
|
||||
})
|
||||
// A pane that never showed state 1 gets nothing.
|
||||
expect(strip(accounts({ state: 'active' }), RUNNING, 1_000, 'tab:other')).toBeNull()
|
||||
strip(
|
||||
accounts({ state: 'revoked' }),
|
||||
{ ...RUNNING, providerSession: undefined },
|
||||
1_000,
|
||||
'tab:c'
|
||||
)
|
||||
expect(
|
||||
strip(
|
||||
accounts({ state: 'active' }),
|
||||
{ ...RUNNING, providerSession: undefined },
|
||||
1_000,
|
||||
'tab:c'
|
||||
)
|
||||
).toMatchObject({ phase: 'renewed', resumable: false })
|
||||
})
|
||||
|
||||
it('never auto-restarts: state 2 stays until Restart or dismiss; a clean turn after renewal clears it', () => {
|
||||
strip(accounts({ state: 'revoked' }))
|
||||
expect(strip(accounts({ state: 'active' }), RUNNING, 2_000)?.phase).toBe('renewed')
|
||||
expect(strip(accounts({ state: 'active' }), RUNNING, 9_000)?.phase).toBe('renewed')
|
||||
// The agent's next call succeeded through egress: its turn ended cleanly after the renewal.
|
||||
const recovered = { ...RUNNING, state: 'done' as const, turnEndedAt: 3_000 }
|
||||
expect(strip(accounts({ state: 'active' }), recovered, 9_000)).toBeNull()
|
||||
strip(accounts({ state: 'revoked' }), RUNNING, 1_000, 'tab:d')
|
||||
strip(accounts({ state: 'active' }), RUNNING, 2_000, 'tab:d')
|
||||
clearCloudRunningSignout('tab:d')
|
||||
expect(strip(accounts({ state: 'active' }), RUNNING, 3_000, 'tab:d')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows nothing new under the kill switch, for a held launch, or without a running account agent', () => {
|
||||
expect(
|
||||
strip(
|
||||
accounts(
|
||||
{ state: 'revoked' },
|
||||
{
|
||||
providerEnabled: { claude: false, codex: true },
|
||||
providerState: { claude: 'disabled', codex: 'on' }
|
||||
}
|
||||
)
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
cloudRunningStrip({
|
||||
paneKey: 'tab:e',
|
||||
entry: RUNNING,
|
||||
accounts: accounts({ state: 'revoked' }),
|
||||
held: true,
|
||||
now: 1
|
||||
})
|
||||
).toBeNull()
|
||||
expect(strip(accounts({ state: 'revoked' }), { ...RUNNING, agentType: 'gemini' })).toBeNull()
|
||||
expect(strip(accounts({ state: 'revoked' }), null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
// RA-11: an Orca Cloud pane whose agent is already running when its Orca-account sign-in expires or
|
||||
// is revoked. State 1 ("expired") shows on every such pane; one Renew (S0) clears it everywhere and
|
||||
// the panes that showed it move to state 2 ("renewed": Restart, never automatic) until restarted,
|
||||
// dismissed, or the agent's next turn ends cleanly on its own. Decided from the Orca account (the
|
||||
// same read the build sheet uses), never from terminal output.
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status'
|
||||
import { agentTurnEndedUncleanly } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import { orcaCloudAgentCoverage } from './orca-cloud-agent-accounts-model'
|
||||
import {
|
||||
ensureFreshOrcaCloudAgentAccounts,
|
||||
type OrcaCloudAgentAccountsState
|
||||
} from './orca-cloud-agent-accounts-store'
|
||||
|
||||
export type CloudRunningStrip =
|
||||
| { phase: 'expired'; agent: HostAgentSignInAgent }
|
||||
| { phase: 'renewed'; agent: HostAgentSignInAgent; resumable: boolean }
|
||||
|
||||
/** Panes that showed state 1, and when their account came back (state 2). */
|
||||
const signedOut = new Map<string, { agent: HostAgentSignInAgent; renewedAt: number | null }>()
|
||||
const listeners = new Set<() => void>()
|
||||
let version = 0
|
||||
|
||||
function notify(): void {
|
||||
version += 1
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Re-renders when a pane's remembered state changes (dismiss, restart). */
|
||||
export function useCloudRunningSignoutVersion(): number {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => version,
|
||||
() => version
|
||||
)
|
||||
}
|
||||
|
||||
/** Forget a pane: dismissed, restarted, or its agent went away. */
|
||||
export function clearCloudRunningSignout(paneKey: string): void {
|
||||
if (signedOut.delete(paneKey)) {
|
||||
notify()
|
||||
}
|
||||
}
|
||||
|
||||
function isAccountAgent(agent: unknown): agent is HostAgentSignInAgent {
|
||||
return agent === 'claude' || agent === 'codex'
|
||||
}
|
||||
|
||||
/**
|
||||
* What a running Orca Cloud pane shows, or null. `entry` is the pane's agent status (the agent is
|
||||
* running); `held` is true while #315's launch hold or the per-workspace gate owns the pane.
|
||||
*/
|
||||
export function cloudRunningStrip(args: {
|
||||
paneKey: string
|
||||
entry:
|
||||
| (Pick<
|
||||
AgentStatusEntry,
|
||||
'agentType' | 'state' | 'turnEndedAt' | 'mainAgent' | 'interrupted'
|
||||
> & {
|
||||
providerSession?: unknown
|
||||
})
|
||||
| null
|
||||
accounts: Pick<OrcaCloudAgentAccountsState, 'load' | 'keyKinds'>
|
||||
held: boolean
|
||||
now: number
|
||||
}): CloudRunningStrip | null {
|
||||
const { paneKey, entry, accounts } = args
|
||||
const remembered = signedOut.get(paneKey)
|
||||
const agent = entry?.agentType
|
||||
if (args.held || !isAccountAgent(agent) || accounts.load.kind !== 'ready') {
|
||||
return null
|
||||
}
|
||||
const coverage = orcaCloudAgentCoverage(agent, accounts.load.status, accounts.keyKinds[agent])
|
||||
switch (coverage.kind) {
|
||||
case 'expired':
|
||||
if (!remembered || remembered.renewedAt !== null) {
|
||||
signedOut.set(paneKey, { agent, renewedAt: null })
|
||||
}
|
||||
return { phase: 'expired', agent }
|
||||
case 'account':
|
||||
case 'key': {
|
||||
if (!remembered) {
|
||||
return null
|
||||
}
|
||||
const renewedAt = remembered.renewedAt ?? args.now
|
||||
if (remembered.renewedAt === null) {
|
||||
signedOut.set(paneKey, { agent, renewedAt })
|
||||
}
|
||||
// Recovered on its own: a turn that ended cleanly after the renewal needs no restart.
|
||||
if (
|
||||
entry &&
|
||||
entry.state === 'done' &&
|
||||
(entry.turnEndedAt ?? 0) > renewedAt &&
|
||||
!agentTurnEndedUncleanly(entry)
|
||||
) {
|
||||
signedOut.delete(paneKey)
|
||||
return null
|
||||
}
|
||||
return { phase: 'renewed', agent, resumable: Boolean(entry?.providerSession) }
|
||||
}
|
||||
// Kill switch, never offered, not set up: nothing new on a running pane.
|
||||
case 'killSwitch':
|
||||
case 'unavailable':
|
||||
case 'notSetUp':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let liveReaders = 0
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
const LIVE_READ_MS = 60_000
|
||||
|
||||
function readNow(): void {
|
||||
void ensureFreshOrcaCloudAgentAccounts().catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* While any running Orca Cloud agent pane is mounted, keep the account read fresh: on window focus
|
||||
* and at most once a minute (the read itself is cached for 30 s). One timer for the whole window.
|
||||
*/
|
||||
export function useCloudRunningAccountReads(active: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
liveReaders += 1
|
||||
if (liveReaders === 1) {
|
||||
window.addEventListener('focus', readNow)
|
||||
timer = setInterval(readNow, LIVE_READ_MS)
|
||||
readNow()
|
||||
}
|
||||
return () => {
|
||||
liveReaders -= 1
|
||||
if (liveReaders === 0) {
|
||||
window.removeEventListener('focus', readNow)
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [active])
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function resetCloudRunningSignoutForTest(): void {
|
||||
signedOut.clear()
|
||||
notify()
|
||||
}
|
||||
@@ -180,6 +180,11 @@ export function subscribeOrcaCloudAgentAccounts(listener: () => void): () => voi
|
||||
}
|
||||
|
||||
/** Reads again when the last read is stale or missing, for a launch about to decide. */
|
||||
/** When the current account view was read (ms); 0 before the first read. */
|
||||
export function orcaCloudAgentAccountsLoadedAt(): number {
|
||||
return loadedAt
|
||||
}
|
||||
|
||||
/** A ready read from within the last 30 s; older reads may miss a revoke (RA-1). */
|
||||
export function isOrcaCloudAgentAccountsFresh(): boolean {
|
||||
return state.load.kind === 'ready' && Date.now() - loadedAt <= FRESH_MS
|
||||
|
||||
@@ -33,7 +33,7 @@ async function boxes(page: Page, id: string) {
|
||||
strip: await box('[data-cloud-sign-in-strip]'),
|
||||
text: await box('[data-strip-text]'),
|
||||
actions: await box('[data-strip-actions]'),
|
||||
terminalTop: (await box('pre')).y
|
||||
terminalTop: (await box('.xterm-container')).y
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +69,12 @@ test('narrowing the pane after mount re-lays the strip out; the title stays read
|
||||
}
|
||||
}, width)
|
||||
await expect(strip).toHaveAttribute('data-strip-width', layout)
|
||||
const titleBox = await strip.getByText(title, { exact: true }).boundingBox()
|
||||
// Readable: at least ~10 characters wide, never collapsed to one letter.
|
||||
await page.screenshot({ path: `/tmp/vdrv/strip-${width}.png` })
|
||||
expect(titleBox?.width ?? 0, `${width}px`).toBeGreaterThan(70)
|
||||
const titleLine = strip.locator('[data-strip-text] > span').first()
|
||||
await expect(titleLine).toHaveText(title)
|
||||
// Readable: at least ~10 characters wide, never collapsed to one letter (once laid out).
|
||||
await expect
|
||||
.poll(async () => (await titleLine.boundingBox())?.width ?? 0, { message: `${width}px` })
|
||||
.toBeGreaterThan(70)
|
||||
expect((await strip.boundingBox())?.height).toBe(56)
|
||||
await expect(strip.getByRole('button', { name: 'Renew' })).toBeVisible()
|
||||
}
|
||||
@@ -97,3 +99,43 @@ test('the pane’s split and close buttons stay hit-testable above the strip', a
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// RA-11 + #315 P2: the strip reserves its height; the terminal refits below it, so no row (and not
|
||||
// the last one) ever sits under the strip, held launch or running agent alike.
|
||||
test('the terminal starts below the strip and its last row stays visible', async ({ page }) => {
|
||||
for (const id of ['needExpired', 'runningExpired', 'runningRenewed', 'runningExpiredCompact']) {
|
||||
await page.goto(`/fixtures.html?id=agent-sign-in-strip/CloudAgentSignInStrip:${id}`)
|
||||
await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready')
|
||||
const strip = await page.locator('[data-cloud-sign-in-strip]').boundingBox()
|
||||
const terminal = await page.locator('.xterm-container').boundingBox()
|
||||
const lastRow = await page.locator('[data-terminal-last-row]').boundingBox()
|
||||
const pane = await page.locator('[data-strip-pane]').boundingBox()
|
||||
if (!strip || !terminal || !lastRow || !pane) {
|
||||
throw new Error(`missing bounds in ${id}`)
|
||||
}
|
||||
expect(terminal.y, id).toBeGreaterThanOrEqual(strip.y + strip.height)
|
||||
expect(lastRow.y, id).toBeGreaterThanOrEqual(strip.y + strip.height)
|
||||
expect(lastRow.y + lastRow.height, id).toBeLessThanOrEqual(pane.y + pane.height)
|
||||
}
|
||||
})
|
||||
|
||||
test('RA-11: state 1 offers Renew only; state 2 offers Restart and a dismiss clear of the pane buttons', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/fixtures.html?id=agent-sign-in-strip/CloudAgentSignInStrip:runningExpired')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready')
|
||||
const strip = page.locator('[data-cloud-sign-in-strip]')
|
||||
await expect(strip).toContainText('Renew to keep working. Your conversation is kept.')
|
||||
await expect(strip.getByRole('button')).toHaveCount(1)
|
||||
await page.goto('/fixtures.html?id=agent-sign-in-strip/CloudAgentSignInStrip:runningRenewed')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready')
|
||||
await expect(strip).toContainText('Restart Claude to pick up where you left off.')
|
||||
await expect(strip.getByRole('button', { name: 'Restart Claude' })).toBeVisible()
|
||||
const dismiss = await strip.getByRole('button', { name: 'Dismiss' }).boundingBox()
|
||||
const close = await page.getByRole('button', { name: 'Close pane', exact: true }).boundingBox()
|
||||
if (!dismiss || !close) {
|
||||
throw new Error('missing bounds')
|
||||
}
|
||||
// Not stacked next to the pane's ×: the strip sits a whole title row lower.
|
||||
expect(dismiss.y).toBeGreaterThanOrEqual(close.y + close.height)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user