Merge remote-tracking branch 'origin/main' into brennanb2025/nc-resume-on-restart

This commit is contained in:
Brennan Benson
2026-09-16 17:27:00 -07:00
29 changed files with 735 additions and 70 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
@@ -17347,7 +17347,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
+25 -1
View File
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
@@ -16,6 +16,7 @@ import {
isUnsupportedWorktreeListZError
} from './git-worktree-command-capabilities'
import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env'
import { buildGitGrepArgs } from './text-search'
import { parseGitRemoteFetchUrls } from './git-remote-url-index'
import { GIT_HISTORY_COMMIT_FORMAT, parseGitHistoryLog } from './git-history-log-parser'
import {
@@ -566,4 +567,27 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
expect(item?.subject).toBe('decorated commit')
expect(item?.references?.map((ref) => ref.id)).toContain('refs/tags/compat-decorated')
})
it('excludes and includes a directory subtree through the generated pathspecs', async () => {
await mkdir(join(repoPath, 'vendored'), { recursive: true })
await writeFile(join(repoPath, 'vendored', 'inner.txt'), 'pathspecneedle\n')
await writeFile(join(repoPath, 'kept.txt'), 'pathspecneedle\n')
await runGit(['add', '-A'])
await runGit(['commit', '-qm', 'pathspec fixture'])
const listFiles = async (opts: Parameters<typeof buildGitGrepArgs>[1]): Promise<string[]> => {
const args = buildGitGrepArgs('pathspecneedle', opts).map((arg) =>
arg === '-n' ? '-l' : arg
)
const { stdout } = await runGit(args)
return stdout.split(/[\0\n]/).filter(Boolean)
}
const excluded = await listFiles({ excludePattern: 'vendored' })
expect(excluded).toContain('kept.txt')
expect(excluded.some((file) => file.startsWith('vendored/'))).toBe(false)
const included = await listFiles({ includePattern: 'vendored' })
expect(included).toEqual(['vendored/inner.txt'])
})
})
+10
View File
@@ -37,3 +37,13 @@ export function toGitGlobPathspec(glob: string, exclude?: boolean): string {
const pattern = needsRecursive ? `**/${glob}` : glob
return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}`
}
export function toGitGlobPathspecs(glob: string, exclude?: boolean): string[] {
const directoryOnly = /\/+$/u.test(glob)
const trimmed = glob.replace(/\/+$/, '')
if (!trimmed) {
return []
}
const pathspec = toGitGlobPathspec(trimmed, exclude)
return directoryOnly ? [`${pathspec}/**`] : [pathspec, `${pathspec}/**`]
}
+69 -5
View File
@@ -14,7 +14,7 @@ import {
MAX_LINE_CONTENT_LENGTH,
SEARCH_JSON_STRUCTURE_LIMITS
} from './text-search'
import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns'
import { splitSearchGlobPatterns, toGitGlobPathspecs } from './text-search-glob-patterns'
import { normalizeRelativePath } from './text-search-paths'
describe('normalizeRelativePath', () => {
@@ -239,6 +239,18 @@ describe('buildGitGrepArgs', () => {
expect(args).toContain(':(exclude,glob)dist/**')
})
it('excludes a directory subtree the way rg --glob does', () => {
const args = buildGitGrepArgs('q', { excludePattern: 'node_modules' })
expect(args).toContain(':(exclude,glob)**/node_modules')
expect(args).toContain(':(exclude,glob)**/node_modules/**')
})
it('includes a directory subtree the way rg --glob does', () => {
const args = buildGitGrepArgs('q', { includePattern: 'src' })
expect(args).toContain(':(glob)**/src')
expect(args).toContain(':(glob)**/src/**')
})
it('keeps escaped commas inside one generated folder pathspec', () => {
const args = buildGitGrepArgs('q', { includePattern: 'foo\\,bar/**, *.ts' })
expect(args).toContain(':(glob)foo\\,bar/**')
@@ -246,11 +258,29 @@ describe('buildGitGrepArgs', () => {
})
})
describe('toGitGlobPathspec', () => {
describe('toGitGlobPathspecs', () => {
it('wraps bare globs with **/ to match recursively', () => {
expect(toGitGlobPathspec('*.ts')).toBe(':(glob)**/*.ts')
expect(toGitGlobPathspec('src/*.ts')).toBe(':(glob)src/*.ts')
expect(toGitGlobPathspec('*.ts', true)).toBe(':(exclude,glob)**/*.ts')
expect(toGitGlobPathspecs('*.ts')).toContain(':(glob)**/*.ts')
expect(toGitGlobPathspecs('src/*.ts')).toContain(':(glob)src/*.ts')
expect(toGitGlobPathspecs('*.ts', true)).toContain(':(exclude,glob)**/*.ts')
})
it('also covers the subtree so a directory name behaves like rg --glob', () => {
expect(toGitGlobPathspecs('node_modules', true)).toEqual([
':(exclude,glob)**/node_modules',
':(exclude,glob)**/node_modules/**'
])
expect(toGitGlobPathspecs('src')).toEqual([':(glob)**/src', ':(glob)**/src/**'])
expect(toGitGlobPathspecs('build/out')).toEqual([':(glob)build/out', ':(glob)build/out/**'])
})
it('keeps trailing-slash patterns directory-only', () => {
expect(toGitGlobPathspecs('node_modules/', true)).toEqual([':(exclude,glob)**/node_modules/**'])
expect(toGitGlobPathspecs('src/')).toEqual([':(glob)**/src/**'])
})
it('drops a pattern that is only separators', () => {
expect(toGitGlobPathspecs('/')).toEqual([])
})
})
@@ -321,6 +351,23 @@ describe('ingestGitGrepLine', () => {
}
})
it('does not broaden a separator-only include pattern to the repository', () => {
const rootPath = mkdtempSync(join(tmpdir(), 'orca-search-git-'))
try {
execFileSync('git', ['init'], { cwd: rootPath, stdio: 'ignore' })
writeFileSync(join(rootPath, 'target.ts'), 'needle\n')
expect(() =>
execFileSync('git', buildGitGrepArgs('needle', { includePattern: '/' }), {
cwd: rootPath,
stdio: 'ignore'
})
).toThrow()
} finally {
rmSync(rootPath, { recursive: true, force: true })
}
})
it('parses git grep null-delimited line, finds all submatch positions', () => {
const acc = createAccumulator()
const re = buildSubmatchRegex('foo', {})
@@ -358,6 +405,23 @@ describe('ingestGitGrepLine', () => {
expect(f.matches[0]).toMatchObject({ line: 10, column: 1, matchLength: 12 })
})
it('does not broaden a separator-only include pattern to the repository', () => {
const rootPath = mkdtempSync(join(tmpdir(), 'orca-search-git-'))
try {
execFileSync('git', ['init'], { cwd: rootPath, stdio: 'ignore' })
writeFileSync(join(rootPath, 'target.ts'), 'needle\n')
expect(() =>
execFileSync('git', buildGitGrepArgs('needle', { includePattern: '/' }), {
cwd: rootPath,
stdio: 'ignore'
})
).toThrow()
} finally {
rmSync(rootPath, { recursive: true, force: true })
}
})
it('handles colons in filenames via null delimiter', () => {
const acc = createAccumulator()
const re = buildSubmatchRegex('x', {})
+12 -6
View File
@@ -12,7 +12,7 @@ import { normalizeSearchResult } from './search-match-count'
import { escapeRegex } from './string-utils'
import type { SearchFileResult, SearchOptions, SearchResult } from './code-search-types'
import { pushSearchMatch } from './text-search-match-accumulator'
import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns'
import { splitSearchGlobPatterns, toGitGlobPathspecs } from './text-search-glob-patterns'
import { joinSearchRoot, normalizeRelativePath, relativeToSearchRoot } from './text-search-paths'
export type SearchAccumulator = {
@@ -195,20 +195,26 @@ export function buildGitGrepArgs(query: string, opts: SearchOptionsLike): string
gitArgs.push('-e', query, '--')
let hasPathspecs = false
let hasIncludePathspecs = false
if (opts.includePattern) {
for (const pat of splitSearchGlobPatterns(opts.includePattern)) {
gitArgs.push(toGitGlobPathspec(pat))
hasPathspecs = true
const pathspecs = toGitGlobPathspecs(pat)
gitArgs.push(...pathspecs)
hasPathspecs ||= pathspecs.length > 0
hasIncludePathspecs ||= pathspecs.length > 0
}
}
if (opts.excludePattern) {
for (const pat of splitSearchGlobPatterns(opts.excludePattern)) {
gitArgs.push(toGitGlobPathspec(pat, true))
hasPathspecs = true
const pathspecs = toGitGlobPathspecs(pat, true)
gitArgs.push(...pathspecs)
hasPathspecs ||= pathspecs.length > 0
}
}
// Why: git grep needs a pathspec to search the working tree; '.' means everything under cwd.
if (!hasPathspecs) {
if (opts.includePattern && !hasIncludePathspecs) {
gitArgs.push(':(top,literal).git')
} else if (!hasPathspecs) {
gitArgs.push('.')
}
return gitArgs