From 88f27711fda30b2b4fd7dcd9d750042c5e7d8d7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:32:04 -0700 Subject: [PATCH] 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 --- .../ManagedRuntimeInstallRow.tsx | 2 +- .../native-chat/NativeChatInstallSlot.tsx | 84 +++++++++++++++++++ ...tructuredSession.launch-lifecycle.test.tsx | 52 ++++++++++++ .../NativeChatStructuredSession.tsx | 19 +++-- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + 10 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatInstallSlot.tsx 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 ? (