From aad41b1a406436f98e2f48d584c11e78c71efffd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:56:47 -0700 Subject: [PATCH] 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 --- .../MobileNativeChatPermission.test.ts | 47 ++++++++ .../session/MobileNativeChatPermission.tsx | 78 +++++++++++-- .../session/mobile-native-chat-permission.ts | 7 ++ .../mobile-structured-agent-prompts.ts | 5 + .../claude/claude-permission-presentation.ts | 47 ++++++++ src/main/claude/claude-prompt-registry.ts | 20 +++- .../claude-structured-inbound-control.test.ts | 50 +++++++- .../claude-structured-inbound-control.ts | 2 + .../claude-structured-prompt-items.test.ts | 85 +++++++++++++- .../claude/claude-structured-prompt-items.ts | 20 ++-- .../claude-structured-prompt-replies.ts | 1 + .../journal-prompt-body-bounds.ts | 17 +++ ...ude-structured-session-integration.test.ts | 5 +- .../NativeChatApprovalCard.test.tsx | 78 ++++++++++++- .../native-chat/NativeChatApprovalCard.tsx | 108 +++++++++++++++--- .../NativeChatResolutionReceipt.test.tsx | 15 +++ .../NativeChatResolutionReceipt.tsx | 2 +- .../NativeChatStructuredSession.tsx | 29 +++-- ...native-chat-composer-reveal-focus.test.tsx | 17 +++ .../native-chat-interactive-prompt.ts | 6 + .../use-native-chat-composer-reveal-focus.ts | 4 +- src/renderer/src/i18n/locales/en.json | 5 +- .../agent-session-journal-schemas.test.ts | 7 +- src/shared/agent-session-journal-schemas.ts | 11 ++ src/shared/agent-session-journal-types.ts | 11 ++ 25 files changed, 619 insertions(+), 58 deletions(-) create mode 100644 src/main/claude/claude-permission-presentation.ts diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 88de20a7ca5..a29067cf1f6 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -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 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index 47ad7a52022..e482ac00418 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -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 => { if (submittingRef.current) { return @@ -31,10 +38,17 @@ function MobileNativeChatPermissionImpl({ } } return ( - + - {permission.title} + + {permission.title} + {onCancel ? ( ) : null} - {permission.detail ? {permission.detail} : null} - + {hasContext ? ( + + {permission.description ? ( + {permission.description} + ) : null} + {permission.decisionReason ? ( + + Reason: + {permission.decisionReason} + + ) : null} + {permission.blockedPath ? ( + + Blocked path: + {permission.blockedPath} + + ) : null} + {permission.matchedAskRule ? ( + + Ask rule: + {permission.matchedAskRule.ruleContent ?? permission.matchedAskRule.toolName} + {' · '} + {permission.matchedAskRule.source} + + ) : null} + {permission.detail ? {permission.detail} : null} + + ) : null} + {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, diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index 53799718eb4..dadefc19519 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -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 } diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index 82d619e49e6..329629c2bea 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -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, diff --git a/src/main/claude/claude-permission-presentation.ts b/src/main/claude/claude-permission-presentation.ts new file mode 100644 index 00000000000..4e6d8be1019 --- /dev/null +++ b/src/main/claude/claude-permission-presentation.ts @@ -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[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 } : {}) + } + } + : {}) + } +} diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts index 6411a19e215..78e17824e8a 100644 --- a/src/main/claude/claude-prompt-registry.ts +++ b/src/main/claude/claude-prompt-registry.ts @@ -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, diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts index 07be4bbb516..4e799dd03fc 100644 --- a/src/main/claude/claude-structured-inbound-control.test.ts +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -14,14 +14,17 @@ function permissionOptions( requestId: string, toolUseID: string, signal: AbortSignal, - suggestions?: unknown[] + suggestions?: CanUseToolOptions['suggestions'], + presentation: Partial = {} ): 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( diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 27a90181aec..f613cf1a1c8 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -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((resolve) => { const prompt = deps.prompts.register({ + ...claudePermissionPresentation(options), requestId: options.requestId, toolName, toolUseId: options.toolUseID, diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index eec4ee74c9c..0650cc769b5 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -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, + presentation: Partial = {} +): 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) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index 25b307bd809..8a4f0151d8b 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -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 = { diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 5a6bc19b9a8..ed3763c9d6c 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -10,6 +10,7 @@ export { ClaudePromptRegistry, type ClaudePendingPrompt, type ClaudePromptClaim, + type ClaudePromptPresentation, type ClaudePromptRegistration, type ClaudePromptSettle } from './claude-prompt-registry' diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 2ecd4e22cb6..376c03be4d4 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -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) } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index f6b540bc5af..2fccc11fb3c 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -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', diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx index b3321a6c86f..692ad269e5c 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + /> + ) + + 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) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 3c5766b7f2f..13864e36da1 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -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(null) + const hasContext = Boolean( + approval.description || + approval.decisionReason || + approval.blockedPath || + approval.matchedAskRule || + approval.detail + ) + useEffect(() => { + if (shouldFocus) { + cardRef.current?.focus() + } + }, [shouldFocus]) + return ( -
-
-
-
+
+
+
{ + 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" + > +
-

{approval.title}

- {approval.detail ? ( -

- {approval.detail} -

- ) : null} +

+ {approval.title} +

{onCancel ? (
-
+ {hasContext ? ( +
+ {approval.description ? ( +

{approval.description}

+ ) : null} + {approval.decisionReason ? ( +

+ + {translate('components.native-chat.approval.reason', 'Reason')}:{' '} + + {approval.decisionReason} +

+ ) : null} + {approval.blockedPath ? ( +

+ + {translate('components.native-chat.approval.blockedPath', 'Blocked path')}:{' '} + + {approval.blockedPath} +

+ ) : null} + {approval.matchedAskRule ? ( +

+ + {translate('components.native-chat.approval.askRule', 'Ask rule')}:{' '} + + {approval.matchedAskRule.ruleContent ?? approval.matchedAskRule.toolName} + + {' · '} + {approval.matchedAskRule.source} + +

+ ) : null} + {approval.detail ? ( +
+ {approval.detail} +
+ ) : null} +
+ ) : null} +
{approval.options.map((opt, i) => (
- {prompt?.body.kind === 'approval' ? ( + {prompt && approval ? ( ({ - 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 ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index 4c6023d63f2..b83f1df97e6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -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') diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 1590f55e7cc..3c04b5b3343 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -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 }[] } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index c8a3e059489..819bc1e5229 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 425b65754b9..8cfb88a7989 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 3a515952601..31b55077331 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -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 diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 5efd47e35c7..3a5f37e9f09 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -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 diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 6978b92b078..a8e11c8665a 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -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