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 ? (