From 7eccb72a9acd5d014ddd18d7acccfef0afce4a18 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 11:21:37 -0800 Subject: [PATCH] wip chat-renew-inplace: idle strip + send block --- .../CloudStructuredChatBanners.tsx | 7 +- .../cloud-chat-running-strip.test.tsx | 54 +++++++++++++++- .../use-cloud-chat-running-strip.ts | 64 ++++++++++++++----- .../host-access/NativeChatInstallSlot.tsx | 20 +++++- .../pane/actions/NativeChatPaneHeader.tsx | 1 + src/renderer/src/i18n/locales/en.json | 3 + 6 files changed, 126 insertions(+), 23 deletions(-) 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." },