diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx
index ba0b56c6ba1..4e8bf438777 100644
--- a/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx
+++ b/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx
@@ -22,16 +22,19 @@ function tabStillOpen(tabId: string): boolean {
export function CloudStructuredChatBanners({
tabId,
paneKey,
- statusKey
+ statusKey,
+ agent
}: {
tabId: string
/** The launch-hold key (from the session id). */
paneKey: string
/** The agent-status key (tab and session): the running strip (B2) reads the agent through it. */
statusKey: string
+ /** The chat's agent, for an idle chat with no status yet. */
+ agent?: string
}): React.JSX.Element {
const { key, active, strip } = useCloudHeldStrip(paneKey, null, { chat: true })
- const running = useCloudChatRunningStrip({ tabId, statusKey, holdKey: paneKey })
+ const running = useCloudChatRunningStrip({ tabId, statusKey, holdKey: paneKey, agent })
const shown = strip ?? running
useEffect(() => {
if (!active) {
diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx
index a53c70fc85f..45147d775d6 100644
--- a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx
+++ b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx
@@ -2,7 +2,7 @@
// CHAT-BANNERS B2: RA-11's running strip on a structured chat. Expired shows Renew; renewed confirms
// with no Restart (the chat's next message uses the renewed sign-in) and its × dismisses; a held
// launch's strip wins over it.
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -94,6 +94,7 @@ vi.stubGlobal(
)
const { CloudStructuredChatBanners } = await import('./CloudStructuredChatBanners')
+const { useCloudChatSendBlock } = await import('./use-cloud-chat-running-strip')
const mount = () =>
render(
@@ -139,3 +140,54 @@ describe('the running strip on a structured chat (B2)', () => {
expect(screen.queryByRole('button', { name: /Renew/ })).toBeNull()
})
})
+
+describe('an idle chat (no message yet, no status entry)', () => {
+ const IDLE_TAB = 'structured-agent-session-claude_idle'
+ const idle = () =>
+ render(
+
+
+
+ )
+
+ it("expired account: the strip shows, decided from the chat's own agent", () => {
+ stub.strip = { phase: 'expired', agent: 'claude' }
+ idle()
+ expect(screen.getAllByText(/Claude sign-in expired/).length).toBeGreaterThan(0)
+ expect(stub.calls.at(-1)?.paneKey).toBe(`${IDLE_TAB}:no-status`)
+ })
+
+ it('covered account: no strip', () => {
+ idle()
+ expect(screen.queryByRole('region')).toBeNull()
+ })
+
+ it('a held launch shows the held strip instead', () => {
+ stub.strip = { phase: 'expired', agent: 'claude' }
+ stub.held = true
+ idle()
+ expect(screen.getByRole('button', { name: 'Set up once' })).toBeTruthy()
+ })
+})
+
+describe('Send while the account is expired', () => {
+ const args = { tabId: TAB_ID, statusKey: STATUS_KEY, holdKey: HOLD_KEY, agent: 'claude' }
+
+ it('expired: Send is held with the Renew hint (typing is not touched)', () => {
+ stub.strip = { phase: 'expired', agent: 'claude' }
+ const { result } = renderHook(() => useCloudChatSendBlock(args))
+ expect(result.current).toBe('Renew Claude sign-in above to send')
+ })
+
+ it('renewed or covered: Send is free again', () => {
+ stub.strip = { phase: 'renewed', agent: 'claude', resumable: true }
+ expect(renderHook(() => useCloudChatSendBlock(args)).result.current).toBeUndefined()
+ stub.strip = null
+ expect(renderHook(() => useCloudChatSendBlock(args)).result.current).toBeUndefined()
+ })
+})
diff --git a/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts b/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts
index ef46a8abf49..9a5ab27a572 100644
--- a/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts
+++ b/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts
@@ -4,7 +4,10 @@
// with the renewed sign-in.
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
-import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status'
+import {
+ HOST_AGENT_SIGN_IN_AGENT_NAME,
+ type HostAgentSignInAgent
+} from '../../../../shared/host-agent-sign-in-status'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { useCloudAgentHold } from '@/lib/orca-cloud/cloud-agent-launch-hold'
import {
@@ -15,6 +18,7 @@ import { isOrcaCloudEnvironmentId } from '@/lib/orca-cloud/orca-cloud-environmen
import {
clearCloudRunningSignout,
cloudRunningStrip,
+ type CloudRunningStrip,
useCloudRunningAccountReads,
useCloudRunningSignoutVersion,
useWorkspaceUsesAccount
@@ -43,27 +47,33 @@ function useChatEnvironmentId(tabId: string): string | null {
})
}
-/**
- * statusKey: the chat's agent-status key (its tab and session); holdKey: its launch-hold key. A held
- * launch shows the held strip instead.
- */
-export function useCloudChatRunningStrip(args: {
+type ChatAccountArgs = {
tabId: string
statusKey: string
holdKey: string
-}): CloudHeldStrip | null {
- const t = useTranslate()
+ /** The chat's own agent: an idle chat (no message yet) has no status entry to name it. */
+ agent?: string
+}
+
+/**
+ * The RA-11 decision for a structured chat: expired / renewed / none. An idle chat with no status
+ * entry is decided from its own agent, so the strip shows as soon as the account expires, before a
+ * first message could fail. A held launch wins (null here).
+ */
+export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStrip | null {
const revision = useCloudRunningSignoutVersion()
const environmentId = useChatEnvironmentId(args.tabId)
const cloud = environmentId !== null && isOrcaCloudEnvironmentId(environmentId)
- const entry = useAppStore((state) =>
+ const status = useAppStore((state) =>
cloud ? (state.agentStatusByPaneKey[args.statusKey] ?? null) : null
)
+ const agentType = status?.agentType ?? args.agent
const agent: HostAgentSignInAgent | null =
- entry?.agentType === 'claude' ? 'claude' : entry?.agentType === 'codex' ? 'codex' : null
- const running = cloud && agent !== null
- useCloudRunningAccountReads(running)
- const accounts = useOrcaCloudAgentAccounts(running)
+ agentType === 'claude' ? 'claude' : agentType === 'codex' ? 'codex' : null
+ const watched = cloud && agent !== null
+ // Focus and the 30 s poll keep the account read fresh for any open cloud chat of an account agent.
+ useCloudRunningAccountReads(watched)
+ const accounts = useOrcaCloudAgentAccounts(watched)
// D13: only a workspace that runs the agent on the person's own account (never shared or keyed).
const credential =
agent && accounts.load.kind === 'ready' ? accounts.load.status.credentials[agent] : null
@@ -73,18 +83,18 @@ export function useCloudChatRunningStrip(args: {
credential.state !== 'expiring' &&
credential.state !== 'none'
const usesAccount = useWorkspaceUsesAccount(
- running ? environmentId : null,
+ watched ? environmentId : null,
agent,
expired,
orcaCloudAgentAccountsLoadedAt()
)
const held = useCloudAgentHold(args.holdKey) !== null
- if (!running) {
+ if (!watched || !agent) {
return null
}
- const strip = cloudRunningStrip({
+ return cloudRunningStrip({
paneKey: args.statusKey,
- entry,
+ entry: status ?? { agentType: agent, state: 'idle' },
accounts,
held,
now: orcaCloudAgentAccountsLoadedAt(),
@@ -92,6 +102,12 @@ export function useCloudChatRunningStrip(args: {
revision,
turnEndedFallback: true
})
+}
+
+/** The strip a structured chat shows for its account while its agent runs or waits idle. */
+export function useCloudChatRunningStrip(args: ChatAccountArgs): CloudHeldStrip | null {
+ const t = useTranslate()
+ const strip = useCloudChatAccountStrip(args)
if (!strip) {
return null
}
@@ -106,3 +122,17 @@ export function useCloudChatRunningStrip(args: {
}
return { model: cloudChatRunningStripModel(strip, t), prompt: null, actions }
}
+
+/**
+ * While the chat's account is expired, typing stays open and only Send is held, with this hint
+ * (the person's draft is never lost); Renew lifts it at once.
+ */
+export function useCloudChatSendBlock(args: ChatAccountArgs): string | undefined {
+ const t = useTranslate()
+ const strip = useCloudChatAccountStrip(args)
+ return strip?.phase === 'expired'
+ ? t('orcaCloud.chatSend.renewAbove', 'Renew {{agent}} sign-in above to send', {
+ agent: HOST_AGENT_SIGN_IN_AGENT_NAME[strip.agent]
+ })
+ : undefined
+}
diff --git a/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx b/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx
index fb311bf91e8..604f7eff689 100644
--- a/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx
+++ b/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx
@@ -1,6 +1,8 @@
import { translate } from '@/i18n/i18n'
import { useCloudChatHoldHint } from '../../agent-sign-in-strip/use-cloud-chat-hold-hint'
import { structuredCloudHoldKey } from '@/lib/orca-cloud/cloud-structured-launch-hold'
+import { useCloudChatSendBlock } from '../../agent-sign-in-strip/use-cloud-chat-running-strip'
+import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import {
managedRuntimeInstallCard,
@@ -33,7 +35,10 @@ const RESERVED: NativeChatInstallSlotState = { kind: 'reserved' }
/** Reserve unknown readiness with an editable composer and held sends; missing runtimes show installation, while read failures leave it alone. */
export function useNativeChatInstallSlot(
- props: Pick,
+ props: Pick<
+ NativeChatStructuredViewProps,
+ 'target' | 'agent' | 'isVisible' | 'sessionId' | 'tabId'
+ >,
agentLabel: string,
layout: ManagedRuntimeInstallLayout
): {
@@ -49,7 +54,15 @@ export function useNativeChatInstallSlot(
hostProgram
)
// CHAT-BANNERS: an Orca Cloud launch held on the chat's strip has no agent to send to yet.
- const cloudHoldHint = useCloudChatHoldHint(structuredCloudHoldKey(props.sessionId), '')
+ const cloudHoldKey = structuredCloudHoldKey(props.sessionId)
+ const cloudHoldHint = useCloudChatHoldHint(cloudHoldKey, '')
+ // While its Orca Cloud account is expired, the chat takes typing but holds Send (draft kept).
+ const cloudSendBlock = useCloudChatSendBlock({
+ tabId: props.tabId,
+ statusKey: structuredAgentSessionPaneKey(props.tabId, props.sessionId),
+ holdKey: cloudHoldKey,
+ agent: props.agent
+ })
const unavailableHint =
cloudHoldHint ??
(slot.kind === 'install'
@@ -57,7 +70,8 @@ export function useNativeChatInstallSlot(
: undefined)
return {
slot,
- sendBlockedReason: unavailableHint ?? installSlotSendBlockedReason(slot, agentLabel, t),
+ sendBlockedReason:
+ unavailableHint ?? cloudSendBlock ?? installSlotSendBlockedReason(slot, agentLabel, t),
unavailableHint
}
}
diff --git a/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx
index 5ca92b45986..153cafb5635 100644
--- a/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx
+++ b/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx
@@ -152,6 +152,7 @@ export function nativeChatPaneHeader(
tabId={view.tabId}
paneKey={structuredCloudHoldKey(view.sessionId)}
statusKey={structuredAgentSessionPaneKey(view.tabId, view.sessionId)}
+ agent={view.agent}
/>
)
const header =
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 1bb5e0ec9a2..dc213e5b036 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -28413,6 +28413,9 @@
"setUpNotStarted": "{{agent}} is set up. Open the workspace to start it.",
"notStartedRow": "{{agent}} hasn’t started"
},
+ "chatSend": {
+ "renewAbove": "Renew {{agent}} sign-in above to send"
+ },
"chatSignIn": {
"renewAbove": "Your {{agent}} sign-in for Orca Cloud didn’t work. Renew it above, then send your message again."
},