diff --git a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx new file mode 100644 index 00000000000..0f9dab5720b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + NATIVE_CHAT_ASK_ROW_COPY, + type NativeChatAskRowSubject +} from '../../../../shared/native-chat-ask-row' +import { NativeChatToolRunIcon } from './NativeChatToolIcon' + +/** + * The row a question tool call draws in place of its raw input. The agent is + * blocked on the reader, so the row says that in plain words and names what was + * asked, rather than printing the tool's name and a clipped JSON payload. + * + * Only the label breathes: the question is the part worth reading, and animating + * it would make the one line the reader has to act on the hardest one to read. + */ +export function NativeChatAwaitingInputRow({ + subject, + pending +}: { + /** Null when the payload named no question; the label carries the row alone. */ + subject: NativeChatAskRowSubject | null + /** Still waiting on an answer; a settled prompt reports what was asked. */ + pending: boolean +}): React.JSX.Element { + const label = pending + ? translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting) + : translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked) + const text = + subject === null + ? null + : subject.kind === 'question' + ? subject.text + : translate( + 'components.native-chat.ask.questionCount', + NATIVE_CHAT_ASK_ROW_COPY.questionCount, + { value0: subject.count } + ) + + return ( +
+ + + {label} + + {text} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx index 0b3a6d51c7d..df13b55dad1 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx @@ -4,6 +4,7 @@ import { Folder, Globe, ListChecks, + MessageSquareMore, Pencil, Plug, Search, @@ -29,7 +30,8 @@ const NATIVE_CHAT_TOOL_GLYPHS: Record = { plug: Plug, bot: Bot, 'list-checks': ListChecks, - wrench: Wrench + wrench: Wrench, + 'message-square-more': MessageSquareMore } /** The fixed 16px slot with a 14px glyph, which keeps every row left-aligned diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx new file mode 100644 index 00000000000..3ec9e61c187 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { NativeChatToolRun } from './NativeChatToolRun' + +afterEach(cleanup) + +const QUESTION = 'What would you like me to do next in this repo?' +const ASK_INPUT = { questions: [{ question: QUESTION }] } + +function askBlocks(state: 'running' | 'completed'): NativeChatBlock[] { + return [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state }] +} + +describe('NativeChatToolRun awaiting-input row', () => { + it('replaces a running ask call with the awaiting row', () => { + const { container } = render( + + ) + + expect(screen.getByText('Awaiting user input:')).toHaveClass( + 'animate-pulse', + 'motion-reduce:animate-none' + ) + expect(screen.getByText(QUESTION)).toBeInTheDocument() + expect(container.querySelector('.lucide-message-square-more')).toBeInTheDocument() + // The raw call and its payload are exactly what this row exists to replace. + expect(screen.queryByText(/Running AskUserQuestion/)).toBeNull() + expect(screen.queryByText(/AskUserQuestion/)).toBeNull() + }) + + it('reports a settled ask without the pulse or a tool-count header', () => { + const { container } = render( + + ) + + expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse') + expect(screen.getByText(QUESTION)).toBeInTheDocument() + // A run that is only the ask has no work left to head, so it draws no `1×`. + expect(container.querySelector('button')).toBeNull() + }) + + it('counts only the work that ran in the header beside the ask', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' }, + { type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'running' } + ] + + render() + + expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + // One call ran; being asked a question is not work to count. + expect(screen.getByText('1×')).toBeInTheDocument() + }) + + it('draws the row from the tool name when the payload names no question', () => { + render( + + ) + + expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + expect(screen.queryByText(/request_user_input/)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index d5cf4ceb1eb..febddb3dbbf 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -552,7 +552,7 @@ describe('NativeChatToolRun', () => { const blocks: NativeChatBlock[] = [ { type: 'tool-call', - name: 'AskUserQuestion', + name: 'CreateWidget', input: { prompt: 'which?' }, state: 'completed' } @@ -569,7 +569,7 @@ describe('NativeChatToolRun', () => { const blocks: NativeChatBlock[] = [ { type: 'tool-call', - name: 'AskUserQuestion', + name: 'CreateWidget', input: { prompt: 'which?' }, state: 'completed' } diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index a1327e276eb..25fe1b394b8 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -25,6 +25,12 @@ import { selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' +import { + hasNativeChatAskCall, + isNativeChatAskCall, + nativeChatAskRunSubject +} from '../../../../shared/native-chat-ask-row' +import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow' import { NativeChatTaskList } from './NativeChatTaskList' import { buildNativeChatTaskListRows } from './native-chat-task-list-history' import { NativeChatSubagentRun } from './NativeChatSubagentRun' @@ -88,11 +94,20 @@ export function NativeChatToolRun({ const subagentRows = subagentGroups .filter(isRenderableSubagentGroup) .map((group) => ) - const callCount = countToolCalls(blocks) || blocks.length + // A question tool call is not work to summarize — the agent is blocked on the + // reader — so its calls leave the header for one awaiting row and the header + // is left describing only what actually ran. Everything below reads + // `headerBlocks`, so a run that is nothing but the ask draws no header at all + // rather than a `1×` counting a call the reader is being asked to answer. + const hasAskCall = structuredActivityUi && hasNativeChatAskCall(blocks) + const askSubject = hasAskCall ? nativeChatAskRunSubject(blocks) : null + const headerBlocks = hasAskCall ? blocks.filter((block) => !isNativeChatAskCall(block)) : blocks + const showsHeader = !hasAskCall || countToolCalls(headerBlocks) > 0 + const callCount = countToolCalls(headerBlocks) || headerBlocks.length // Members stay separate all the way to the markup: joining them into one // string is what made a run read as a single call, because the separator also // occurs inside tool names like `browser.open` and `tools/read`. - const summaryMembers = toolRunSummaryMembers(blocks) + const summaryMembers = toolRunSummaryMembers(headerBlocks) const hiddenCallCount = Math.max(0, callCount - summaryMembers.length) // Same content-signature keying the member rows below use: two identical calls // in one run are distinguished by occurrence, never by list position. @@ -109,7 +124,14 @@ export function NativeChatToolRun({ ? selectActiveToolCall(blocks, { activeTurnIsWorking }) : null const isSettled = latestActiveCall == null - const hasRunningCall = blocks.some((block) => isToolCallBlock(block) && block.state === 'running') + // The ask owns the active slot when it is the live call: its own row already + // says the turn is waiting, and a second "Running request_user_input" beside + // it would report the block twice in two different vocabularies. + const askIsActive = latestActiveCall !== null && isNativeChatAskCall(latestActiveCall) + const headerActiveCall = askIsActive ? null : latestActiveCall + const hasRunningCall = headerBlocks.some( + (block) => isToolCallBlock(block) && block.state === 'running' + ) // The turn caret opens the activity group while each child tool stays collapsed. const expandToolLines = expandOverride === undefined ? open : false // Diffing every edit is the run's most expensive work, so a collapsed run — @@ -135,7 +157,7 @@ export function NativeChatToolRun({ // spans categories therefore heads with the generic tool glyph. The glyph is // fixed once settled, so state rides on the trailing mark — a leading glyph // that flipped to a check would read as a change of identity. - const settledHeaderIcon = nativeChatToolRunIconName(blocks.filter(isToolCallBlock)) + const settledHeaderIcon = nativeChatToolRunIconName(headerBlocks.filter(isToolCallBlock)) const fallbackLabel = callCount === 1 ? translate('components.native-chat.tool.countOne', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne) @@ -177,7 +199,10 @@ export function NativeChatToolRun({ // so the turn's activity doesn't crowd the message text.
{subagentRows} - {latestActiveCall ? ( + {hasAskCall ? ( + + ) : null} + {!showsHeader ? null : headerActiveCall ? ( @@ -267,14 +292,14 @@ export function NativeChatToolRun({ /> )} - {open ? ( + {open && showsHeader ? ( // Members are indented under the header because nothing else marks the // run's extent — flush rows are indistinguishable from the blocks after // them, so the batch has no visible end.
{(() => { const seen = new Map() - return blocks.map((block, blockIndex) => { + return headerBlocks.map((block, blockIndex) => { const taskList = taskLists?.rows.get(block) if (taskList) { return diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 44f74db10cb..b9ac1d5cf63 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17255,6 +17255,11 @@ "skip": "Skip", "sending": "Sending…" }, + "ask": { + "awaiting": "Awaiting user input:", + "asked": "Asked:", + "questionCount": "{{value0}} questions" + }, "approval": { "title": "Allow {{value0}}?", "allow": "Allow", diff --git a/src/shared/native-chat-ask-row.test.ts b/src/shared/native-chat-ask-row.test.ts new file mode 100644 index 00000000000..e3446024354 --- /dev/null +++ b/src/shared/native-chat-ask-row.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { hasNativeChatAskCall, nativeChatAskRunSubject } from './native-chat-ask-row' +import type { NativeChatBlock } from './native-chat-types' + +function askCall(input: unknown, name = 'AskUserQuestion'): NativeChatBlock { + return { type: 'tool-call', name, input } +} + +describe('native chat ask row', () => { + it('names the one question a prompt asks', () => { + expect( + nativeChatAskRunSubject([askCall({ questions: [{ question: 'Which branch?' }] })]) + ).toEqual({ kind: 'question', text: 'Which branch?' }) + }) + + it('counts a grouped prompt rather than quoting only its first question', () => { + expect( + nativeChatAskRunSubject([ + askCall({ questions: [{ question: 'Which branch?' }, { question: 'Proceed?' }] }) + ]) + ).toEqual({ kind: 'count', count: 2 }) + }) + + it('aggregates the per-question calls Codex journals for a single prompt', () => { + // Codex writes one call per question, so a per-call row would stack two + // pulsing lines for a prompt the reader was shown once. + expect( + nativeChatAskRunSubject([ + askCall({ questions: [{ question: 'Which branch?' }] }, 'request_user_input'), + askCall({ questions: [{ question: 'Proceed?' }] }, 'request_user_input') + ]) + ).toEqual({ kind: 'count', count: 2 }) + }) + + it('decodes the JSON-string arguments Codex delivers', () => { + expect( + nativeChatAskRunSubject([ + askCall( + JSON.stringify({ questions: [{ question: 'Which branch?' }] }), + 'request_user_input' + ) + ]) + ).toEqual({ kind: 'question', text: 'Which branch?' }) + }) + + it('still reports an ask whose payload names no question', () => { + // Decided by the tool name alone: an unreadable payload must not put the raw + // call back on screen as the row it was meant to replace. + const blocks = [askCall({ prompt: 'which?' })] + + expect(hasNativeChatAskCall(blocks)).toBe(true) + expect(nativeChatAskRunSubject(blocks)).toBeNull() + }) + + it('leaves an ordinary tool call alone even when its input carries questions', () => { + expect(hasNativeChatAskCall([askCall({ questions: [{ question: 'x' }] }, 'Read')])).toBe(false) + }) +}) diff --git a/src/shared/native-chat-ask-row.ts b/src/shared/native-chat-ask-row.ts new file mode 100644 index 00000000000..530347a4974 --- /dev/null +++ b/src/shared/native-chat-ask-row.ts @@ -0,0 +1,67 @@ +// The native-chat row that stands in for a question tool call. Both platform +// UIs read this copy (desktop as its i18n fallbacks, mobile directly) so the two +// can never describe the same pending question differently. + +import { isAskUserQuestionTool } from './agent-question-answered-intent' +import { parseAskFromToolInput } from './native-chat-ask' +import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' + +export const NATIVE_CHAT_ASK_ROW_COPY = { + awaiting: 'Awaiting user input:', + asked: 'Asked:', + questionCount: '{{value0}} questions' +} as const + +/** What the row names after its label: the question itself, or how many were + * asked. One row stands for the whole prompt, so a grouped prompt may not quote + * just its first question as though it were the only one. */ +export type NativeChatAskRowSubject = + | { kind: 'question'; text: string } + | { kind: 'count'; count: number } + +/** Whether this block is a question tool call, and so is drawn as the awaiting + * row rather than as an ordinary tool line. */ +export function isNativeChatAskCall(block: NativeChatBlock): boolean { + return isToolCallBlock(block) && isAskUserQuestionTool(block.name) +} + +/** Whether this run asks the reader anything. Decided by the tool name alone, + * because that already says the agent is blocked on an answer — a payload this + * cannot parse must not put the raw call back on screen as the row it replaced. */ +export function hasNativeChatAskCall(blocks: readonly NativeChatBlock[]): boolean { + return blocks.some(isNativeChatAskCall) +} + +/** The questions one call names, dropping any it states blankly. */ +function askCallQuestions(block: NativeChatBlock): string[] { + if (!isToolCallBlock(block)) { + return [] + } + const prompt = parseAskFromToolInput(block.name, block.input) + return prompt + ? prompt.questions.map((question) => question.question.trim()).filter((text) => text.length > 0) + : [] +} + +/** + * The subject for the whole run's question activity, or null when nothing in it + * names a question — the row then stands on its label alone, which still tells + * the reader the turn is theirs to unblock. + * + * Aggregated across calls, not taken from one: Codex journals a separate call + * per question of the same prompt, so a per-call row would stack three pulsing + * lines for what the reader was asked once. + */ +export function nativeChatAskRunSubject( + blocks: readonly NativeChatBlock[] +): NativeChatAskRowSubject | null { + const questions = blocks.filter(isNativeChatAskCall).flatMap(askCallQuestions) + if (questions.length === 0) { + return null + } + if (questions.length > 1) { + return { kind: 'count', count: questions.length } + } + const text = questions[0] + return text ? { kind: 'question', text } : null +} diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index 7096db88353..6edd394ab41 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -95,6 +95,18 @@ export function parseAskFromStatus( } } +/** Parse a question tool call's own input, through the same registered-parser + * dispatch live status uses. Codex delivers arguments as a JSON string, so a + * string input is decoded rather than treated as prose. */ +export function parseAskFromToolInput( + toolName: string | undefined, + input: unknown +): AskPrompt | null { + return typeof input === 'string' + ? parseAskFromStatus(input, toolName) + : parseToolInput(toolName, input) +} + /** Resolve the newest question tool that has not received its FIFO tool result. * Transcript replay parses each tool-call through the same registered-parser + * canonical-shape fallback as live status, so a question tool that rendered diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index d52df9e52e9..1bdc5736c62 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -40,6 +40,10 @@ export type NativeChatToolIconName = | 'bot' | 'list-checks' | 'wrench' + /** The awaiting-input row's glyph. Carried here for the shared aligned slot; + * it names no tool category, because that row stands for a question rather + * than for the call that asked it. */ + | 'message-square-more' /** Category to glyph. */ export const NATIVE_CHAT_TOOL_ICON_NAMES: Record = { diff --git a/src/shared/structured-agent-session-projection.ask-row.test.ts b/src/shared/structured-agent-session-projection.ask-row.test.ts new file mode 100644 index 00000000000..86f1bce774d --- /dev/null +++ b/src/shared/structured-agent-session-projection.ask-row.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { projectStructuredItemToNativeChat } from './structured-agent-session-projection' + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +function item(itemId: string, body: AgentJournalRenderItem['body']): AgentJournalRenderItem { + return { itemId, sequence: 1, revision: 1, observedAt: 1, body } +} + +describe('structured agent session ask-row projection', () => { + it('gives a pending question a row instead of dropping it from the transcript', () => { + // Codex only ever journals the question, so without this the reader sees + // nothing in the log while the agent is blocked on them. + const projected = projectStructuredItemToNativeChat( + item('q', { + kind: 'question', + question: 'Which branch?', + options: [{ id: 'q1:main', label: 'main' }], + resolution: { ...PENDING } + }) + ) + + expect(projected?.role).toBe('assistant') + expect(projected?.blocks).toEqual([ + { + type: 'tool-call', + name: 'request_user_input', + input: { questions: [{ question: 'Which branch?' }] }, + state: 'running' + } + ]) + }) + + it('prefers a grouped prompt own questions over the label naming their count', () => { + const projected = projectStructuredItemToNativeChat( + item('grouped', { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { id: 'q1', question: 'Which targets?', multiSelect: true, options: [] }, + { id: 'q2', question: 'Proceed?', multiSelect: false, options: [] } + ], + resolution: { ...PENDING } + }) + ) + + expect(projected?.blocks).toEqual([ + { + type: 'tool-call', + name: 'request_user_input', + input: { questions: [{ question: 'Which targets?' }, { question: 'Proceed?' }] }, + state: 'running' + } + ]) + }) + + it('drops the question tool call itself so the row is not drawn twice', () => { + // Claude journals both the `AskUserQuestion` call and the question it + // raised; the question item above is the one that draws the row. + expect( + projectStructuredItemToNativeChat( + item('ask', { + kind: 'tool-call', + name: 'AskUserQuestion', + input: { questions: [{ question: 'Which branch?' }] }, + state: 'running' + }) + ) + ).toBeNull() + }) + + it('keeps an ordinary tool call', () => { + expect( + projectStructuredItemToNativeChat( + item('read', { + kind: 'tool-call', + name: 'Read', + input: { file_path: 'a.ts' }, + state: 'running' + }) + )?.blocks + ).toHaveLength(1) + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 68e8caf22eb..d20f85f5873 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -4,6 +4,7 @@ import { normalizePromptField } from './agent-status-field-normalization' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { isAskUserQuestionTool } from './agent-question-answered-intent' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH @@ -43,6 +44,17 @@ export function stripBoundedTextMarker(text: string): { text: string; truncated: return { text: stripped, truncated: stripped.length !== text.length } } +/** What a pending question asks. Claude groups several under one item and names + * that item by their count, so its own list wins over that summary label. */ +function pendingQuestionTexts(body: { + question: string + questions?: readonly { question: string }[] +}): { question: string }[] { + return body.questions && body.questions.length > 0 + ? body.questions.map((question) => ({ question: question.question })) + : [{ question: body.question }] +} + function itemBlocks(item: AgentJournalRenderItem): { role: NativeChatMessage['role'] blocks: NativeChatBlock[] @@ -52,6 +64,12 @@ function itemBlocks(item: AgentJournalRenderItem): { return { role: body.role, blocks: body.blocks } } if (body.kind === 'tool-call') { + // The question item below draws this call's row. Claude journals both the + // `AskUserQuestion` call and the question it raised, so keeping this one too + // would print the awaiting row twice — once per lane that saw the same ask. + if (isAskUserQuestionTool(body.name)) { + return null + } return { role: 'assistant', blocks: [ @@ -105,7 +123,21 @@ function itemBlocks(item: AgentJournalRenderItem): { } if (body.kind === 'question') { if (body.resolution.state === 'pending') { - return null + // A pending question is work the reader has to act on, so it takes a row + // instead of living only in the docked card. Shaped as the question tool + // call it came from, so the one awaiting-input row serves both this and + // the lanes that journal that call directly. + return { + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'request_user_input', + input: { questions: pendingQuestionTexts(body) }, + state: 'running' + } + ] + } } const choices = body.options.map((option) => option.label).join(' · ') return {