diff --git a/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx b/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx index 3b93dcf1428..7c8c0113296 100644 --- a/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx +++ b/src/renderer/src/components/managed-runtime/ManagedRuntimeInstallRow.tsx @@ -69,7 +69,7 @@ export function ManagedRuntimeInstallRow(props: { >
{chat ? ( -

+

{translate( 'components.managed-runtime.chatTitle', 'Install {{agent}} to start chatting.', diff --git a/src/renderer/src/components/native-chat/NativeChatInstallSlot.tsx b/src/renderer/src/components/native-chat/NativeChatInstallSlot.tsx new file mode 100644 index 00000000000..fdb82a1d31a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInstallSlot.tsx @@ -0,0 +1,84 @@ +import { ManagedRuntimeInstallRow } from '@/components/managed-runtime/ManagedRuntimeInstallRow' +import { translate } from '@/i18n/i18n' +import { + useManagedRuntimeInstall, + type ManagedRuntimeInstallObservation +} from '@/runtime/use-managed-runtime-install' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' + +/** What a chat's input slot holds for the program its host must install first. */ +export type NativeChatInstallSlotState = + | { kind: 'none' } + /** The first status read is out: the composer stays, its sends held until the host answers. */ + | { kind: 'reserved' } + | { kind: 'install'; observation: Extract } + +const NONE: NativeChatInstallSlotState = { kind: 'none' } +const RESERVED: NativeChatInstallSlotState = { kind: 'reserved' } + +/** Install takes the composer's place while the chat's host has nothing to run, its install + * included; anything runnable, or a status the host could not report, leaves the composer. Until + * the host says whether it provides the program, the composer stays but sends nothing. */ +export function useNativeChatInstallSlot( + target: RuntimeClientTarget, + agent: string, + shown: boolean, + agentLabel: string +): { slot: NativeChatInstallSlotState; sendBlockedReason: string | null } { + const slot = installSlotState(useManagedRuntimeInstall(target, agent, shown)) + return { slot, sendBlockedReason: installSlotSendBlockedReason(slot, agentLabel) } +} + +function installSlotState( + observation: ManagedRuntimeInstallObservation +): NativeChatInstallSlotState { + if (observation.kind === 'inactive') { + return NONE + } + if (observation.kind === 'pending') { + return RESERVED + } + const { snapshot } = observation + if (snapshot.kind === 'readFailed') { + return NONE + } + if (snapshot.kind === 'unknown') { + return RESERVED + } + return snapshot.state.source === null ? { kind: 'install', observation } : NONE +} + +/** Send stays held until the host says it can run this agent. */ +function installSlotSendBlockedReason( + slot: NativeChatInstallSlotState, + agentLabel: string +): string | null { + return slot.kind === 'none' + ? null + : translate( + 'components.native-chat.install.checking', + 'Checking whether {{agent}} is ready on this host…', + { agent: agentLabel } + ) +} + +export function NativeChatInstallSlot(props: { + slot: NativeChatInstallSlotState + agentLabel: string +}): React.JSX.Element | null { + const { slot } = props + if (slot.kind !== 'install') { + return null + } + return ( +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 6d90aa576ec..e100264cc25 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -3,10 +3,15 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { ManagedRuntimeInstallState } from '../../../../shared/managed-runtime-install' +import type { ManagedRuntimeInstallObservation } from '@/runtime/use-managed-runtime-install' const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() ) +const install = vi.hoisted((): { observation: ManagedRuntimeInstallObservation } => ({ + observation: { kind: 'inactive' } +})) vi.mock('@/lib/structured-agent-session-launch', () => moduleFactories.structuredAgentSessionLaunch() @@ -27,11 +32,19 @@ vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) +vi.mock('@/runtime/use-managed-runtime-install', () => ({ + useManagedRuntimeInstall: () => install.observation +})) import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' +import { + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' const NOT_SIGNED_IN = { kind: 'refused', @@ -67,6 +80,45 @@ describe('NativeChatStructuredSession launch lifecycle', () => { cleanup() localStorage.clear() resetStructuredSessionMocks() + install.observation = { kind: 'inactive' } + }) + + it('puts Install in the composer slot until the host has the program, and keeps the draft', () => { + const notInstalled: ManagedRuntimeInstallState = { + agent: 'codex', + revision: 1, + available: true, + version: '1.3.0', + installedVersion: null, + installedCurrent: false, + source: null, + operation: null + } + const hostSays = (state: ManagedRuntimeInstallState | null): void => { + install.observation = { + kind: 'active', + subject: { target: { kind: 'local' }, runtimeId: null, agent: 'codex' }, + snapshot: state ? { kind: 'known', state } : { kind: 'unknown' } + } + } + const draft = structuredAgentSessionDraftScopeKey('session-1') + updateNativeChatComposerDraft(draft, { text: 'hello' }, 'immediate') + // Before the first answer the composer stays (its sends held) and Install doesn't flash. + hostSays(null) + const { rerender } = render(sessionView()) + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Install Codex' })).toBeNull() + + hostSays(notInstalled) + rerender(sessionView()) + expect(screen.getByRole('button', { name: 'Install Codex' })).toBeTruthy() + expect(screen.queryByTestId('structured-composer')).toBeNull() + + hostSays({ ...notInstalled, revision: 2, installedVersion: '1.3.0', source: 'managed' }) + rerender(sessionView()) + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Install Codex' })).toBeNull() + expect(readNativeChatComposerDraft(draft).text).toBe('hello') }) it('shows the ordinary usable chat without a startup label while launch is pending', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 0e6d1ccb080..e412bf2cf4d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -35,6 +35,7 @@ import { useNativeChatInterruptedContinuation } from './NativeChatInterruptedCon import { NativeChatOrcaStopContext } from './native-chat-orca-stop-context' import { structuredSessionNotices } from './native-chat-structured-session-notices' import { NativeChatPromptSlotNotices } from './NativeChatComposerNotices' +import { NativeChatInstallSlot, useNativeChatInstallSlot } from './NativeChatInstallSlot' import { useNativeChatComposerError } from './use-native-chat-composer-notice' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' @@ -125,7 +126,6 @@ export function NativeChatStructuredSession( const hostPermission = useNativeChatHostPermissionGate(props.target, props.agent, agentLabel) const hostHeld = hostPermission.sendBlockedReason !== null const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry, hostHeld) - const { revealLatest } = submits const promptResponse = useStructuredPromptResponseHold(submits.respond) const continuation = useNativeChatInterruptedContinuation({ target: props.target, @@ -199,6 +199,9 @@ export function NativeChatStructuredSession( // cancel then works. const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally + // An owned prompt keeps its card; otherwise a missing program takes the composer's place. + const install = useNativeChatInstallSlot(props.target, props.agent, props.isVisible, agentLabel) + const composerOpen = composerShown && install.slot.kind !== 'install' const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null const cancelPrompt = () => { if (prompt && (controller.turnId || props.agent === 'pi')) { @@ -215,7 +218,7 @@ export function NativeChatStructuredSession( questionAnswerInputRef, isVisible: props.isVisible, isFocusedGroup: props.isFocusedGroup, - composerReady: composerShown + composerReady: composerOpen }) const questionBody = prompt?.body.kind === 'question' ? prompt.body : null const questions = questionBody ? agentSessionPromptQuestions(questionBody) : [] @@ -228,8 +231,8 @@ export function NativeChatStructuredSession( optionPickerRequest, setOptionPickerRequest, onError: reportComposerError, - onSubmitted: revealLatest, - sendBlockedReason: hostPermission.sendBlockedReason + onSubmitted: submits.revealLatest, + sendBlockedReason: hostPermission.sendBlockedReason ?? install.sendBlockedReason }) // Said once: on the pane when the failure took it, else in the composer's notices. @@ -251,8 +254,9 @@ export function NativeChatStructuredSession( failure: provisionalLaunch.failure, failureRows: startFailures.filter((fact) => isClaudeSignInFailureKind(fact.kind)).length }) + // While Install shows, it says what to do; a start failure's "send again" or Retry can't help. const notices = structuredSessionNotices({ - launch, + launch: install.slot.kind === 'install' ? { ...launch, lifecycle: null } : launch, agentLabel, sessionError, composerError: composerError ?? continuation.continueError, @@ -361,7 +365,7 @@ export function NativeChatStructuredSession( /> )} {/* Prompt cards take the composer's slot, below the background-task dock. */} - {composerShown ? null : } + {composerOpen ? null : } {prompt && approval ? ( ) : null} - {composerShown ? ( + {composerShown && } + {composerOpen ? (