feat(native-chat): a chat whose host has no program to run shows Install in place of its composer (#29051)

* feat(native-chat): a chat whose host has no program to run shows Install in place of its composer

The chat's one input slot is chosen in one place: a final read refusal, then an owned prompt card,
then Install while the host has nothing runnable (its install included), then the composer. Until
the first status answer the slot stays empty instead of flashing either. Send and slash commands are
refused while Install holds the slot, so nothing hidden reaches the host. The draft stays in its
store and is there when the composer returns. A failed status read leaves the composer; the host
remains the final refusal.

* fix(native-chat): keep the composer slot reserved until the host's agent list says, and say nothing about sending while Install shows

- While the host's capabilities or agent list are still loading for a chat's agent, the slot stays
  quiet instead of flashing the composer.
- While the install row holds the composer's place, a failed start's notice ("Send your message
  to try again", Retry) is left out: nothing starts until the install lands.
- The chat install card's title is semibold like the other cards in that slot.

* fix(native-chat): keep the chat session view within its line limit after the install slot

* fix(native-chat): the composer stays while the host's first install answer is out, its sends held, so no agent's chat loses its input box

* fix(native-chat): the install slot holds Send through the composer's existing held-send reason instead of a flag of its own

* fix(native-chat): the install slot hands the chat its held-Send reason, keeping the chat session view under its line limit
This commit is contained in:
Brennan Benson
2026-10-16 23:32:04 -07:00
committed by GitHub
parent 7bb83b47c8
commit 88f27711fd
10 changed files with 167 additions and 8 deletions
@@ -69,7 +69,7 @@ export function ManagedRuntimeInstallRow(props: {
>
<div className="min-w-0 flex-1 space-y-1">
{chat ? (
<p className="font-medium text-foreground">
<p className="font-semibold text-foreground">
{translate(
'components.managed-runtime.chatTitle',
'Install {{agent}} to start chatting.',
@@ -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<ManagedRuntimeInstallObservation, { kind: 'active' }> }
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 (
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
<div className="mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
<ManagedRuntimeInstallRow
observation={slot.observation}
agentLabel={props.agentLabel}
surface="chat"
/>
</div>
</div>
)
}
@@ -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', () => {
@@ -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 : <NativeChatPromptSlotNotices notices={notices} />}
{composerOpen ? null : <NativeChatPromptSlotNotices notices={notices} />}
{prompt && approval ? (
<NativeChatApprovalCard
key={`${prompt.itemId}:${prompt.revision}`}
@@ -387,7 +391,8 @@ export function NativeChatStructuredSession(
answerInputRef={questionAnswerInputRef}
/>
) : null}
{composerShown ? (
{composerShown && <NativeChatInstallSlot slot={install.slot} agentLabel={agentLabel} />}
{composerOpen ? (
<NativeChatComposer
ref={composerRef}
terminalTabId={props.tabId}
+3
View File
@@ -18235,6 +18235,9 @@
"empty": "No users found"
},
"native-chat": {
"install": {
"checking": "Checking whether {{agent}} is ready on this host…"
},
"reasoning": "Reasoning",
"failureWords": {
"theAgent": "The agent",
+3
View File
@@ -17987,6 +17987,9 @@
},
"components": {
"native-chat": {
"install": {
"checking": "Comprobando si {{agent}} está listo en este host…"
},
"reasoning": "Razonamiento",
"contextMenu": {
"copyOrcaSessionId": "Copiar ID de sesión de Orca",
+3
View File
@@ -18096,6 +18096,9 @@
},
"components": {
"native-chat": {
"install": {
"checking": "Vérification que {{agent}} est prêt sur cet hôte…"
},
"reasoning": "Raisonnement",
"contextMenu": {
"copyOrcaSessionId": "Copier l'ID de session Orca",
+3
View File
@@ -18027,6 +18027,9 @@
}
},
"native-chat": {
"install": {
"checking": "このホストで {{agent}} の準備ができているか確認しています…"
},
"reasoning": "推論",
"contextMenu": {
"copyOrcaSessionId": "Orca セッション ID をコピー",
+3
View File
@@ -18027,6 +18027,9 @@
}
},
"native-chat": {
"install": {
"checking": "이 호스트에서 {{agent}}이(가) 준비되었는지 확인하는 중…"
},
"reasoning": "추론",
"contextMenu": {
"copyOrcaSessionId": "Orca 세션 ID 복사",
+3
View File
@@ -17992,6 +17992,9 @@
},
"components": {
"native-chat": {
"install": {
"checking": "正在检查 {{agent}} 是否已在此主机上就绪…"
},
"reasoning": "推理",
"contextMenu": {
"copyOrcaSessionId": "复制 Orca 会话 ID",