From 1dee49b2e847f14726b6de5b31fbeafe8e72e60a Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 16:47:04 -0700 Subject: [PATCH] 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. --- .../native-chat/NativeChatComposer.test.tsx | 43 ++++++++++++++++--- .../native-chat/NativeChatComposer.tsx | 6 +-- .../NativeChatStructuredSession.test.tsx | 23 ++++++++++ .../NativeChatStructuredSession.tsx | 4 +- .../structured-agent-session-composer.test.ts | 31 +++++++++++++ .../structured-agent-session-composer.ts | 5 ++- 6 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 src/shared/structured-agent-session-composer.test.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index a1aecd23419..a6f233b96a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -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()), - 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( + 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' }] diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index e0b1d97a057..06ae0ec5c8c 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -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 - structuredTransport - ? STRUCTURED_AGENT_SESSION_SLASH_COMMANDS - : getVerifiedNativeChatCommands(agent), + structuredTransport ? structuredSlashCommands(agent) : getVerifiedNativeChatCommands(agent), [agent, structuredTransport] ) const picker = useNativeChatPickerState({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index d3c13a48ef1..70afd6758aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -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 }, 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( + + ) + + 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( )} diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts new file mode 100644 index 00000000000..38fed5ddbc1 --- /dev/null +++ b/src/shared/structured-agent-session-composer.test.ts @@ -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') + } + }) +}) diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 420651aba5b..18bdeab001d 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -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 }