fix(native-chat): render Claude structured chat through the same UI as Codex

Structured native chat is one shared, agent-agnostic component tree, but three
Codex-hardcoded terms on that path made a Claude session render differently.

- `showTurnStatus` was `agent === 'codex'`, which gated the whole structured
  presentation: the live tool-progress row, the completion check and activity
  grouping (`structuredActivityUi`), and the Thinking / Working for N /
  Worked for N status rows. Claude fell back to the legacy compact chrome.
- `runtimeContext` was likewise Codex-only, so a Claude transcript rendered
  images as filename chips instead of previews.
- The composer's structured slash menu always served the Codex catalog,
  ignoring `agent`. That disagreed with the dispatcher, which does branch per
  agent: Codex-only tokens offered to Claude missed the command guard and were
  sent to the model as literal prompt text, where Codex shows an error.

The first two gates landed Codex-first (#17597, #18266) before the Claude
structured lane existed; they were rollout scoping, not capability limits.
Neither `useNativeChatTurnStatus` nor `useNativeChatImageRuntimeContext` has
any agent-specific logic. The menu now reads `structuredSlashCommands(agent)`,
the function the dispatcher already used, so both read one list.

No new rendering logic: two gates removed and one existing shared function
reused. There are now zero `agent === 'codex' | 'claude'` branches in any
native-chat component.
This commit is contained in:
Merge Sim
2026-09-04 16:47:30 -07:00
parent 0a821e5bc8
commit 1dee49b2e8
6 changed files with 100 additions and 12 deletions
@@ -6,7 +6,6 @@ import type {
SessionOptionDescriptor,
SessionOptionsSurface
} from '../../../../shared/native-chat-session-options'
import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles'
import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache'
import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment'
@@ -27,6 +26,7 @@ const mocks = vi.hoisted(() => ({
sessionOptionsSnapshot?: SessionOptionDescriptor[]
attachDisabled?: boolean
sendButtonDisabled?: boolean
autocomplete?: { mode: string; items?: { kind: string; name: string }[] }
} | null,
modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown',
confirmationObserver: null as {
@@ -90,10 +90,6 @@ vi.mock('./claude-model-switch-confirmation', () => ({
createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) =>
mocks.createClaudeModelSwitchConfirmationObserver(...args)
}))
vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({
...(await importOriginal<typeof nativeChatAgentProfiles>()),
getVerifiedNativeChatCommands: () => []
}))
vi.mock('@/lib/native-chat-telemetry', () => ({
emitNativeChatMessageSent: vi.fn(),
emitNativeChatPickerItemAccepted: vi.fn(),
@@ -308,6 +304,43 @@ describe('NativeChatComposer', () => {
expect(mocks.setDraft).toHaveBeenCalledWith('')
})
// The structured slash menu must offer the running agent's own catalog. Offering
// another agent's tokens sends them past the command guard as literal prompt text.
it.each([
['claude', 'compact', 'vim'],
['codex', 'vim', 'help']
] as const)('offers %s its own structured slash commands', (agent, offered, withheld) => {
mocks.draft = '/'
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey={`tab-1:structured-${agent}`}
targetPtyId={null}
agent={agent}
structuredTransport={{
send: vi.fn(() => true),
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
optionsSurface: {
getSnapshot: () => [],
setOption: vi.fn(),
invokeAction: vi.fn(),
subscribe: () => () => {}
},
optionSnapshot: [],
onError: vi.fn(),
runtime: 'local'
}}
/>
)
const names = (mocks.fieldProps?.autocomplete?.items ?? [])
.filter((item) => item.kind === 'command')
.map((item) => item.name)
expect(names).toContain(offered)
expect(names).toContain('effort')
expect(names).not.toContain(withheld)
})
it('sends structured image attachments through the durable transport', async () => {
mocks.draft = ''
mocks.imageAttachments = [{ id: 'image-1', path: '/tmp/image.png' }]
@@ -3,7 +3,7 @@ import { useAppStore } from '../../store'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import { STRUCTURED_AGENT_SESSION_SLASH_COMMANDS } from '../../../../shared/structured-agent-session-composer'
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
import {
applyMentionSuggestion,
EMPTY_HISTORY,
@@ -111,9 +111,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
const agentCommands = useMemo(
() =>
structuredTransport
? STRUCTURED_AGENT_SESSION_SLASH_COMMANDS
: getVerifiedNativeChatCommands(agent),
structuredTransport ? structuredSlashCommands(agent) : getVerifiedNativeChatCommands(agent),
[agent, structuredTransport]
)
const picker = useNativeChatPickerState({
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
messageListProps: null as null | {
allowFileUriLinks?: boolean
onLinkClick?: (...args: unknown[]) => void
showTurnStatus?: boolean
runtimeContext?: unknown
},
composerProps: null as null | { structuredTransport?: Record<string, unknown> },
questionCardProps: null as NativeChatQuestionCardProps | null,
@@ -190,6 +192,27 @@ describe('NativeChatStructuredSession', () => {
expect(mocks.messageListProps?.onLinkClick).toBe(mocks.fileLinkClick)
})
// Turn status and transcript image previews shipped Codex-first. Every
// structured session renders through the same list, so neither is agent-gated.
it.each(['codex', 'claude'] as const)(
'renders the same structured transcript chrome for %s',
(agent) => {
render(
<NativeChatStructuredSession
isVisible
tabId="structured-tab-parity"
sessionId="session-parity"
target={{ kind: 'local' }}
agent={agent}
allowFileUriLinks
/>
)
expect(mocks.messageListProps?.showTurnStatus).toBe(true)
expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined()
}
)
it('routes a bare model command to the native option picker', async () => {
render(
<NativeChatStructuredSession
@@ -166,10 +166,10 @@ export function NativeChatStructuredSession(
expandSignal={false}
fontScale={fontScale.scale}
workingStartedAt={null}
showTurnStatus={props.agent === 'codex'}
showTurnStatus
onLinkClick={fileLinkClick}
allowFileUriLinks={fileLinkClick !== undefined}
runtimeContext={props.agent === 'codex' ? imageRuntimeContext : undefined}
runtimeContext={imageRuntimeContext}
/>
)}
</div>
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import {
isStructuredAgentSessionComposerCommand,
structuredSlashCommands
} from './structured-agent-session-composer'
describe('structuredSlashCommands', () => {
// The composer menu and the dispatcher read this one list. When they disagreed,
// a Claude session was offered Codex-only tokens that missed the command guard
// and reached the model as literal prompt text instead of erroring.
it.each(['codex', 'claude'] as const)('offers %s only commands it also accepts', (agent) => {
const offered = structuredSlashCommands(agent)
expect(offered.length).toBeGreaterThan(0)
for (const command of offered) {
expect(isStructuredAgentSessionComposerCommand(`/${command.name}`, agent)).toBe(true)
}
})
it('offers each agent its own catalog', () => {
const claude = structuredSlashCommands('claude').map((command) => command.name)
expect(claude).toContain('compact')
expect(claude).not.toContain('vim')
expect(structuredSlashCommands('codex').map((command) => command.name)).toContain('vim')
})
it('offers effort to every structured agent', () => {
for (const agent of ['codex', 'claude'] as const) {
expect(structuredSlashCommands(agent).map((command) => command.name)).toContain('effort')
}
})
})
@@ -35,7 +35,10 @@ function commandParts(text: string): { name: string; argument: string } | null {
return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null
}
function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
/** The command catalog a structured session offers and accepts. The composer menu
* and the dispatcher must read the same list, or a menu pick falls through the
* command guard and reaches the model as literal prompt text. */
export function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
if (agent === 'codex') {
return STRUCTURED_AGENT_SESSION_SLASH_COMMANDS
}