fix(native-chat): render approvals from the harness presentation, not serialized tool input (#21087)

* fix(native-chat): render approvals from the harness presentation, not serialized tool input

The approval card built its title from the tool name and rendered
JSON.stringify(input) into an element with no height bound. Any large
payload - a file write's contents, a proposed plan - pushed the action
buttons past the viewport with no way to scroll to them, leaving the
prompt unanswerable without zooming the pane out.

Thread the agent SDK's own presentation fields through the prompt
registry into the journal item: title, displayName, description,
decisionReason, blockedPath and matchedAskRule. The SDK documents its
title as the prompt text to use instead of reconstructing one, and
warns that the decision reason may carry terminal escapes, so those are
stripped before rendering. The card now also shows why a request was
raised rather than only what it was.

Bound the detail in a scrollable region that is reachable by keyboard,
and cap it main-side with the existing shared tool-detail limit rather
than the far looser journal payload bound. Focus moves to the card when
a prompt appears and Escape resolves it, which previously did nothing
because the composer owning that handler is unmounted while a prompt is
pending.

Mobile rendered the same unbounded detail and is fixed alongside.

* fix(native-chat): keep approval actions reachable
This commit is contained in:
Brennan Benson
2026-09-16 16:56:47 -07:00
committed by GitHub
parent 55ae3b393c
commit aad41b1a40
25 changed files with 619 additions and 58 deletions
@@ -5,6 +5,7 @@ import { MobileNativeChatPermission } from './MobileNativeChatPermission'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
@@ -62,4 +63,50 @@ describe('MobileNativeChatPermission', () => {
await act(async () => cancel.props.onPress())
expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 4 })
})
it('keeps oversized provider context in a bounded scroller above the actions', async () => {
const description = `Workspace access ${'description '.repeat(400)}`
const decisionReason = `Outside the allowed root ${'reason '.repeat(400)}`
const blockedPath = `/repo/${'nested/'.repeat(400)}secrets.txt`
const ruleContent = `/repo/${'**/'.repeat(400)}`
await act(async () => {
renderer = create(
createElement(MobileNativeChatPermission, {
permission: {
title: 'Claude wants to read secrets.txt '.repeat(400),
description,
decisionReason,
blockedPath,
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent },
options: [{ label: 'Allow', send: '1' }]
},
onRespond: vi.fn(async () => true)
})
)
})
const card = renderer.root.findByProps({ testID: 'native-chat-approval-card' })
const title = renderer.root.findByProps({ testID: 'native-chat-approval-title' })
const content = renderer.root.findByProps({ testID: 'native-chat-approval-content' })
const actions = renderer.root.findByProps({ testID: 'native-chat-approval-actions' })
const contentText = content.findAllByType('Text')
const containsText = (value: string): boolean =>
contentText.some((node) => {
const children = Array.isArray(node.props.children)
? node.props.children
: [node.props.children]
return children.includes(value)
})
expect(card.props.style).toMatchObject({ flexShrink: 1, minHeight: 0 })
expect(title.props).toMatchObject({ numberOfLines: 2, ellipsizeMode: 'tail' })
expect(content.props.style).toMatchObject({ maxHeight: 240, minHeight: 0, flexShrink: 1 })
expect(containsText(description)).toBe(true)
expect(containsText(decisionReason)).toBe(true)
expect(containsText(blockedPath)).toBe(true)
expect(containsText(ruleContent)).toBe(true)
expect(content.findAllByProps({ children: 'Allow' })).toHaveLength(0)
expect(actions.findAllByProps({ children: 'Allow' })).toHaveLength(1)
expect(actions.props.style).toMatchObject({ flexShrink: 0 })
})
})
@@ -1,5 +1,5 @@
import { memo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { ShieldQuestion, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { MobileChatPermission } from './mobile-native-chat-permission'
@@ -18,6 +18,13 @@ function MobileNativeChatPermissionImpl({
}): React.JSX.Element {
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
const hasContext = Boolean(
permission.description ||
permission.decisionReason ||
permission.blockedPath ||
permission.matchedAskRule ||
permission.detail
)
const respond = async (send: string): Promise<void> => {
if (submittingRef.current) {
return
@@ -31,10 +38,17 @@ function MobileNativeChatPermissionImpl({
}
}
return (
<View style={styles.card}>
<View testID="native-chat-approval-card" style={styles.card}>
<View style={styles.header}>
<ShieldQuestion size={16} color={colors.accentBlue} strokeWidth={2} />
<Text style={styles.title}>{permission.title}</Text>
<Text
testID="native-chat-approval-title"
style={styles.title}
numberOfLines={2}
ellipsizeMode="tail"
>
{permission.title}
</Text>
{onCancel ? (
<Pressable
accessibilityLabel="Cancel"
@@ -47,8 +61,40 @@ function MobileNativeChatPermissionImpl({
</Pressable>
) : null}
</View>
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
<View style={styles.options}>
{hasContext ? (
<ScrollView
testID="native-chat-approval-content"
style={styles.contentScroll}
contentContainerStyle={styles.content}
nestedScrollEnabled
>
{permission.description ? (
<Text style={styles.detail}>{permission.description}</Text>
) : null}
{permission.decisionReason ? (
<Text style={styles.detail}>
<Text style={styles.contextLabel}>Reason: </Text>
{permission.decisionReason}
</Text>
) : null}
{permission.blockedPath ? (
<Text style={styles.detail}>
<Text style={styles.contextLabel}>Blocked path: </Text>
{permission.blockedPath}
</Text>
) : null}
{permission.matchedAskRule ? (
<Text style={styles.detail}>
<Text style={styles.contextLabel}>Ask rule: </Text>
{permission.matchedAskRule.ruleContent ?? permission.matchedAskRule.toolName}
{' · '}
{permission.matchedAskRule.source}
</Text>
) : null}
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
</ScrollView>
) : null}
<View testID="native-chat-approval-actions" style={styles.options}>
{permission.options.map((option, index) => {
const isPrimary = index === 0
return (
@@ -85,12 +131,15 @@ const styles = StyleSheet.create({
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
backgroundColor: colors.bgPanel,
flexShrink: 1,
minHeight: 0
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
gap: spacing.sm,
flexShrink: 0
},
title: {
flex: 1,
@@ -109,10 +158,23 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
lineHeight: typography.metaSize + 5
},
contextLabel: {
color: colors.textPrimary,
fontWeight: '600'
},
contentScroll: {
maxHeight: 240,
minHeight: 0,
flexShrink: 1
},
content: {
gap: spacing.sm
},
options: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm
gap: spacing.sm,
flexShrink: 0
},
option: {
minHeight: 44,
@@ -1,3 +1,5 @@
import type { AgentJournalApprovalMatchedAskRule } from '../../../src/shared/agent-session-journal-types'
// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface
// as plain TUI text in the agent's last assistant message — there is no
// structured permission event on mobile. We detect them heuristically so the
@@ -10,6 +12,11 @@
* (e.g. "y", "1") when the user taps it. */
export type MobileChatPermission = {
title: string
displayName?: string
description?: string
decisionReason?: string
blockedPath?: string
matchedAskRule?: AgentJournalApprovalMatchedAskRule
detail?: string
/** Structured prompt identity, present only when the host can cancel it exactly. */
prompt?: { itemId: string; expectedRevision: number }
@@ -135,6 +135,11 @@ export function projectStructuredPermission(
return {
title: prompt.body.title,
prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision },
...(prompt.body.displayName ? { displayName: prompt.body.displayName } : {}),
...(prompt.body.description ? { description: prompt.body.description } : {}),
...(prompt.body.decisionReason ? { decisionReason: prompt.body.decisionReason } : {}),
...(prompt.body.blockedPath ? { blockedPath: prompt.body.blockedPath } : {}),
...(prompt.body.matchedAskRule ? { matchedAskRule: prompt.body.matchedAskRule } : {}),
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
options: prompt.body.options.map((option) => ({
label: option.label,
@@ -0,0 +1,47 @@
import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk'
import {
stripAnsiEscapeSequences,
TERMINAL_CONTROL_CHARACTER_PATTERN
} from '../../shared/ansi-escape-sequences'
import type { ClaudePromptPresentation } from './claude-prompt-registry'
type ClaudePermissionOptions = Parameters<CanUseTool>[2]
function presentationText(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const sanitized = stripAnsiEscapeSequences(value)
.replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
.trim()
return sanitized.length > 0 ? sanitized : null
}
export function claudePermissionPresentation(
options: ClaudePermissionOptions
): ClaudePromptPresentation {
const title = presentationText(options.title)
const displayName = presentationText(options.displayName)
const description = presentationText(options.description)
const decisionReason = presentationText(options.decisionReason)
const blockedPath = presentationText(options.blockedPath)
const matchedSource = presentationText(options.matchedAskRule?.source)
const matchedToolName = presentationText(options.matchedAskRule?.toolName)
const matchedRuleContent = presentationText(options.matchedAskRule?.ruleContent)
return {
...(title ? { title } : {}),
...(displayName ? { displayName } : {}),
...(description ? { description } : {}),
...(decisionReason ? { decisionReason } : {}),
...(blockedPath ? { blockedPath } : {}),
...(matchedSource && matchedToolName
? {
matchedAskRule: {
source: matchedSource,
toolName: matchedToolName,
...(matchedRuleContent ? { ruleContent: matchedRuleContent } : {})
}
}
: {})
}
}
+18 -2
View File
@@ -1,9 +1,19 @@
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import type { AgentJournalApprovalMatchedAskRule } from '../../shared/agent-session-journal-types'
/** Settles the SDK's `canUseTool` promise; `null` writes no provider response. */
export type ClaudePromptSettle = (response: PermissionResult | null) => void
export type ClaudePendingPrompt = {
export type ClaudePromptPresentation = {
title?: string
displayName?: string
description?: string
decisionReason?: string
blockedPath?: string
matchedAskRule?: AgentJournalApprovalMatchedAskRule
}
export type ClaudePendingPrompt = ClaudePromptPresentation & {
requestId: string
promptKey: string
toolUseId: string
@@ -17,7 +27,7 @@ export type ClaudePendingPrompt = {
turnId?: string | null
}
export type ClaudePromptRegistration = {
export type ClaudePromptRegistration = ClaudePromptPresentation & {
requestId: string
toolName: string
toolUseId: string
@@ -89,6 +99,12 @@ export class ClaudePromptRegistry {
kind: questions.length > 0 ? 'question' : 'approval',
input,
suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [],
...(registration.title ? { title: registration.title } : {}),
...(registration.displayName ? { displayName: registration.displayName } : {}),
...(registration.description ? { description: registration.description } : {}),
...(registration.decisionReason ? { decisionReason: registration.decisionReason } : {}),
...(registration.blockedPath ? { blockedPath: registration.blockedPath } : {}),
...(registration.matchedAskRule ? { matchedAskRule: registration.matchedAskRule } : {}),
questionIds: questions.map(questionId),
answers: new Map(),
settle: registration.settle,
@@ -14,14 +14,17 @@ function permissionOptions(
requestId: string,
toolUseID: string,
signal: AbortSignal,
suggestions?: unknown[]
suggestions?: CanUseToolOptions['suggestions'],
presentation: Partial<CanUseToolOptions> = {}
): CanUseToolOptions {
// Spread first so the required fields below stay definite rather than optional.
return {
...presentation,
requestId,
toolUseID,
signal,
...(suggestions ? { suggestions } : {})
} as unknown as CanUseToolOptions
}
}
function callbacksFor() {
@@ -41,7 +44,9 @@ describe('Claude permission callbacks', () => {
const answered = control.canUseTool(
'Bash',
{ command: 'git status' },
permissionOptions('perm-1', 'tool-1', new AbortController().signal, [{ type: 'addRules' }])
permissionOptions('perm-1', 'tool-1', new AbortController().signal, [
{ type: 'addRules', rules: [], behavior: 'allow', destination: 'session' }
])
)
expect(control.emit).toHaveBeenCalledWith(
@@ -52,12 +57,49 @@ describe('Claude permission callbacks', () => {
})
)
const found = control.prompts.find('perm-1')
expect(found?.prompt.suggestions).toEqual([{ type: 'addRules' }])
expect(found?.prompt.suggestions).toEqual([
{ type: 'addRules', rules: [], behavior: 'allow', destination: 'session' }
])
// The prompt's settle is the SDK callback's own resolve — answering resolves this promise.
found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' })
await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' })
})
it('keeps the SDK permission presentation and strips terminal escapes', async () => {
const control = callbacksFor()
const answered = control.canUseTool(
'Read',
{ file_path: '/repo/secrets.txt' },
permissionOptions(
'perm-presentation',
'tool-presentation',
new AbortController().signal,
[],
{
title: '\u001b[31mClaude wants to read secrets.txt\u001b[0m',
displayName: 'Read file',
description: 'Read access outside the workspace',
decisionReason: '\u001b[33mThe path is outside the allowed root.\u001b[0m',
blockedPath: '/repo/secrets.txt',
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' }
}
)
)
const prompt = control.prompts.find('perm-presentation')?.prompt
expect(prompt).toMatchObject({
title: 'Claude wants to read secrets.txt',
displayName: 'Read file',
description: 'Read access outside the workspace',
decisionReason: 'The path is outside the allowed root.',
blockedPath: '/repo/secrets.txt',
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' }
})
expect(JSON.stringify(prompt)).not.toContain('\\u001b')
prompt?.settle({ behavior: 'deny', message: 'done', toolUseID: 'tool-presentation' })
await expect(answered).resolves.toMatchObject({ behavior: 'deny' })
})
it('denies a malformed permission request without registering a prompt', async () => {
const control = callbacksFor()
const answered = control.canUseTool(
@@ -1,6 +1,7 @@
import type { CanUseTool, OnUserDialog, PermissionResult } from '@anthropic-ai/claude-agent-sdk'
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import { claudePermissionPresentation } from './claude-permission-presentation'
export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool'
export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog'
@@ -53,6 +54,7 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep
const canUseTool: CanUseTool = (toolName, input, options) =>
new Promise<PermissionResult | null>((resolve) => {
const prompt = deps.prompts.register({
...claudePermissionPresentation(options),
requestId: options.requestId,
toolName,
toolUseId: options.toolUseID,
@@ -3,13 +3,96 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key
import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer'
import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds'
import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema'
import { claudeQuestionItems } from './claude-structured-prompt-items'
import { MAX_TOOL_DETAIL_LENGTH } from '../../shared/native-chat-tool-summary'
import { claudeApprovalItem, claudeQuestionItems } from './claude-structured-prompt-items'
import {
applyClaudePromptAnswer,
encodeClaudeQuestionOptionId,
type ClaudePendingPrompt
} from './claude-structured-prompt-replies'
function approvalPrompt(
input: Record<string, unknown>,
presentation: Partial<ClaudePendingPrompt> = {}
): ClaudePendingPrompt {
return {
requestId: 'approval-1',
promptKey: 'approval-1',
toolUseId: 'tool-approval',
toolName: 'ExitPlanMode',
kind: 'approval',
input,
suggestions: [],
questionIds: [],
answers: new Map(),
settle: () => {},
...presentation
}
}
describe('Claude structured approval presentation', () => {
it('journals the harness presentation instead of reconstructing from tool input', () => {
const prompt = approvalPrompt(
{ file_path: '/repo/secrets.txt', content: 'export const token = 1' },
{
toolName: 'Write',
title: 'Claude wants to write secrets.txt',
displayName: 'Write file',
description: 'Write access inside the workspace.',
decisionReason: 'The path requires approval.',
blockedPath: '/repo/secrets.txt',
matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' }
}
)
expect(claudeApprovalItem(prompt)).toMatchObject({
kind: 'approval',
title: 'Claude wants to write secrets.txt',
displayName: 'Write file',
description: 'Write access inside the workspace.',
decisionReason: 'The path requires approval.',
blockedPath: '/repo/secrets.txt',
matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' },
options: [
{ id: 'allow', label: 'Allow' },
{ id: 'allowForSession', label: 'Allow for this session' },
{ id: 'deny', label: 'Deny' },
{ id: 'cancel', label: 'Stop' }
]
})
})
it.each([{ plan: '' }, {}])(
'falls back to a reconstructed title when the harness sends no presentation',
(input) => {
const item = claudeApprovalItem(approvalPrompt(input))
expect(item.title).toBe('Allow ExitPlanMode?')
expect(item.detail).toContain('{')
expect(item.options[0]?.label).toBe('Allow')
}
)
it('caps an oversized generic payload with the shared tool-detail limit', () => {
const item = claudeApprovalItem(
approvalPrompt({ plan: '', payload: 'x'.repeat(MAX_TOOL_DETAIL_LENGTH * 2) })
)
expect(item.detail?.length).toBeLessThanOrEqual(MAX_TOOL_DETAIL_LENGTH + 1)
expect(item.detail?.endsWith('…')).toBe(true)
})
it('denying authorizes nothing', () => {
const prompt = approvalPrompt({ plan: '# Release' })
expect(applyClaudePromptAnswer({ prompt }, 'deny')).toEqual({
behavior: 'deny',
message: 'User denied this action.',
toolUseID: 'tool-approval'
})
})
})
describe('Claude structured question addressing', () => {
it('bounds a valid grouped question before cancellation enters a lifecycle batch', () => {
const oversized = 'large prompt text '.repeat(40_000)
@@ -5,10 +5,7 @@ import type {
AgentJournalQuestion,
AgentJournalQuestionItem
} from '../../shared/agent-session-journal-types'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { formatToolInput, truncateToolDetail } from '../../shared/native-chat-tool-summary'
import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
import {
@@ -45,17 +42,22 @@ export function claudePromptIdentity(input: {
}
export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem {
const serialized = JSON.stringify(prompt.input)
return {
const detail = truncateToolDetail(formatToolInput(prompt.input))
return boundJournalPromptBody({
kind: 'approval',
title: `Allow ${prompt.toolName}?`,
detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null,
title: prompt.title ?? `Allow ${prompt.toolName}?`,
...(prompt.displayName ? { displayName: prompt.displayName } : {}),
...(prompt.description ? { description: prompt.description } : {}),
...(prompt.decisionReason ? { decisionReason: prompt.decisionReason } : {}),
...(prompt.blockedPath ? { blockedPath: prompt.blockedPath } : {}),
...(prompt.matchedAskRule ? { matchedAskRule: prompt.matchedAskRule } : {}),
detail: detail || null,
options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({
id: decision,
label: APPROVAL_LABELS[decision]
})),
resolution: { ...PENDING }
}
})
}
export type ClaudeQuestionItem = {
@@ -10,6 +10,7 @@ export {
ClaudePromptRegistry,
type ClaudePendingPrompt,
type ClaudePromptClaim,
type ClaudePromptPresentation,
type ClaudePromptRegistration,
type ClaudePromptSettle
} from './claude-prompt-registry'
@@ -51,6 +51,23 @@ export function boundJournalPromptBody(
return {
...body,
title: boundPromptText(body.title),
...(body.displayName === undefined ? {} : { displayName: boundPromptText(body.displayName) }),
...(body.description === undefined ? {} : { description: boundPromptText(body.description) }),
...(body.decisionReason === undefined
? {}
: { decisionReason: boundPromptText(body.decisionReason) }),
...(body.blockedPath === undefined ? {} : { blockedPath: boundPromptText(body.blockedPath) }),
...(body.matchedAskRule === undefined
? {}
: {
matchedAskRule: {
source: boundPromptText(body.matchedAskRule.source),
toolName: boundPromptText(body.matchedAskRule.toolName),
...(body.matchedAskRule.ruleContent === undefined
? {}
: { ruleContent: boundPromptText(body.matchedAskRule.ruleContent) })
}
}),
detail: body.detail === null ? null : boundPromptText(body.detail),
options: boundPromptOptions(body.options)
}
@@ -661,7 +661,10 @@ describe('a structured Claude session over agentSession.*', () => {
)
await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION)
const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval')
expect(approval?.body).toMatchObject({ title: 'Allow Bash?', detail: '{"command":"ls"}' })
expect(approval?.body).toMatchObject({
title: 'Allow Bash?',
detail: '{\n "command": "ls"\n}'
})
await ok('agentSession.respondToApproval', {
envelope: envelope(
'agentSession.respondTo:approval',
@@ -1,9 +1,11 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
afterEach(cleanup)
describe('NativeChatApprovalCard', () => {
it('exposes cancellation while it owns the composer region', () => {
const onCancel = vi.fn()
@@ -26,4 +28,76 @@ describe('NativeChatApprovalCard', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onCancel).toHaveBeenCalledOnce()
})
it('focuses once on appearance and routes Escape through cancellation', () => {
const onCancel = vi.fn()
const { rerender } = render(
<NativeChatApprovalCard
approval={{ title: 'Allow command?', options: [{ label: 'Allow', send: 'allow' }] }}
onChoose={() => {}}
onCancel={onCancel}
shouldFocus
/>
)
const card = screen.getByRole('group', { name: 'Allow command?' })
expect(document.activeElement).toBe(card)
fireEvent.keyDown(card, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledOnce()
const outside = document.createElement('button')
document.body.appendChild(outside)
outside.focus()
rerender(
<NativeChatApprovalCard
approval={{ title: 'Allow command?', options: [{ label: 'Allow', send: 'allow' }] }}
onChoose={() => {}}
onCancel={onCancel}
shouldFocus
/>
)
expect(document.activeElement).toBe(outside)
outside.remove()
})
it('keeps all oversized provider context in one bounded scroller above the actions', () => {
const description = `Read access outside the workspace ${'description '.repeat(400)}`
const decisionReason = `The path is outside the allowed root. ${'reason '.repeat(400)}`
const blockedPath = `/repo/${'nested/'.repeat(400)}secrets.txt`
const ruleContent = `/repo/${'**/'.repeat(400)}`
render(
<NativeChatApprovalCard
approval={{
title: 'Claude wants to read secrets.txt '.repeat(400),
description,
decisionReason,
blockedPath,
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent },
detail: 'x'.repeat(4_000),
options: [{ label: 'Allow', send: 'allow' }]
}}
onChoose={() => {}}
/>
)
const card = document.querySelector('[data-native-chat-approval-card="true"]')
const content = document.querySelector('[data-native-chat-approval-content="true"]')
const detail = document.querySelector('[data-native-chat-approval-detail="true"]')
const actions = document.querySelector('[data-native-chat-approval-actions="true"]')
const allow = screen.getByRole('button', { name: 'Allow' })
expect(card?.classList.contains('min-h-0')).toBe(true)
expect(card?.classList.contains('overflow-hidden')).toBe(true)
expect(content?.classList.contains('max-h-72')).toBe(true)
expect(content?.classList.contains('overflow-auto')).toBe(true)
expect(content?.getAttribute('tabindex')).toBe('0')
expect(content?.textContent).toContain(description.trim())
expect(content?.textContent).toContain(decisionReason.trim())
expect(content?.textContent).toContain(blockedPath)
expect(content?.textContent).toContain(ruleContent)
expect(content?.contains(detail)).toBe(true)
expect(content?.contains(allow)).toBe(false)
expect(actions?.contains(allow)).toBe(true)
expect(actions?.classList.contains('shrink-0')).toBe(true)
})
})
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react'
import { ShieldQuestion, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -5,36 +6,62 @@ import type { ChatApproval } from './native-chat-interactive-prompt'
export type NativeChatApprovalCardProps = {
approval: ChatApproval
/** Send the chosen option's literal string to the agent's PTY. */
onChoose: (send: string) => void
/** Deliver the option's transport-specific response token. */
onChoose: (option: string) => void
/** Cancel the active provider turn while this card owns the composer region. */
onCancel?: () => void
shouldFocus?: boolean
}
/**
* Native renderer for an agent tool-approval (PermissionRequest) as an
* Allow/Deny card. Each button writes its option's literal `send` string back
* to the agent (a number to allow; ESC to deny). The first option reads as the
* affirmative action and gets the primary styling.
* Allow/Deny card. PTY callers supply literal replies while structured callers
* supply journal option IDs. The first option gets the primary styling.
*/
export function NativeChatApprovalCard({
approval,
onChoose,
onCancel
onCancel,
shouldFocus = false
}: NativeChatApprovalCardProps): React.JSX.Element {
const cardRef = useRef<HTMLDivElement>(null)
const hasContext = Boolean(
approval.description ||
approval.decisionReason ||
approval.blockedPath ||
approval.matchedAskRule ||
approval.detail
)
useEffect(() => {
if (shouldFocus) {
cardRef.current?.focus()
}
}, [shouldFocus])
return (
<div className="shrink-0 bg-background">
<div className="mx-auto w-full max-w-4xl px-3 pt-2 pb-1 sm:px-4">
<div className="flex w-full flex-col gap-2 rounded-lg border border-input bg-card px-4 py-3 shadow-xs">
<div className="flex items-start gap-2">
<div className="min-h-0 shrink overflow-hidden bg-background">
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-4xl px-3 pt-2 pb-1 sm:px-4">
<div
ref={cardRef}
data-native-chat-approval-card="true"
role="group"
aria-label={approval.title}
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel) {
event.preventDefault()
event.stopPropagation()
onCancel()
}
}}
className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex shrink-0 items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
{approval.detail ? (
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
{approval.detail}
</p>
) : null}
<p className="line-clamp-2 break-words text-sm font-semibold text-foreground">
{approval.title}
</p>
</div>
{onCancel ? (
<button
@@ -47,7 +74,54 @@ export function NativeChatApprovalCard({
</button>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{hasContext ? (
<div
data-native-chat-approval-content="true"
tabIndex={0}
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-xs text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
>
{approval.description ? (
<p className="whitespace-pre-wrap break-words">{approval.description}</p>
) : null}
{approval.decisionReason ? (
<p className="whitespace-pre-wrap break-words">
<span className="font-medium text-foreground/80">
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
</span>
{approval.decisionReason}
</p>
) : null}
{approval.blockedPath ? (
<p className="break-words">
<span className="font-medium text-foreground/80">
{translate('components.native-chat.approval.blockedPath', 'Blocked path')}:{' '}
</span>
<span className="font-mono">{approval.blockedPath}</span>
</p>
) : null}
{approval.matchedAskRule ? (
<p className="break-words">
<span className="font-medium text-foreground/80">
{translate('components.native-chat.approval.askRule', 'Ask rule')}:{' '}
</span>
{approval.matchedAskRule.ruleContent ?? approval.matchedAskRule.toolName}
<span className="text-muted-foreground/80">
{' · '}
{approval.matchedAskRule.source}
</span>
</p>
) : null}
{approval.detail ? (
<div
data-native-chat-approval-detail="true"
className="whitespace-pre-wrap break-words font-mono"
>
{approval.detail}
</div>
) : null}
</div>
) : null}
<div data-native-chat-approval-actions="true" className="flex shrink-0 flex-wrap gap-2">
{approval.options.map((opt, i) => (
<button
key={`${opt.label}-${i}`}
@@ -62,6 +62,21 @@ describe('resolution receipts', () => {
expect(screen.queryByRole('button')).toBeNull()
})
it('uses the SDK display name in the compact resolved receipt', () => {
render(
<NativeChatResolutionReceipt
body={{
...approval,
title: 'Claude wants to present its implementation plan',
displayName: 'Present plan'
}}
/>
)
expect(screen.getByText('Present plan')).toBeInTheDocument()
expect(screen.queryByText('Claude wants to present its implementation plan')).toBeNull()
})
it('renders cancellation quietly without inventing a choice or resolver', () => {
render(
<NativeChatResolutionReceipt
@@ -35,7 +35,7 @@ export function NativeChatResolutionReceipt({
) : null
}
const { resolution } = body
const title = body.kind === 'approval' ? body.title : body.question
const title = body.kind === 'approval' ? (body.displayName ?? body.title) : body.question
const answers = nativeChatReceiptAnswers(body)
return (
<div
@@ -104,6 +104,22 @@ export function NativeChatStructuredSession(
{ sessionId: props.sessionId, isVisible: props.isVisible }
)
const prompt = controller.prompts[0] ?? null
const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null
const approval = approvalBody
? {
title: approvalBody.title,
...(approvalBody.displayName ? { displayName: approvalBody.displayName } : {}),
...(approvalBody.description ? { description: approvalBody.description } : {}),
...(approvalBody.decisionReason ? { decisionReason: approvalBody.decisionReason } : {}),
...(approvalBody.blockedPath ? { blockedPath: approvalBody.blockedPath } : {}),
...(approvalBody.matchedAskRule ? { matchedAskRule: approvalBody.matchedAskRule } : {}),
...(approvalBody.detail ? { detail: approvalBody.detail } : {}),
options: approvalBody.options.map((option) => ({
label: option.label,
send: option.id
}))
}
: null
const cancelPrompt = () => {
if (controller.turnId && prompt) {
void controller.cancel(controller.turnId, {
@@ -222,18 +238,13 @@ export function NativeChatStructuredSession(
/>
)}
</div>
{prompt?.body.kind === 'approval' ? (
{prompt && approval ? (
<NativeChatApprovalCard
approval={{
title: prompt.body.title,
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
options: prompt.body.options.map((option) => ({
label: option.label,
send: option.id
}))
}}
key={`${prompt.itemId}:${prompt.revision}`}
approval={approval}
onChoose={(optionId) => void controller.respond(prompt, optionId)}
onCancel={cancelPrompt}
shouldFocus={props.isVisible && props.isFocusedGroup}
/>
) : null}
{prompt && questionBody ? (
@@ -254,6 +254,23 @@ describe('useNativeChatComposerRevealFocus', () => {
expect(focusCalls).toBe(1)
})
it('re-arms when a prompt replaces an already focused composer', () => {
render({ isVisible: true, isFocusedGroup: true, composerReady: true })
drainFrames()
expect(focusCalls).toBe(1)
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
drainFrames()
act(() => {
;(document.activeElement as HTMLElement | null)?.blur()
})
render({ isVisible: true, isFocusedGroup: true, composerReady: true })
drainFrames()
expect(focusCalls).toBe(2)
expect(container.querySelector('textarea')).toBe(document.activeElement)
})
it('leaves focus alone when it is already inside the pane', () => {
render({ isVisible: false, isFocusedGroup: true })
const field = container.querySelector('textarea')
@@ -1,4 +1,5 @@
import { translate } from '@/i18n/i18n'
import type { AgentJournalApprovalMatchedAskRule } from '../../../../shared/agent-session-journal-types'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
@@ -31,6 +32,11 @@ export {
export type ChatApproval = {
title: string
displayName?: string
description?: string
decisionReason?: string
blockedPath?: string
matchedAskRule?: AgentJournalApprovalMatchedAskRule
detail?: string
options: { label: string; send: string }[]
}
@@ -36,11 +36,11 @@ export function useNativeChatComposerRevealFocus({
const claimedRef = useRef(false)
useEffect(() => {
if (!revealed) {
if (!revealed || !composerReady) {
claimedRef.current = false
return
}
if (claimedRef.current || !composerReady) {
if (claimedRef.current) {
return
}
let cancelled = false
+4 -1
View File
@@ -17316,7 +17316,10 @@
"title": "Allow {{value0}}?",
"allow": "Allow",
"deny": "Deny",
"cancel": "Cancel"
"cancel": "Cancel",
"reason": "Reason",
"blockedPath": "Blocked path",
"askRule": "Ask rule"
},
"launchPromptNotDelivered": "Not delivered — check the terminal",
"structuredSessionCloseFailed": "Could not close this chat session",
@@ -50,7 +50,12 @@ const CANONICAL_BODIES: AgentJournalItemBody[] = [
{ kind: 'diff', path: 'a.ts', patch: PAYLOAD },
{
kind: 'approval',
title: 'Run?',
title: 'Claude wants to present a plan',
displayName: 'Present plan',
description: 'Review the proposed implementation steps.',
decisionReason: 'Plan mode requires approval.',
blockedPath: '/repo/PLAN.md',
matchedAskRule: { source: 'project', toolName: 'ExitPlanMode', ruleContent: 'ask' },
detail: null,
options: [{ id: 'a', label: 'Yes' }],
resolution: RESOLUTION
@@ -132,6 +132,12 @@ const Resolution = z.object({
resolvedAt: z.number().nullable()
})
const ApprovalMatchedAskRule = z.object({
source: z.string(),
toolName: z.string(),
ruleContent: z.string().optional()
})
const MessageBody = z.object({
kind: z.literal('message'),
role: z.string().min(1),
@@ -154,6 +160,11 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('approval'),
title: z.string(),
displayName: z.string().optional(),
description: z.string().optional(),
decisionReason: z.string().optional(),
blockedPath: z.string().optional(),
matchedAskRule: ApprovalMatchedAskRule.optional(),
detail: z.string().nullable(),
options: z.array(PromptOption),
resolution: Resolution
+11
View File
@@ -140,9 +140,20 @@ export type AgentJournalQuestion = {
freeTextQuestionId?: string
}
export type AgentJournalApprovalMatchedAskRule = {
source: string
toolName: string
ruleContent?: string
}
export type AgentJournalApprovalItem = {
kind: 'approval'
title: string
displayName?: string
description?: string
decisionReason?: string
blockedPath?: string
matchedAskRule?: AgentJournalApprovalMatchedAskRule
detail: string | null
options: AgentJournalPromptOption[]
resolution: AgentJournalResolution