mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud): sign-in row state sticks while Agent accounts say not set up, expired or revoked
design-squad P1-P3 on #310 from the m4air run: - P1: after a restart leatherback fell back to the title-derived row ("Claude Code - Idle": prompt = title label, lastAssistantMessage = "Idle"), whose message made "no turn finished" false. While Agent accounts say not set up / expired / revoked, the sign-in state now wins over every done/idle/unverifiable row shape; only a sign-in made on the sandbox (cliLogin) clears it, not the Orca-account placeholder the sandbox reports as an API key. Covered accounts with a signed-out sandbox clear once a turn ends after the account became active (first covered read). - P2: sign-in rows use the catalog name (Claude/Codex), never the title or a stale reply. - P2: the tooltip opens below, aligned to the start, so the workspace name stays visible. - P3: the full row reads "Needs sign-in - Claude" / "Sign-in expired - Claude" and drops the stale message line.
This commit is contained in:
@@ -423,7 +423,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
expanded={expanded}
|
||||
isInterrupted={isInterrupted}
|
||||
stoppedByUser={agentMainAgentVerdict(agent.entry) === 'cancellation'}
|
||||
lastAssistantMessage={lastAssistantMessage}
|
||||
lastAssistantMessage={cloudStatus ? '' : lastAssistantMessage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,14 +4,12 @@ import React, { useCallback } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentStateDot } from '@/components/AgentStateDot'
|
||||
import {
|
||||
StateIndicatorTooltip,
|
||||
type StateIndicatorTooltipSide
|
||||
} from '@/components/StateIndicatorTooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { STATE_INDICATOR_TOOLTIP_DELAY_MS } from '@/components/StateIndicatorTooltip'
|
||||
import { openOrcaCloudAgentAccounts } from '@/components/new-workspace/OrcaCloudAgentSetupRow'
|
||||
|
||||
/** What the status shows: its label, and the tooltip naming the fix. */
|
||||
export type CloudAgentRowStatusText = { label: string; tooltip: string | null }
|
||||
/** What the status shows: its label, the agent's catalog name, and the tooltip naming the fix. */
|
||||
export type CloudAgentRowStatusText = { label: string; agentName: string; tooltip: string | null }
|
||||
|
||||
/** The composer's "Set up once" target, plus opening Settings, which the sidebar has closed. */
|
||||
export function openCloudAgentAccountsSettings(): void {
|
||||
@@ -19,17 +17,18 @@ export function openCloudAgentAccountsSettings(): void {
|
||||
useAppStore.getState().openSettingsPage()
|
||||
}
|
||||
|
||||
/** A focusable status button: the app tooltip names the fix on hover and on keyboard focus. */
|
||||
/**
|
||||
* A focusable status button: the app tooltip names the fix on hover and on keyboard focus. It
|
||||
* opens below, from the row's start, so the workspace name above the row stays visible.
|
||||
*/
|
||||
export function CloudAgentRowSignInTarget({
|
||||
tooltip,
|
||||
tooltipSide = 'top',
|
||||
ariaLabel,
|
||||
className,
|
||||
children
|
||||
}: {
|
||||
/** Null shows no tooltip (StateIndicatorTooltip passes the button through). */
|
||||
/** Null shows no tooltip. */
|
||||
tooltip: string | null
|
||||
tooltipSide?: StateIndicatorTooltipSide
|
||||
/** For the dot, whose button has no text of its own. */
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
@@ -39,49 +38,58 @@ export function CloudAgentRowSignInTarget({
|
||||
e.stopPropagation()
|
||||
openCloudAgentAccountsSettings()
|
||||
}, [])
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex min-w-0 shrink-0 cursor-pointer items-center rounded-sm text-left',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
data-cloud-sign-in-status=""
|
||||
onClick={handleClick}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
if (tooltip === null) {
|
||||
return button
|
||||
}
|
||||
return (
|
||||
<StateIndicatorTooltip label={tooltip} side={tooltipSide}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex min-w-0 shrink-0 cursor-pointer items-center rounded-sm text-left',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
data-cloud-sign-in-status=""
|
||||
onClick={handleClick}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</StateIndicatorTooltip>
|
||||
<Tooltip delayDuration={STATE_INDICATOR_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** The status dot: the attention dot, opening Agent accounts. Its tooltip is the button's. */
|
||||
export function CloudAgentRowSignInDot({
|
||||
status,
|
||||
tooltipSide,
|
||||
size
|
||||
}: {
|
||||
status: CloudAgentRowStatusText
|
||||
tooltipSide?: StateIndicatorTooltipSide
|
||||
size: 'sm' | 'md'
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<CloudAgentRowSignInTarget
|
||||
tooltip={status.tooltip}
|
||||
tooltipSide={tooltipSide}
|
||||
ariaLabel={status.label}
|
||||
ariaLabel={`${status.label} - ${status.agentName}`}
|
||||
>
|
||||
<AgentStateDot state="permission" size={size} title={null} />
|
||||
</CloudAgentRowSignInTarget>
|
||||
)
|
||||
}
|
||||
|
||||
/** The full row's label slot: the text links to Agent accounts; the rest of the slot opens the pane. */
|
||||
/**
|
||||
* The full row's label slot: "Needs sign-in - Claude", the status linking to Agent accounts and the
|
||||
* agent's catalog name after it; the rest of the slot opens the pane.
|
||||
*/
|
||||
export function CloudAgentRowSignInLabel({
|
||||
status,
|
||||
emphasis
|
||||
@@ -105,6 +113,12 @@ export function CloudAgentRowSignInLabel({
|
||||
>
|
||||
{status.label}
|
||||
</CloudAgentRowSignInTarget>
|
||||
<span
|
||||
className={emphasis === 'highlighted' ? 'text-foreground/70' : 'text-muted-foreground/65'}
|
||||
>
|
||||
{' - '}
|
||||
{status.agentName}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
readHostAgentSignInReport,
|
||||
subscribeHostAgentSignInReports
|
||||
} from '@/runtime/provider-accounts/sign-in/host-agent-sign-in-status-client'
|
||||
import { cloudAgentAccountActiveSince } from '@/lib/orca-cloud/cloud-agent-account-active-since'
|
||||
import { HOST_AGENT_SIGN_IN_AGENT_NAME } from '../../../../shared/host-agent-sign-in-status'
|
||||
import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
|
||||
|
||||
const subscribeNothing = (): (() => void) => () => {}
|
||||
@@ -42,8 +44,8 @@ function orcaCloudEnvironmentIdForWorktree(state: AppState, worktreeId: string):
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a cloud agent row that never ran a turn is not "Done": its agent isn't signed in.
|
||||
* Null for local, SSH and paired hosts, and for any row that has finished a turn.
|
||||
* Why a cloud agent row at rest is not "Done" or "Idle": its agent isn't signed in.
|
||||
* Null for local, SSH and paired hosts, and for working or waiting rows.
|
||||
*/
|
||||
export function useCloudAgentRowSignIn(
|
||||
agent: Pick<DashboardAgentRow, 'agentType' | 'entry' | 'tab' | 'childRow'>,
|
||||
@@ -52,7 +54,6 @@ export function useCloudAgentRowSignIn(
|
||||
const candidate = isCloudRowSignInCandidate({
|
||||
agentType: agent.agentType,
|
||||
dotState,
|
||||
entry: agent.entry,
|
||||
isChildRow: agent.childRow !== undefined
|
||||
})
|
||||
const worktreeId = agent.tab.worktreeId
|
||||
@@ -81,12 +82,17 @@ export function useCloudAgentRowSignIn(
|
||||
if (!environmentId || !signInAgent) {
|
||||
return null
|
||||
}
|
||||
return cloudAgentRowSignIn(signInAgent, accounts, hostAgentSignInEntry(report, signInAgent))
|
||||
return cloudAgentRowSignIn(signInAgent, accounts, hostAgentSignInEntry(report, signInAgent), {
|
||||
turnEndedAt: agent.entry.turnEndedAt,
|
||||
activeSince: cloudAgentAccountActiveSince(signInAgent)
|
||||
})
|
||||
}
|
||||
|
||||
export type CloudAgentRowStatus = {
|
||||
dotState: AgentDotState
|
||||
label: string
|
||||
/** The catalog name ("Claude", "Codex"), never a terminal title such as "Claude Code". */
|
||||
agentName: string
|
||||
/** Null in send-target mode, which owns row clicks; otherwise the status links to Settings. */
|
||||
tooltip: string | null
|
||||
}
|
||||
@@ -100,12 +106,14 @@ export function useCloudAgentRowStatus(
|
||||
): CloudAgentRowStatus | null {
|
||||
const t = useTranslate()
|
||||
const signIn = useCloudAgentRowSignIn(agent, dotState)
|
||||
if (!signIn) {
|
||||
const signInAgent = cloudRowSignInAgent(agent.agentType)
|
||||
if (!signIn || !signInAgent) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
dotState: 'permission',
|
||||
label: cloudAgentRowSignInLabel(signIn, t),
|
||||
agentName: HOST_AGENT_SIGN_IN_AGENT_NAME[signInAgent],
|
||||
tooltip: sendTargetStatus ? null : cloudAgentRowSignInTooltip(signIn, t)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -190,3 +190,33 @@ export const fullClaudeSignInExpiredTooltip = defineFixture({
|
||||
),
|
||||
interact: focusStatusText
|
||||
})
|
||||
|
||||
/** After a restart the pane can fall back to its title-derived row ("Claude Code - Idle"). */
|
||||
function titleDerivedRow(): DashboardAgentRow {
|
||||
const base = row('claude', null)
|
||||
return {
|
||||
...base,
|
||||
state: 'idle',
|
||||
startedAt: 0,
|
||||
entry: {
|
||||
...base.entry,
|
||||
state: 'working',
|
||||
prompt: 'Claude Code',
|
||||
lastAssistantMessage: 'Idle',
|
||||
stateStartedAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoked account, title-derived row after a restart: still "Sign-in expired - Claude". */
|
||||
export const claudeRevokedAfterRestart = cloudRowFixture(
|
||||
accounts({ state: 'revoked' }, { state: 'none' }),
|
||||
titleDerivedRow()
|
||||
)
|
||||
|
||||
export const fullClaudeRevokedAfterRestart = cloudRowFixture(
|
||||
accounts({ state: 'revoked' }, { state: 'none' }),
|
||||
titleDerivedRow(),
|
||||
'full'
|
||||
)
|
||||
|
||||
+146
-101
@@ -18,6 +18,7 @@ import type * as HostAgentSignInClient from '@/runtime/provider-accounts/sign-in
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../../../shared/remote-runtime/host-catalog/runtime-environments'
|
||||
import { makeTabWithIds } from '@/components/activity/ActivityPrototypePage-test-fixtures'
|
||||
import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
|
||||
import { _resetCloudAgentAccountActiveSinceForTest } from '@/lib/orca-cloud/cloud-agent-account-active-since'
|
||||
import { CompactAgentRow } from './worktree-card-compact-agent-row'
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
@@ -118,6 +119,41 @@ function makeMirroredAgent(worktreeId: string): DashboardAgentRowData {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row leatherback showed 30 s after a restart ("Claude Code - Idle", grey dot, "now"): the
|
||||
* title-derived fallback row (worktree-title-derived-agent-rows), built when no hook entry is
|
||||
* live. Its prompt is the title label and its message the translated "Idle".
|
||||
*/
|
||||
function makeTitleDerivedAgent(worktreeId: string): DashboardAgentRowData {
|
||||
return {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
tab: makeTabWithIds('tab-1', worktreeId),
|
||||
agentType: 'claude',
|
||||
rowSource: 'live',
|
||||
state: 'idle',
|
||||
startedAt: 0,
|
||||
entry: {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
state: 'working',
|
||||
prompt: 'Claude Code',
|
||||
updatedAt: 2000,
|
||||
stateStartedAt: 2000,
|
||||
stateHistory: [],
|
||||
agentType: 'claude',
|
||||
terminalTitle: '✳ Claude Code',
|
||||
lastAssistantMessage: 'Idle',
|
||||
observation: {
|
||||
origin: 'title',
|
||||
authorityId: 'title-derived',
|
||||
incarnation: 0,
|
||||
revision: 2000,
|
||||
observedAt: 2000,
|
||||
kind: 'snapshot'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the row reads only id, name and source.
|
||||
const cloudEnvironment = {
|
||||
id: CLOUD_ENV,
|
||||
@@ -136,12 +172,18 @@ afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
document.body.replaceChildren()
|
||||
_resetHostAgentSignInReportsForTest()
|
||||
_resetCloudAgentAccountActiveSinceForTest()
|
||||
})
|
||||
|
||||
function renderRow(agent: DashboardAgentRowData, onActivate = () => {}): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
rerenderRow(agent, onActivate)
|
||||
return container
|
||||
}
|
||||
|
||||
function rerenderRow(agent: DashboardAgentRowData, onActivate = () => {}): void {
|
||||
act(() => {
|
||||
root!.render(
|
||||
<TooltipProvider>
|
||||
@@ -149,99 +191,106 @@ function renderRow(agent: DashboardAgentRowData, onActivate = () => {}): HTMLEle
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
describe('CompactAgentRow on an Orca Cloud workspace whose agent is not signed in', () => {
|
||||
function signedIn(method: 'apiKey' | 'cliLogin' | 'orcaAccount'): void {
|
||||
rememberHostAgentSignInReport(CLOUD_ENV, {
|
||||
checkedAt: 1,
|
||||
agents: [{ agent: 'claude', state: 'signedIn', method }]
|
||||
})
|
||||
}
|
||||
|
||||
function signedOut(): void {
|
||||
rememberHostAgentSignInReport(CLOUD_ENV, {
|
||||
checkedAt: 1,
|
||||
agents: [{ agent: 'claude', state: 'signedOut' }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('CompactAgentRow on an Orca Cloud workspace: Agent accounts decide', () => {
|
||||
it('shows Needs sign-in instead of Done when Claude is not set up', () => {
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, true))
|
||||
expect(container.textContent).toContain('Needs sign-in - Claude')
|
||||
expect(container.textContent).not.toContain('Done')
|
||||
})
|
||||
|
||||
it('shows Sign-in expired when the Orca account sign-in expired', () => {
|
||||
it('shows Sign-in expired for an expired or revoked Orca account sign-in', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' }))
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, true))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
act(() => setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' })))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
})
|
||||
|
||||
it('wins over the title-derived idle row seen after a restart, and never says "Claude Code"', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
const container = renderRow(makeTitleDerivedAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
expect(container.textContent).not.toContain('Claude Code')
|
||||
expect(container.textContent).not.toContain('Idle')
|
||||
})
|
||||
|
||||
it('sticks across the row shapes that follow a restart', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
const container = renderRow(makeMirroredAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
rerenderRow(makeTitleDerivedAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
rerenderRow(makeAgent(CLOUD_WORKTREE, false))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
rerenderRow({ ...makeMirroredAgent(CLOUD_WORKTREE), state: 'unverifiable' })
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
})
|
||||
|
||||
it('wins over turn evidence while the account is not set up', () => {
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, false))
|
||||
expect(container.textContent).toContain('Needs sign-in - Claude')
|
||||
})
|
||||
|
||||
it('is not cleared by the sandbox reporting the Orca-account placeholder as signed in', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
signedIn('apiKey')
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, true))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
})
|
||||
|
||||
it('trusts the sandbox report: signedOut shows Needs sign-in even when the account covers it', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' }))
|
||||
rememberHostAgentSignInReport(CLOUD_ENV, {
|
||||
checkedAt: 1,
|
||||
agents: [{ agent: 'claude', state: 'signedOut' }]
|
||||
})
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, true))
|
||||
expect(container.textContent).toContain('Needs sign-in - Claude')
|
||||
it('is cleared by a sign-in made on the sandbox itself (/login there)', () => {
|
||||
signedIn('cliLogin')
|
||||
expect(renderRow(makeAgent(CLOUD_WORKTREE, true)).textContent).toContain('Done - Claude')
|
||||
})
|
||||
|
||||
it('keeps Done when the sandbox reports Claude signed in (a /login there)', () => {
|
||||
rememberHostAgentSignInReport(CLOUD_ENV, {
|
||||
checkedAt: 1,
|
||||
agents: [{ agent: 'claude', state: 'signedIn', method: 'cliLogin' }]
|
||||
})
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, true))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
it('clears when the account becomes active', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
const container = renderRow(makeTitleDerivedAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
act(() => setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })))
|
||||
expect(container.textContent).toContain('Claude Code - Idle')
|
||||
})
|
||||
|
||||
it('keeps Done for a covered agent idle after a turn', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' }))
|
||||
expect(renderRow(makeAgent(CLOUD_WORKTREE, false)).textContent).toContain('Done - Claude')
|
||||
})
|
||||
|
||||
it('covered but the sandbox is signed out: needs sign-in until a turn ends after the account became active', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' }))
|
||||
signedOut()
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, false))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
})
|
||||
|
||||
it('keeps Done after a finished turn even when the account is not set up', () => {
|
||||
const container = renderRow(makeAgent(CLOUD_WORKTREE, false))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
})
|
||||
|
||||
it('leaves a local workspace unchanged', () => {
|
||||
const container = renderRow(makeAgent(LOCAL_WORKTREE, true))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
expect(container.textContent).not.toContain('Needs sign-in')
|
||||
})
|
||||
|
||||
it('shows Needs sign-in for the row mirrored from the cloud host (no sessionBoundary)', () => {
|
||||
const container = renderRow(makeMirroredAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Needs sign-in - Claude')
|
||||
expect(container.textContent).not.toContain('Done')
|
||||
})
|
||||
|
||||
it('keeps Done for a mirrored row with a finished turn', () => {
|
||||
const mirrored = makeMirroredAgent(CLOUD_WORKTREE)
|
||||
const container = renderRow({
|
||||
...mirrored,
|
||||
entry: {
|
||||
...mirrored.entry,
|
||||
stateHistory: [{ state: 'working', prompt: '', startedAt: 600 }],
|
||||
turnEndedAt: 1000
|
||||
}
|
||||
})
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
})
|
||||
|
||||
it('keeps Done for a mirrored row with an assistant reply', () => {
|
||||
const mirrored = makeMirroredAgent(CLOUD_WORKTREE)
|
||||
const container = renderRow({
|
||||
...mirrored,
|
||||
entry: { ...mirrored.entry, lastAssistantMessage: 'Fixed the redirect.' }
|
||||
})
|
||||
expect(container.textContent).not.toContain('Needs sign-in')
|
||||
})
|
||||
|
||||
it('ignores sessionBoundary: turn evidence alone decides', () => {
|
||||
const finished = makeAgent(CLOUD_WORKTREE, false)
|
||||
const container = renderRow({
|
||||
...finished,
|
||||
entry: { ...finished.entry, sessionBoundary: true }
|
||||
})
|
||||
rerenderRow({ ...finished, entry: { ...finished.entry, turnEndedAt: Date.now() + 60_000 } })
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
})
|
||||
|
||||
it('shows Sign-in expired for a revoked Orca account sign-in', () => {
|
||||
it('leaves working rows alone', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
const container = renderRow(makeMirroredAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
const working = makeAgent(CLOUD_WORKTREE, true)
|
||||
const container = renderRow({
|
||||
...working,
|
||||
state: 'working',
|
||||
entry: { ...working.entry, state: 'working', prompt: 'Fix the login redirect' }
|
||||
})
|
||||
expect(container.textContent).not.toContain('Sign-in expired')
|
||||
})
|
||||
|
||||
it('shows nothing extra while the Orca account path is paused (providerState disabled)', () => {
|
||||
@@ -251,13 +300,15 @@ describe('CompactAgentRow on an Orca Cloud workspace whose agent is not signed i
|
||||
paused.load.status.credentials.providerState = { claude: 'disabled', codex: 'on' }
|
||||
}
|
||||
setOrcaCloudAgentAccountsStateForTest(paused)
|
||||
const container = renderRow(makeMirroredAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
expect(renderRow(makeMirroredAgent(CLOUD_WORKTREE)).textContent).toContain('Done - Claude')
|
||||
})
|
||||
|
||||
it('leaves the same mirrored shape on a local workspace unchanged', () => {
|
||||
const container = renderRow(makeMirroredAgent(LOCAL_WORKTREE))
|
||||
it('leaves a local workspace unchanged, whatever the row shape', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
const container = renderRow(makeAgent(LOCAL_WORKTREE, true))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
rerenderRow(makeTitleDerivedAgent(LOCAL_WORKTREE))
|
||||
expect(container.textContent).toContain('Claude Code - Idle')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -280,15 +331,18 @@ function fullRowLabel(container: HTMLElement): string | null {
|
||||
}
|
||||
|
||||
describe('DashboardAgentRow (full row) on an Orca Cloud workspace', () => {
|
||||
it('shows Needs sign-in instead of Done when Claude is not set up', () => {
|
||||
it('reads "Needs sign-in - Claude" when Claude is not set up', () => {
|
||||
const container = renderFullRow(makeAgent(CLOUD_WORKTREE, true))
|
||||
expect(fullRowLabel(container)).toBe('Needs sign-in')
|
||||
expect(fullRowLabel(container)).toBe('Needs sign-in - Claude')
|
||||
expect(container.querySelector('[aria-label="Needs attention"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows Sign-in expired when the Orca account sign-in expired', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' }))
|
||||
expect(fullRowLabel(renderFullRow(makeAgent(CLOUD_WORKTREE, true)))).toBe('Sign-in expired')
|
||||
it('reads "Sign-in expired - Claude" over the title-derived idle row', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
const container = renderFullRow(makeTitleDerivedAgent(CLOUD_WORKTREE))
|
||||
expect(fullRowLabel(container)).toBe('Sign-in expired - Claude')
|
||||
expect(container.textContent).not.toContain('Claude Code')
|
||||
expect(container.textContent).not.toContain('Idle')
|
||||
})
|
||||
|
||||
it('keeps Done for a covered agent idle after a turn', () => {
|
||||
@@ -296,28 +350,23 @@ describe('DashboardAgentRow (full row) on an Orca Cloud workspace', () => {
|
||||
expect(fullRowLabel(renderFullRow(makeAgent(CLOUD_WORKTREE, false)))).toBe('Done')
|
||||
})
|
||||
|
||||
it('shows Needs sign-in for the row mirrored from the cloud host', () => {
|
||||
expect(fullRowLabel(renderFullRow(makeMirroredAgent(CLOUD_WORKTREE)))).toBe('Needs sign-in')
|
||||
})
|
||||
|
||||
it('leaves a local workspace unchanged', () => {
|
||||
const container = renderFullRow(makeAgent(LOCAL_WORKTREE, true))
|
||||
expect(fullRowLabel(container)).toBe('Done')
|
||||
expect(container.textContent).not.toContain('Needs sign-in')
|
||||
expect(container.querySelector('[data-cloud-sign-in-status]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a row that renders before the Agent accounts read lands', () => {
|
||||
it('picks up Needs sign-in once the accounts load (restored row)', () => {
|
||||
it('picks up the sign-in state once the accounts load (restored row)', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest({
|
||||
...accounts({ state: 'none' }),
|
||||
load: { kind: 'loading' }
|
||||
})
|
||||
const container = renderRow(makeMirroredAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
act(() => setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' })))
|
||||
expect(container.textContent).toContain('Needs sign-in - Claude')
|
||||
const container = renderRow(makeTitleDerivedAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Claude Code - Idle')
|
||||
act(() => setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' })))
|
||||
expect(container.textContent).toContain('Sign-in expired - Claude')
|
||||
})
|
||||
|
||||
it('picks it up once the cloud environment is known', () => {
|
||||
@@ -325,14 +374,7 @@ describe('a row that renders before the Agent accounts read lands', () => {
|
||||
const container = renderFullRow(makeMirroredAgent(CLOUD_WORKTREE))
|
||||
expect(fullRowLabel(container)).toBe('Done')
|
||||
act(() => useAppStore.setState({ runtimeEnvironments: [cloudEnvironment] }))
|
||||
expect(fullRowLabel(container)).toBe('Needs sign-in')
|
||||
})
|
||||
|
||||
it('goes back to Done when Claude is set up', () => {
|
||||
const container = renderRow(makeMirroredAgent(CLOUD_WORKTREE))
|
||||
expect(container.textContent).toContain('Needs sign-in - Claude')
|
||||
act(() => setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })))
|
||||
expect(container.textContent).toContain('Done - Claude')
|
||||
expect(fullRowLabel(container)).toBe('Needs sign-in - Claude')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -347,9 +389,9 @@ function statusButtons(container: HTMLElement): HTMLButtonElement[] {
|
||||
}
|
||||
|
||||
/** Keyboard focus opens the app tooltip; read its content, not a title attribute. */
|
||||
function focusTooltip(button: HTMLButtonElement): string | null {
|
||||
function focusTooltip(button: HTMLButtonElement): Element | null {
|
||||
act(() => button.focus())
|
||||
return document.body.querySelector('[role="tooltip"]')?.textContent ?? null
|
||||
return document.body.querySelector('[role="tooltip"]')
|
||||
}
|
||||
|
||||
describe.each([
|
||||
@@ -362,25 +404,28 @@ describe.each([
|
||||
|
||||
it('has a focusable dot and text, with no native title', () => {
|
||||
const [dot, text] = statusButtons(render(makeAgent(CLOUD_WORKTREE, true)))
|
||||
expect(dot.getAttribute('aria-label')).toBe('Needs sign-in')
|
||||
expect(dot.getAttribute('aria-label')).toBe('Needs sign-in - Claude')
|
||||
expect(text.textContent).toBe('Needs sign-in')
|
||||
expect(dot.hasAttribute('title') || text.hasAttribute('title')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the app tooltip on keyboard focus of the text', () => {
|
||||
it('shows the app tooltip below and from the start on keyboard focus, clear of the name above', () => {
|
||||
const [, text] = statusButtons(render(makeAgent(CLOUD_WORKTREE, true)))
|
||||
expect(focusTooltip(text)).toBe('Set up once in Settings → Agent accounts')
|
||||
expect(focusTooltip(text)?.textContent).toBe('Set up once in Settings → Agent accounts')
|
||||
const content = document.body.querySelector('[data-side]')
|
||||
expect(content?.getAttribute('data-side')).toBe('bottom')
|
||||
expect(content?.getAttribute('data-align')).toBe('start')
|
||||
})
|
||||
|
||||
it('shows the app tooltip on keyboard focus of the dot', () => {
|
||||
const [dot] = statusButtons(render(makeAgent(CLOUD_WORKTREE, true)))
|
||||
expect(focusTooltip(dot)).toBe('Set up once in Settings → Agent accounts')
|
||||
expect(focusTooltip(dot)?.textContent).toBe('Set up once in Settings → Agent accounts')
|
||||
})
|
||||
|
||||
it('tells an expired sign-in to renew', () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' }))
|
||||
const [, text] = statusButtons(render(makeAgent(CLOUD_WORKTREE, true)))
|
||||
expect(focusTooltip(text)).toBe('Renew in Settings → Agent accounts')
|
||||
expect(focusTooltip(text)?.textContent).toBe('Renew in Settings → Agent accounts')
|
||||
})
|
||||
|
||||
it('clicking the text opens Agent accounts, not the pane', () => {
|
||||
|
||||
+4
-2
@@ -168,7 +168,9 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
|
||||
const held = heldMessageRef.current
|
||||
const stableMessage =
|
||||
turnHoldable && !currentMessage && held?.turn === turn ? held.message : undefined
|
||||
const secondary = getCompactAgentSecondary(agent, now, stableMessage, presentation, t)
|
||||
// A sign-in row names its agent by catalog name, never a stale reply or a title like "Claude Code".
|
||||
const secondary =
|
||||
cloudStatus?.agentName ?? getCompactAgentSecondary(agent, now, stableMessage, presentation, t)
|
||||
const { leadingText, trailingText } = getCompactAgentLineOrder(
|
||||
agent,
|
||||
dotState,
|
||||
@@ -245,7 +247,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
|
||||
<>
|
||||
{/* Show the actionable disabled reason over every hit area. */}
|
||||
{signIn ? (
|
||||
<CloudAgentRowSignInDot status={signIn} tooltipSide="right" size="sm" />
|
||||
<CloudAgentRowSignInDot status={signIn} size="sm" />
|
||||
) : (
|
||||
<AgentStateDot
|
||||
state={dotState}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// When this renderer first saw each agent covered (account or key) in the Orca account's Agent
|
||||
// accounts, for "a turn ended after the account became active". The server sends no such time, so
|
||||
// the first covered read stands in; any uncovered read resets it.
|
||||
import {
|
||||
HOST_AGENT_SIGN_IN_AGENTS,
|
||||
type HostAgentSignInAgent
|
||||
} from '../../../../shared/host-agent-sign-in-status'
|
||||
import { orcaCloudAgentCoverageFromAccounts } from './orca-cloud-agent-accounts-model'
|
||||
import {
|
||||
readOrcaCloudAgentAccounts,
|
||||
subscribeOrcaCloudAgentAccounts
|
||||
} from './orca-cloud-agent-accounts-store'
|
||||
|
||||
const activeSince = new Map<HostAgentSignInAgent, number>()
|
||||
let watching = false
|
||||
|
||||
function observe(): void {
|
||||
const accounts = readOrcaCloudAgentAccounts()
|
||||
if (accounts.load.kind !== 'ready') {
|
||||
return
|
||||
}
|
||||
for (const agent of HOST_AGENT_SIGN_IN_AGENTS) {
|
||||
const kind = orcaCloudAgentCoverageFromAccounts(agent, accounts)?.kind
|
||||
if (kind === 'account' || kind === 'key') {
|
||||
if (!activeSince.has(agent)) {
|
||||
activeSince.set(agent, Date.now())
|
||||
}
|
||||
} else {
|
||||
activeSince.delete(agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Null until this renderer has seen the agent covered. */
|
||||
export function cloudAgentAccountActiveSince(agent: HostAgentSignInAgent): number | null {
|
||||
if (!watching) {
|
||||
watching = true
|
||||
subscribeOrcaCloudAgentAccounts(observe)
|
||||
}
|
||||
observe()
|
||||
return activeSince.get(agent) ?? null
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function _resetCloudAgentAccountActiveSinceForTest(): void {
|
||||
activeSince.clear()
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
// Private layer, Orca Cloud only: a cloud Claude/Codex pane whose agent isn't signed in sits at its
|
||||
// own "Not logged in" screen, yet its row would read "Done". The row says why instead. The source of
|
||||
// truth is the Orca account's Agent accounts (credential state and providerState, via the shared
|
||||
// coverage selector), combined with "no turn finished"; the sandbox's own sign-in report can
|
||||
// clear it (signedIn) or add to it (signedOut).
|
||||
// own "Not logged in" screen, yet its row would read "Done" or "Idle". The row says why instead.
|
||||
// The source of truth is the Orca account's Agent accounts (credential state and providerState, via
|
||||
// the shared coverage selector). While they say not set up, expired or revoked, the sign-in state
|
||||
// wins over every idle/done row shape (hook, mirrored or title-derived).
|
||||
import type { AgentDotState } from '@/components/AgentStateDot'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import {
|
||||
HOST_AGENT_SIGN_IN_AGENTS,
|
||||
type HostAgentSignInAgent,
|
||||
@@ -22,64 +21,61 @@ export function cloudRowSignInAgent(agentType: string): HostAgentSignInAgent | n
|
||||
return HOST_AGENT_SIGN_IN_AGENTS.find((agent) => agent === agentType) ?? null
|
||||
}
|
||||
|
||||
/** The turn evidence on a row: what only a turn that ran and ended leaves behind. */
|
||||
export type CloudRowTurnEvidence = Pick<
|
||||
AgentStatusEntry,
|
||||
'lastAssistantMessage' | 'stateHistory' | 'turnEndedAt'
|
||||
>
|
||||
/** Row states that claim nothing live: a sign-in state replaces them. Working and waiting keep theirs. */
|
||||
const SIGN_IN_OVERRIDABLE_DOTS: ReadonlySet<AgentDotState> = new Set([
|
||||
'done',
|
||||
'idle',
|
||||
'unverifiable',
|
||||
'unconfirmed'
|
||||
])
|
||||
|
||||
/**
|
||||
* True when no turn has finished: no state history, no turn end and no assistant reply. Hook
|
||||
* markers such as sessionBoundary are not used: a row mirrored from a cloud host can lack them.
|
||||
*/
|
||||
export function hasNoFinishedTurn(entry: CloudRowTurnEvidence): boolean {
|
||||
return (
|
||||
(entry.stateHistory?.length ?? 0) === 0 &&
|
||||
entry.turnEndedAt === undefined &&
|
||||
!entry.lastAssistantMessage?.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a row that has not finished a turn can be waiting on sign-in: a finished turn proves the
|
||||
* agent reached its provider, so its Done stays.
|
||||
*/
|
||||
/** A main Claude/Codex row at rest; whether it is on Orca Cloud is checked after. */
|
||||
export function isCloudRowSignInCandidate(row: {
|
||||
agentType: string
|
||||
dotState: AgentDotState
|
||||
entry: CloudRowTurnEvidence
|
||||
isChildRow: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
!row.isChildRow &&
|
||||
(row.dotState === 'done' || row.dotState === 'idle') &&
|
||||
cloudRowSignInAgent(row.agentType) !== null &&
|
||||
hasNoFinishedTurn(row.entry)
|
||||
SIGN_IN_OVERRIDABLE_DOTS.has(row.dotState) &&
|
||||
cloudRowSignInAgent(row.agentType) !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent accounts decide: not set up -> needsSignIn, expired or revoked -> expired. A sandbox that
|
||||
* reports the agent signedIn (an account placeholder, a key, or a /login there) clears the row;
|
||||
* one that reports signedOut needs sign-in too. A paused or never-offered provider, or a read
|
||||
* not ready yet, shows nothing; the row updates when the read lands.
|
||||
* Agent accounts decide.
|
||||
* - Not set up -> needsSignIn; expired or revoked -> expired. These win over the row's turn
|
||||
* evidence and over the sandbox reporting an Orca-account placeholder or key as signed in; only
|
||||
* a sign-in made on the sandbox itself (cliLogin) clears them.
|
||||
* - Covered (account or key): normal, unless the sandbox reports signedOut, and then only until a
|
||||
* turn ends after the account became active (`activeSince`).
|
||||
* - Paused or never offered: only a sandbox signedOut shows needsSignIn. A read not ready yet
|
||||
* shows nothing; the row updates when it lands.
|
||||
*/
|
||||
export function cloudAgentRowSignIn(
|
||||
agent: HostAgentSignInAgent,
|
||||
accounts: OrcaCloudAgentAccountsRead,
|
||||
hostEntry: HostAgentSignInEntry | null
|
||||
hostEntry: HostAgentSignInEntry | null,
|
||||
turn: { turnEndedAt?: number; activeSince: number | null }
|
||||
): CloudAgentRowSignIn | null {
|
||||
if (hostEntry?.state === 'signedIn' || hostEntry?.state === 'notInstalled') {
|
||||
if (hostEntry?.state === 'notInstalled') {
|
||||
return null
|
||||
}
|
||||
const coverage = orcaCloudAgentCoverageFromAccounts(agent, accounts)?.kind
|
||||
if (coverage === 'expired') {
|
||||
return 'expired'
|
||||
if (coverage === 'expired' || coverage === 'notSetUp') {
|
||||
if (hostEntry?.state === 'signedIn' && hostEntry.method === 'cliLogin') {
|
||||
return null
|
||||
}
|
||||
return coverage === 'expired' ? 'expired' : 'needsSignIn'
|
||||
}
|
||||
if (coverage === 'notSetUp' || hostEntry?.state === 'signedOut') {
|
||||
return 'needsSignIn'
|
||||
if (hostEntry?.state !== 'signedOut') {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
const turnEndedSinceActive =
|
||||
turn.activeSince !== null &&
|
||||
turn.turnEndedAt !== undefined &&
|
||||
turn.turnEndedAt > turn.activeSince
|
||||
return turnEndedSinceActive ? null : 'needsSignIn'
|
||||
}
|
||||
|
||||
export function cloudAgentRowSignInLabel(signIn: CloudAgentRowSignIn, t: Translate): string {
|
||||
|
||||
Reference in New Issue
Block a user