From e95d45c0e68fa147e56c8bfb4ede5a0951e5b53d Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:46:37 -0800 Subject: [PATCH] feat(cloud): CHAT-RENEW-INPLACE every expired strip renews in place (held, running terminal, chat); idle-chat strip and send block; fixtures --- .../CloudAgentSignInStrip.tsx | 13 ++ .../CloudRenewInPlace.fixture.tsx | 136 +++++++++++++++ .../CloudRunningStripPortal.test.tsx | 9 +- .../CloudRunningStripPortal.tsx | 26 ++- .../CloudStripSettingsLink.tsx | 34 ++++ .../cloud-agent-strip-model.test.ts | 4 +- .../cloud-agent-strip-model.ts | 11 +- .../cloud-chat-running-strip.test.tsx | 10 +- .../cloud-renew-in-place.test.ts | 153 +++++++++++++++++ .../cloud-renew-in-place.ts | 159 ++++++++++++++++++ .../cloud-setup-strip-actions.ts | 2 +- .../use-cloud-chat-running-strip.ts | 28 ++- .../use-cloud-held-strip.ts | 28 ++- src/renderer/src/i18n/locales/en.json | 4 + 14 files changed, 587 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx create mode 100644 src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx create mode 100644 src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts create mode 100644 src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx index 3132c871845..5583bcb9e7c 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button' import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' import { CloudStripPasteForm } from './CloudStripPasteForm' import { StripPrimaryButton, stripPrimaryLink } from './CloudStripPrimaryButton' +import { CloudStripSettingsLink } from './CloudStripSettingsLink' import { DropdownMenu, DropdownMenuContent, @@ -103,6 +104,8 @@ export type CloudStripActions = { * setup's sign-in page, known only to main). Actions carrying a `url` copy it without this. */ linkFor?: (action: CloudStripPrimary) => ExternalLinkSource | null + /** CHAT-RENEW-INPLACE: the model's `settingsLink` ("Renew in Settings"). */ + openSettings?: () => void } /** GA-P3t: the strip's severity look; anything that isn't a warning or an error reads as info. */ @@ -336,6 +339,9 @@ export function CloudAgentSignInStrip({ ) : null} + {model.settingsLink && !stacked ? ( + + ) : null} {/* Renew and Restart are the whole action and the strip leaves on its own; Escape still cancels. */} {model.noClose || (!model.dismiss && @@ -378,6 +384,13 @@ export function CloudAgentSignInStrip({ {model.primary.label} ) : null} + {model.settingsLink ? ( + + ) : null} ) : null} diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx new file mode 100644 index 00000000000..387efbaaf71 --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx @@ -0,0 +1,136 @@ +// CHAT-RENEW-INPLACE: the in-place renewal, step by step, on all three surfaces (a launch held on the +// expired sign-in, a running terminal, a structured chat). design-squad's per-step copy. +import React from 'react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { translate } from '@/i18n/i18n' +import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { + CloudAgentSignInStrip, + CloudStripLayer, + cloudStripWidth, + type CloudStripActions +} from './CloudAgentSignInStrip' +import type { CloudStripModel } from './cloud-agent-strip-model' +import { cloudAgentStripModel } from './cloud-agent-strip-model' +import { cloudAgentAwaitingStartStripModel } from './cloud-agent-setup-strip-model' +import { cloudChatRunningStripModel, cloudRunningStripModel } from './cloud-running-strip-model' +import { cloudRenewInPlaceModel } from './cloud-renew-in-place' + +const noop = (): void => {} +const ACTIONS: CloudStripActions = { + primary: noop, + submitCode: noop, + pasteCode: noop, + useCode: noop, + useApiKey: noop, + cancel: noop, + openSettings: noop +} + +type Surface = 'held' | 'terminal' | 'chat' +type Step = 'expired' | 'browser' | 'checking' | 'rejected' | 'renewed' + +const SETUP: Record, OrcaCloudAgentSetup> = { + expired: { phase: 'idle' }, + browser: { phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true }, + checking: { + phase: 'browser', + startedAt: 0, + canReopen: true, + acceptsCode: true, + code: 'checking' + }, + rejected: { phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true, code: 'rejected' } +} + +function expiredModel(surface: Surface): CloudStripModel { + const strip = { phase: 'expired' as const, agent: 'claude' as const } + return surface === 'held' + ? cloudAgentStripModel({ + agent: 'claude', + reason: 'accountExpired', + view: null, + hostLabel: '', + t: translate + }) + : surface === 'chat' + ? cloudChatRunningStripModel(strip, translate) + : cloudRunningStripModel(strip, translate) +} + +function stepModel(surface: Surface, step: Step): CloudStripModel { + if (step !== 'renewed') { + return cloudRenewInPlaceModel({ + agent: 'claude', + expired: expiredModel(surface), + setup: SETUP[step], + t: translate + }) + } + const renewed = { phase: 'renewed' as const, agent: 'claude' as const, resumable: true } + // Step 5 is each surface's own: held waits for Start (team-lead B), the terminal offers Restart + // (RA-11), the chat's next message uses the renewed sign-in. + return surface === 'held' + ? cloudAgentAwaitingStartStripModel({ + agent: 'claude', + awaiting: 'renewed', + queuedPrompt: true, + t: translate + }) + : surface === 'chat' + ? cloudChatRunningStripModel(renewed, translate) + : cloudRunningStripModel(renewed, translate) +} + +function Frame({ + surface, + step, + width +}: { + surface: Surface + step: Step + width: number +}): React.JSX.Element { + return ( +
+
+ + + +
+
+ ) +} + +const fixture = (surface: Surface, step: Step, width = 900) => + defineFixture({ + width: width + 40, + render: () => + }) + +export const heldExpired = fixture('held', 'expired') +export const heldBrowser = fixture('held', 'browser') +export const heldChecking = fixture('held', 'checking') +export const heldRejected = fixture('held', 'rejected') +export const heldRenewed = fixture('held', 'renewed') +export const terminalExpired = fixture('terminal', 'expired') +export const terminalBrowser = fixture('terminal', 'browser') +export const terminalChecking = fixture('terminal', 'checking') +export const terminalRejected = fixture('terminal', 'rejected') +export const terminalRenewed = fixture('terminal', 'renewed') +export const chatExpired = fixture('chat', 'expired') +export const chatBrowser = fixture('chat', 'browser') +export const chatChecking = fixture('chat', 'checking') +export const chatRejected = fixture('chat', 'rejected') +export const chatRenewed = fixture('chat', 'renewed') +/** Narrow: the code field stacks under the text, and "Renew in Settings" gets its own line. */ +export const chatBrowserNarrow = fixture('chat', 'browser', 440) +export const terminalRejectedNarrow = fixture('terminal', 'rejected', 440) diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx index 33e5548aa71..33af6feba9f 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx @@ -49,7 +49,14 @@ vi.mock('@/lib/orca-cloud/orca-cloud-environment-id', () => ({ })) vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ orcaCloudAgentAccountsLoadedAt: () => 0, - useOrcaCloudAgentAccounts: () => ({ load: { kind: 'loading' }, keyKinds: {} }) + useOrcaCloudAgentAccounts: () => ({ + load: { kind: 'loading' }, + keyKinds: {}, + setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } } + }), + runOrcaCloudAgentSetups: () => undefined, + stopOrcaCloudAgentSetups: () => undefined, + reloadOrcaCloudAgentAccounts: async () => undefined })) vi.mock('@/lib/orca-cloud/cloud-running-agent-signout', () => ({ clearCloudRunningSignout: () => undefined, diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx index bdfde2a50c4..690d5b6aa60 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx @@ -42,6 +42,7 @@ import { cloudRestartFreshModel, cloudRunningStripModel } from './cloud-running-strip-model' +import { openOrcaCloudAccountsSettings, withRenewInPlace } from './cloud-renew-in-place' const NO_ACTIONS: CloudStripActions = { primary: () => undefined, @@ -52,12 +53,6 @@ const NO_ACTIONS: CloudStripActions = { cancel: () => undefined } -function openAccounts(): void { - const store = useAppStore.getState() - store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) - store.openSettingsPage() -} - export function CloudRunningStripPortal({ pane, tabId, @@ -192,7 +187,7 @@ export function CloudRunningStripPortal({ const actions: CloudStripActions = { primary: (action) => { if (action.kind === 'renew') { - openAccounts() + openOrcaCloudAccountsSettings() } else if (action.kind === 'restart' && strip.phase === 'renewed') { restart({ agent: strip.agent, resumable: strip.resumable }) } @@ -204,6 +199,19 @@ export function CloudRunningStripPortal({ // State 2's ×; state 1 has none (Renew is the action) and Escape leaves it showing. cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(paneKey) : undefined) } + // CHAT-RENEW-INPLACE: "sign-in expired · Renew" renews right here; renewed keeps RA-11's Restart. + const model = cloudRunningStripModel(strip, t) + const shown = + strip.phase === 'expired' + ? withRenewInPlace({ + agent: strip.agent, + setup: accounts.setups[strip.agent], + model, + actions, + key: paneKey, + t + }) + : { model, actions } return ( ) diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx new file mode 100644 index 00000000000..e8bc3692d04 --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { cn } from '@/lib/class-names' + +/** + * CHAT-RENEW-INPLACE: the strip's quiet "Renew in Settings" link, at the end of the actions row, or on + * its own line under the buttons in the narrow stack (design-squad). + */ +export function CloudStripSettingsLink({ + label, + onOpen, + ownLine = false +}: { + label: string + onOpen?: () => void + ownLine?: boolean +}): React.JSX.Element | null { + if (!onOpen) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts index 547f12c3306..388752f0dd0 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts @@ -5,11 +5,11 @@ import { cloudAgentStripModel } from './cloud-agent-strip-model' const base = { agent: 'claude' as const, hostLabel: 'the sandbox', t: translate } describe('cloudAgentStripModel', () => { - it('account recovery (S4-c): Renew, account-level wording, no per-workspace words', () => { + it('account recovery (S4-c), held: Renew, "Renew to start Claude." (CHAT-RENEW-INPLACE copy)', () => { const model = cloudAgentStripModel({ ...base, reason: 'accountExpired', view: null }) expect(model).toMatchObject({ title: 'Your Claude sign-in expired', - detail: 'Sign in once to fix every workspace.', + detail: 'Renew to start Claude.', primary: { label: 'Renew', action: { kind: 'renew' } } }) }) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts index 3f7b7a6baa6..e0f7827e35e 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts @@ -57,6 +57,11 @@ export type CloudStripModel = { otherWays: boolean /** Already on a code: ⋯ offers the paste fallback and Cancel instead of "Use a code". */ codeStep?: boolean + /** + * CHAT-RENEW-INPLACE: a quiet text link at the end of the actions row ("Renew in Settings"); in + * the narrow stack it gets its own line under the buttons. + */ + settingsLink?: string /** RA-11 state 2: a dismiss × with this label (the strip otherwise drops its × on Renew). */ dismiss?: string /** RA-11 "Restarting…": nothing to close; the strip leaves with the result. */ @@ -121,15 +126,15 @@ export function cloudAgentStripModel(args: { return { status: 'warning', title: t('agentSignInStrip.expired', 'Your {{agent}} sign-in expired', { agent }), - detail: t('agentSignInStrip.expiredDetail', 'Sign in once to fix every workspace.'), + detail: t('agentSignInStrip.expiredHeldDetail', 'Renew to start {{agent}}.', { agent }), description: null, primary: { label: t('agentSignInStrip.renew', 'Renew'), action: { kind: 'renew' } }, code: null, paste: null, otherWays: false, announcement: t( - 'agentSignInStrip.expiredAnnounce', - 'Your {{agent}} sign-in expired. Renew it once to fix every workspace.', + 'agentSignInStrip.expiredHeldAnnounce', + 'Your {{agent}} sign-in expired. Renew to start {{agent}}.', { agent } ) } 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 45147d775d6..aac72b6f24e 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 @@ -21,7 +21,8 @@ const stub = vi.hoisted(() => { cleared: string[] calls: { paneKey: string; turnEndedFallback?: boolean }[] openedSettings: number - } = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0 } + renewsRun: string[][] + } = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0, renewsRun: [] } return state }) @@ -47,6 +48,9 @@ vi.mock('@/lib/orca-cloud/orca-cloud-environment-id', () => ({ })) vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ orcaCloudAgentAccountsLoadedAt: () => 1, + runOrcaCloudAgentSetups: (agents: string[]) => stub.renewsRun.push(agents), + stopOrcaCloudAgentSetups: () => undefined, + reloadOrcaCloudAgentAccounts: async () => undefined, useOrcaCloudAgentAccounts: () => ({ load: { kind: 'loading' }, keyKinds: {}, @@ -118,7 +122,9 @@ describe('the running strip on a structured chat (B2)', () => { mount() expect(screen.getAllByText(/Claude sign-in expired/).length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: /Renew/ })) - expect(stub.openedSettings).toBe(1) + // CHAT-RENEW-INPLACE: Renew runs the renewal right here, never Settings. + expect(stub.renewsRun).toEqual([['claude']]) + expect(stub.openedSettings).toBe(0) expect(stub.calls.at(-1)).toEqual({ paneKey: STATUS_KEY, turnEndedFallback: true }) }) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts new file mode 100644 index 00000000000..8620ee7337e --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts @@ -0,0 +1,153 @@ +// CHAT-RENEW-INPLACE: every expired strip renews in place, with design-squad's per-step copy. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import type { CloudStripActions } from './CloudAgentSignInStrip' +import type { CloudStripModel } from './cloud-agent-strip-model' +import { translate as t } from '@/i18n/i18n' + +const store = vi.hoisted(() => ({ run: vi.fn(), stop: vi.fn() })) +vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ + runOrcaCloudAgentSetups: store.run, + stopOrcaCloudAgentSetups: store.stop, + reloadOrcaCloudAgentAccounts: async () => undefined +})) +vi.mock('@/lib/orca-cloud/cloud-agent-launch-hold', () => ({ + releaseCloudAgentHold: vi.fn(), + startCloudAgentHold: vi.fn() +})) +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('../agent-sign-in/AgentSignInPanePortals', () => ({ + agentSignInCardActions: () => ({ copyText: vi.fn(), openLink: vi.fn() }) +})) +const api = vi.hoisted(() => ({ reopen: vi.fn(async () => undefined), submitClaudeCode: vi.fn() })) +vi.stubGlobal('window', { api: { orcaCloudAgents: api } }) + +const { cloudRenewInPlaceActions, cloudRenewInPlaceModel } = await import('./cloud-renew-in-place') + +const EXPIRED: CloudStripModel = { + status: 'warning', + title: 'Your Claude sign-in expired', + detail: 'Renew to keep working. Your conversation is kept.', + description: null, + primary: { label: 'Renew', action: { kind: 'renew' } }, + code: null, + paste: null, + otherWays: false, + announcement: 'Your Claude sign-in expired.' +} + +const model = (setup: OrcaCloudAgentSetup) => + cloudRenewInPlaceModel({ agent: 'claude', expired: EXPIRED, setup, t }) + +describe('cloudRenewInPlaceModel (design-squad per-step copy)', () => { + it("1 expired: the surface's own strip, [Renew], no Settings link", () => { + expect(model({ phase: 'idle' })).toBe(EXPIRED) + expect(model({ phase: 'saved' })).toBe(EXPIRED) + }) + + it('2 browser: same title, paste code, [Finish renewing] [Open again], "Renew in Settings"', () => { + const shown = model({ phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true }) + expect(shown.title).toBe('Your Claude sign-in expired') + expect(shown.detail).toBe('Finish in your browser, then paste the code here.') + expect(shown.paste?.setupCode).toEqual({ + placeholder: 'Paste code', + submitLabel: 'Finish renewing' + }) + expect(shown.primary).toMatchObject({ label: 'Open again', action: { kind: 'reopenSetup' } }) + expect(shown.settingsLink).toBe('Renew in Settings') + expect(JSON.stringify(shown)).not.toContain('Finish setup') + }) + + it('3 checking: the field and its button disabled', () => { + const shown = model({ + phase: 'browser', + startedAt: 0, + canReopen: true, + acceptsCode: true, + code: 'checking' + }) + expect(shown.paste).toMatchObject({ submitting: true }) + expect(shown.paste?.setupCode?.submitLabel).toBe('Checking the code…') + }) + + it('4 rejected: "Code didn\'t work", the newest-code detail, [Open again] and the Settings link', () => { + const shown = model({ + phase: 'browser', + startedAt: 0, + canReopen: true, + acceptsCode: true, + code: 'rejected' + }) + expect(shown.title).toBe('Code didn’t work') + expect(shown.detail).toBe('Paste the newest code from the sign-in page, or open it again.') + expect(shown.paste?.refusal).toBeTruthy() + expect(shown.primary?.action.kind).toBe('reopenSetup') + expect(shown.settingsLink).toBe('Renew in Settings') + }) + + it('a setup that failed outright starts again from Renew', () => { + const shown = model({ phase: 'failed', error: 'denied' }) + expect(shown.status).toBe('error') + expect(shown.primary?.action.kind).toBe('renew') + }) +}) + +describe('cloudRenewInPlaceActions', () => { + const surface = (): CloudStripActions => ({ + primary: vi.fn(), + submitCode: vi.fn(), + pasteCode: vi.fn(), + useCode: vi.fn(), + useApiKey: vi.fn(), + cancel: vi.fn() + }) + const actions = (setup: OrcaCloudAgentSetup, own = surface(), openSettings = vi.fn()) => ({ + own, + openSettings, + built: cloudRenewInPlaceActions({ + agent: 'claude', + setup, + surface: own, + openSettings, + copyText: vi.fn(), + openLink: vi.fn() + }) + }) + beforeEach(() => { + store.run.mockClear() + store.stop.mockClear() + api.reopen.mockClear() + }) + + it('Renew runs the setup here (never the Settings page or the sign-in sheet)', () => { + const { built, own, openSettings } = actions({ phase: 'idle' }) + built.primary({ kind: 'renew' }) + expect(store.run).toHaveBeenCalledExactlyOnceWith(['claude']) + expect(own.primary).not.toHaveBeenCalled() + expect(openSettings).not.toHaveBeenCalled() + }) + + it('Open again reopens the setup page; "Renew in Settings" opens Settings', () => { + const { built, openSettings } = actions({ phase: 'browser', startedAt: 0, canReopen: true }) + built.primary({ kind: 'reopenSetup' }) + expect(api.reopen).toHaveBeenCalledExactlyOnceWith({ agent: 'claude' }) + built.openSettings?.() + expect(openSettings).toHaveBeenCalledOnce() + }) + + it('6: × mid-flow stops the setup (back to step 1) and never dismisses the strip', () => { + const { built, own } = actions({ phase: 'browser', startedAt: 0, canReopen: true }) + built.cancel() + expect(store.stop).toHaveBeenCalledExactlyOnceWith('claude') + expect(own.cancel).not.toHaveBeenCalled() + }) + + it('outside a renewal, the surface keeps its own × and Restart', () => { + const { built, own } = actions({ phase: 'idle' }) + built.cancel() + built.primary({ kind: 'restart' }) + expect(store.stop).not.toHaveBeenCalled() + expect(own.cancel).toHaveBeenCalledOnce() + expect(own.primary).toHaveBeenCalledExactlyOnceWith({ kind: 'restart' }) + }) +}) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts new file mode 100644 index 00000000000..f6514d36286 --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts @@ -0,0 +1,159 @@ +// CHAT-RENEW-INPLACE (Option A, design-squad per-step copy): every "sign-in expired" strip — a held +// launch, a running terminal, a structured chat — renews in place. Renew runs the same Orca Cloud +// setup as "Set up once" right in the strip (re-running it replaces the account's expired sign-in), +// instead of sending the person to Settings. One model and one set of actions for every surface, so +// the terminal and the chat can't drift apart. +// 1 expired: [Renew] 2 browser: paste code, [Finish renewing] [Open again] × +// 3 checking: field and buttons disabled 4 rejected: "Code didn't work", [Open again] +// 5 renewed: the surface's own renewed state (held: Start; terminal: Restart; chat: next message) +// × mid-flow stops the setup and goes back to step 1; it never dismisses the strip. +import type { Translate } from '@/i18n/use-translate' +import { useAppStore } from '@/store' +import { + runOrcaCloudAgentSetups, + stopOrcaCloudAgentSetups +} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' +import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status' +import type { CloudStripActions } from './CloudAgentSignInStrip' +import type { CloudStripModel } from './cloud-agent-strip-model' +import { cloudAgentSetupStripModel } from './cloud-agent-setup-strip-model' +import { cloudSetupStripActions } from './cloud-setup-strip-actions' +import { agentSignInCardActions } from '../agent-sign-in/AgentSignInPanePortals' + +/** The renewal's own setup is under way (steps 2-4): the strip shows it instead of step 1. */ +export function renewInPlaceRunning(setup: OrcaCloudAgentSetup): boolean { + return setup.phase !== 'idle' && setup.phase !== 'saved' +} + +/** + * Steps 1-4 over a surface's "expired" model (step 1 keeps the surface's own detail: running "Renew + * to keep working…", held "Renew to start {{agent}}."). Step 5 is each surface's renewed state. + */ +export function cloudRenewInPlaceModel(args: { + agent: HostAgentSignInAgent + expired: CloudStripModel + setup: OrcaCloudAgentSetup + t: Translate +}): CloudStripModel { + const { expired, setup, t } = args + if (!renewInPlaceRunning(setup)) { + return expired + } + const flow = cloudAgentSetupStripModel({ agent: args.agent, setup, ready: false, t }) + const settingsLink = t('agentSignInStrip.renewInSettings', 'Renew in Settings') + // A failed setup keeps its own words; a refused code says "Code didn't work" (as #484). + const keepsTitle = + setup.phase === 'failed' || (setup.phase === 'browser' && setup.code === 'rejected') + const finishRenewing = t('agentSignInStrip.finishRenewing', 'Finish renewing') + const paste = flow.paste + ? { + ...flow.paste, + // Never the setup's "Finish setup" (design-squad). + ...(flow.paste.setupCode + ? { + setupCode: { + ...flow.paste.setupCode, + submitLabel: + setup.phase === 'browser' && setup.code === 'checking' + ? flow.paste.setupCode.submitLabel + : finishRenewing + } + } + : {}), + ...(flow.paste.submitLabel ? { submitLabel: finishRenewing } : {}) + } + : null + return { + ...flow, + title: keepsTitle ? flow.title : expired.title, + paste, + // A setup that failed outright starts again from Renew. + primary: + setup.phase === 'failed' && flow.primary?.action.kind === 'setUpOnce' + ? { ...flow.primary, action: { kind: 'renew' } } + : flow.primary, + ...(setup.phase === 'browser' || setup.phase === 'paste' ? { settingsLink } : {}), + announcement: keepsTitle ? flow.announcement : `${expired.title}. ${flow.announcement}` + } +} + +/** + * Renew starts the setup here; Open again, the code and the paste go to it; × stops it (back to + * step 1). With no renewal running, everything else is the surface's own (its × or Restart). + */ +export function cloudRenewInPlaceActions(args: { + agent: HostAgentSignInAgent + setup: OrcaCloudAgentSetup + /** The surface's own actions, used outside the renewal (Restart, a renewed strip's ×). */ + surface: CloudStripActions + /** "Renew in Settings": the same renewal in Settings › Accounts › Orca Cloud. */ + openSettings: () => void + copyText: (text: string) => void + openLink: (url: string) => void +}): CloudStripActions { + const running = renewInPlaceRunning(args.setup) + const flow = cloudSetupStripActions({ + agent: args.agent, + // Renewing holds nothing itself: Start (step 5) belongs to the surface. + holdKey: '', + setupRunning: running, + copyText: args.copyText, + openLink: args.openLink, + useApiKey: args.surface.useApiKey, + keepHoldOnCancel: true + }) + return { + ...args.surface, + primary: (action) => { + if (action.kind === 'renew') { + runOrcaCloudAgentSetups([args.agent]) + } else if (action.kind === 'reopenSetup' || action.kind === 'copyAndOpen') { + flow.primary(action) + } else { + args.surface.primary(action) + } + }, + submitCode: running ? flow.submitCode : args.surface.submitCode, + submitSetupCode: flow.submitSetupCode, + pasteCode: running ? flow.pasteCode : args.surface.pasteCode, + openSettings: args.openSettings, + cancel: () => (running ? stopOrcaCloudAgentSetups(args.agent) : args.surface.cancel()) + } +} + +/** "Renew in Settings": Settings › Accounts › Orca Cloud, where the same renewal runs. */ +export function openOrcaCloudAccountsSettings(): void { + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) + store.openSettingsPage() +} + +/** A surface's expired strip, renewing in place: its model (steps 1-4) and actions together. */ +export function withRenewInPlace(args: { + agent: HostAgentSignInAgent + setup: OrcaCloudAgentSetup + model: CloudStripModel + actions: CloudStripActions + /** The pane or chat key, for the sign-in page's copy and open. */ + key: string + t: Translate +}): { model: CloudStripModel; actions: CloudStripActions } { + const page = agentSignInCardActions(args.key) + return { + model: cloudRenewInPlaceModel({ + agent: args.agent, + expired: args.model, + setup: args.setup, + t: args.t + }), + actions: cloudRenewInPlaceActions({ + agent: args.agent, + setup: args.setup, + surface: args.actions, + openSettings: openOrcaCloudAccountsSettings, + copyText: page.copyText, + openLink: page.openLink + }) + } +} diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts index ab90eac24da..7325eea6371 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts @@ -31,7 +31,7 @@ export function cloudSetupStripActions(args: { keepHoldOnCancel?: boolean }): CloudStripActions { const { agent, holdKey } = args - const api = window.api.orcaCloudAgents + const api = window.api?.orcaCloudAgents return { primary: (action) => { if (action.kind === 'setUpOnce') { 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 9a5ab27a572..92330c53794 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 @@ -26,12 +26,7 @@ import { import type { CloudStripActions } from './CloudAgentSignInStrip' import type { CloudHeldStrip } from './use-cloud-held-strip' import { cloudChatRunningStripModel } from './cloud-running-strip-model' - -function openAccounts(): void { - const store = useAppStore.getState() - store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) - store.openSettingsPage() -} +import { openOrcaCloudAccountsSettings, withRenewInPlace } from './cloud-renew-in-place' /** The Orca Cloud environment a structured chat's tab runs on, or null. */ function useChatEnvironmentId(tabId: string): string | null { @@ -94,7 +89,8 @@ export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStr } return cloudRunningStrip({ paneKey: args.statusKey, - entry: status ?? { agentType: agent, state: 'idle' }, + // An idle chat (no message yet) has no status entry: decided from its own agent. + entry: status ?? { agentType: agent, state: 'waiting' }, accounts, held, now: orcaCloudAgentAccountsLoadedAt(), @@ -108,11 +104,12 @@ export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStr export function useCloudChatRunningStrip(args: ChatAccountArgs): CloudHeldStrip | null { const t = useTranslate() const strip = useCloudChatAccountStrip(args) + const accounts = useOrcaCloudAgentAccounts(strip !== null) if (!strip) { return null } const actions: CloudStripActions = { - primary: (action) => (action.kind === 'renew' ? openAccounts() : undefined), + primary: (action) => (action.kind === 'renew' ? openOrcaCloudAccountsSettings() : undefined), submitCode: () => undefined, pasteCode: () => undefined, useCode: () => undefined, @@ -120,7 +117,20 @@ export function useCloudChatRunningStrip(args: ChatAccountArgs): CloudHeldStrip // Expired has no ×; renewed's × dismisses. cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(args.statusKey) : undefined) } - return { model: cloudChatRunningStripModel(strip, t), prompt: null, actions } + const model = cloudChatRunningStripModel(strip, t) + // CHAT-RENEW-INPLACE: the chat's "sign-in expired · Renew" renews right here. + const shown = + strip.phase === 'expired' + ? withRenewInPlace({ + agent: strip.agent, + setup: accounts.setups[strip.agent], + model, + actions, + key: args.statusKey, + t + }) + : { model, actions } + return { ...shown, prompt: null } } /** diff --git a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts index fa9989f86ae..5711413b7db 100644 --- a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts +++ b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts @@ -21,6 +21,11 @@ import { cloudAgentSetupStripModel } from './cloud-agent-setup-strip-model' import { cloudSetupStripActions } from './cloud-setup-strip-actions' +import { + cloudRenewInPlaceActions, + cloudRenewInPlaceModel, + openOrcaCloudAccountsSettings +} from './cloud-renew-in-place' export type CloudHeldStrip = { model: CloudStripModel @@ -70,7 +75,7 @@ export function useCloudHeldStrip( const setupHold = hold?.reason === 'notSetUp' || awaiting !== null const setup = accounts.setups[agent] // HELD-STALE-COVERED: the setup just finished here: "is set up · Start" (the account, read fresh). - const model = + const shownBeforeRenew = awaiting === 'setUp' ? cloudAgentSetupStripModel({ agent, setup: accounts.setups[agent], ready: true, t }) : awaiting @@ -95,6 +100,11 @@ export function useCloudHeldStrip( hostLabel: cloudGate?.hostLabel ?? t('agentSignInStrip.sandbox', 'the sandbox'), t }) + // CHAT-RENEW-INPLACE: a launch held on an expired sign-in renews right here (steps 1-4). + const renewing = hold?.reason === 'accountExpired' + const model = renewing + ? cloudRenewInPlaceModel({ agent, expired: shownBeforeRenew, setup, t }) + : shownBeforeRenew const setupRunning = setup.phase !== 'idle' && setup.phase !== 'saved' && setup.phase !== 'failed' const holdActions: CloudStripActions | null = setupHold ? cloudSetupStripActions({ @@ -107,7 +117,7 @@ export function useCloudHeldStrip( keepHoldOnCancel: chat }) : null - const actions: CloudStripActions = holdActions ?? { + const gateOrRecovery: CloudStripActions = { primary: (action) => { // RA-11's restart belongs to the running-pane strip; a held launch never offers it. if ( @@ -152,9 +162,21 @@ export function useCloudHeldStrip( } } } + const actions: CloudStripActions = + holdActions ?? + (renewing + ? cloudRenewInPlaceActions({ + agent, + setup, + surface: gateOrRecovery, + openSettings: openOrcaCloudAccountsSettings, + copyText: gateActions.copyText, + openLink: gateActions.openLink + }) + : gateOrRecovery) // A held chat's strip has nothing to cancel unless a setup is running: no × (Escape is a no-op). const shownModel = - chat && hold && !(setupHold && setupRunning) ? { ...model, noClose: true } : model + chat && hold && !((setupHold || renewing) && setupRunning) ? { ...model, noClose: true } : model return { key, active, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dc213e5b036..7caf5e1b393 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -29017,6 +29017,10 @@ "nothingNeedsAttention": "Nothing needs attention." }, "agentSignInStrip": { + "renewInSettings": "Renew in Settings", + "finishRenewing": "Finish renewing", + "expiredHeldDetail": "Renew to start {{agent}}.", + "expiredHeldAnnounce": "Your {{agent}} sign-in expired. Renew to start {{agent}}.", "rowNeedsSignIn": "Needs sign-in", "rowNeedsSignInTooltip": "Set up once for Orca Cloud", "rowExpired": "Sign-in expired",