diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 10a436f21b3..1e867dcc7d1 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -423,7 +423,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ expanded={expanded} isInterrupted={isInterrupted} stoppedByUser={agentMainAgentVerdict(agent.entry) === 'cancellation'} - lastAssistantMessage={lastAssistantMessage} + lastAssistantMessage={cloudStatus ? '' : lastAssistantMessage} /> ) diff --git a/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx b/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx index 3a666db9a8b..daba491c714 100644 --- a/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx +++ b/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx @@ -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 = ( + + ) + if (tooltip === null) { + return button + } return ( - - - + + {button} + + {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 ( ) } -/** 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} + + {' - '} + {status.agentName} + ) } diff --git a/src/renderer/src/components/orca-cloud/use-cloud-agent-row-sign-in.ts b/src/renderer/src/components/orca-cloud/use-cloud-agent-row-sign-in.ts index e2c7ce22b73..98464fe0292 100644 --- a/src/renderer/src/components/orca-cloud/use-cloud-agent-row-sign-in.ts +++ b/src/renderer/src/components/orca-cloud/use-cloud-agent-row-sign-in.ts @@ -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, @@ -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) } } diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/CompactAgentRowCloudSignIn.fixture.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/CompactAgentRowCloudSignIn.fixture.tsx index 090ba4b61de..d19e8522a1a 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/CompactAgentRowCloudSignIn.fixture.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/CompactAgentRowCloudSignIn.fixture.tsx @@ -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' +) diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.cloud-sign-in.test.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.cloud-sign-in.test.tsx index 12ef1abd242..9d5bee296d6 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.cloud-sign-in.test.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.cloud-sign-in.test.tsx @@ -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( @@ -149,99 +191,106 @@ function renderRow(agent: DashboardAgentRowData, onActivate = () => {}): HTMLEle ) }) - 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', () => { diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx index 96b080d7b46..fc337f0c942 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx @@ -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 ? ( - + ) : ( () +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() +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-row-sign-in.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-row-sign-in.ts index 447b9dd4f94..e9308be89c3 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-row-sign-in.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-row-sign-in.ts @@ -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 = 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 {