diff --git a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx index d201f87b06d..2ad2426c74b 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx @@ -12,6 +12,7 @@ import type { AgentJournalFreeTextInput } from '../../../../../src/shared/agent- import { askAcceptsEmptyAnswer, askConfirmAction, + askQuestionShowsPreviews, askConfirmLabel, askTypedText, toggleAskOption @@ -65,7 +66,8 @@ export function MobileNativeChatAsk({ }: Props): React.JSX.Element { useMobileColorScheme() const t = useMobileTranslation() - const { scrollRef, inputRef, revealReply, onViewportLayout } = useMobileNativeChatReplyScroll() + const { scrollRef, inputRef, revealReply, scrollViewProps, revealContent } = + useMobileNativeChatReplyScroll() const [index, setIndex] = useState(0) const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => @@ -109,11 +111,15 @@ export function MobileNativeChatAsk({ return } const cur = selections[qi] ?? [] + // A pick reveals its preview, so a re-tap keeps it and the user confirms. + const keep = optIndex !== OTHER && askQuestionShowsPreviews(prompt.questions[qi]) const picked = !cur.includes(optIndex) - const next = selections.map((s, i) => (i === qi ? toggleAskOption(cur, optIndex, multi) : s)) + const next = selections.map((s, i) => + i === qi ? toggleAskOption(cur, optIndex, multi, keep) : s + ) setSelections(next) autoAdvance.cancel() - if (picked && !multi && optIndex !== OTHER) { + if (picked && !multi && optIndex !== OTHER && !keep) { autoAdvance.schedule(() => void advance(next)) } } @@ -246,12 +252,7 @@ export function MobileNativeChatAsk({ ) : null} - + void selected: boolean multi?: boolean disabled: boolean onPress: () => void }): React.JSX.Element { useMobileColorScheme() - return ( + const row = ( ) + // One root shape whether or not the preview shows, so selecting never remounts the row. + return ( + <> + {row} + {preview && selected ? ( + { + const { y, height } = event.nativeEvent.layout + onPreviewLayout?.(y, y + height) + }} + > + + {/* Mockups are drawn in columns, so they scroll sideways instead of wrapping at phone width. */} + + + {preview} + + + + + ) : null} + + ) } const styles = themedStyleSheet(() => ({ @@ -106,5 +136,19 @@ const styles = themedStyleSheet(() => ({ optionDescription: { color: colors.textSecondary, fontSize: typography.metaSize + }, + preview: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: concentricRadius(radii.surface, spacing.xs), + padding: spacing.sm, + marginBottom: spacing.xs, + maxHeight: 220 + }, + previewText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize } })) diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts index fe9fd376050..9226a6f7f2f 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts @@ -1,11 +1,18 @@ import { useRef } from 'react' -import type { LayoutChangeEvent, ScrollView, TextInput } from 'react-native' +import type { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + TextInput +} from 'react-native' // Keep a focused reply in view when its card shrinks above the keyboard. export function useMobileNativeChatReplyScroll() { const scrollRef = useRef(null) const inputRef = useRef(null) const viewportHeight = useRef(0) + const scrollOffset = useRef(0) const revealReply = (): void => { if (inputRef.current?.isFocused()) { scrollRef.current?.scrollToEnd({ animated: false }) @@ -20,5 +27,31 @@ export function useMobileNativeChatReplyScroll() { revealReply() } } - return { scrollRef, inputRef, revealReply, onViewportLayout } + const onScroll = (event: NativeSyntheticEvent): void => { + scrollOffset.current = event.nativeEvent.contentOffset.y + } + // Scroll just enough to show content that opened below the visible edge, never past its top. + const revealContent = (top: number, bottom: number): void => { + const hidden = bottom - (scrollOffset.current + viewportHeight.current) + if (viewportHeight.current > 0 && hidden > 0) { + scrollRef.current?.scrollTo({ + y: Math.min(scrollOffset.current + hidden, top), + animated: true + }) + } + } + const scrollViewProps = { + ref: scrollRef, + onLayout: onViewportLayout, + onScroll, + scrollEventThrottle: 16 + } + return { + scrollRef, + inputRef, + revealReply, + onViewportLayout, + scrollViewProps, + revealContent + } } diff --git a/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts b/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts new file mode 100644 index 00000000000..0be0dd589c7 --- /dev/null +++ b/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { claudeQuestionItems } from './claude-structured-prompt-items' + +const LAYOUT_A = '┌──────┬────────┐\n│ nav │ main │\n└──────┴────────┘' + +describe('claudeQuestionItems', () => { + it('keeps option previews on single-select questions only', () => { + const [item] = claudeQuestionItems({ + sessionId: 'session-1', + prompt: { + requestId: 'req-1', + promptKey: 'req-1', + toolUseId: 'toolu-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: 'Which layout?', + header: 'Layout', + multiSelect: false, + options: [ + { label: 'Sidebar', description: 'Nav on the left', preview: LAYOUT_A }, + { label: 'Top bar', description: 'Nav on top', preview: ' \n ' } + ] + }, + { + question: 'Which panes?', + header: 'Panes', + multiSelect: true, + options: [ + { label: 'Files', description: 'File tree', preview: 'files' }, + { label: 'Git', description: 'Changes', preview: 'git' } + ] + } + ] + }, + suggestions: [], + questionIds: ['q1', 'q2'], + settle: vi.fn() + } + }) + const questions = item!.body.questions! + expect(questions[0]!.options.map((option) => option.preview)).toEqual([LAYOUT_A, undefined]) + expect(questions[1]!.options.map((option) => option.preview)).toEqual([undefined, undefined]) + }) +}) diff --git a/src/main/claude/structured/journal/claude-structured-prompt-items.ts b/src/main/claude/structured/journal/claude-structured-prompt-items.ts index ef669549d3b..adefb290d77 100644 --- a/src/main/claude/structured/journal/claude-structured-prompt-items.ts +++ b/src/main/claude/structured/journal/claude-structured-prompt-items.ts @@ -10,6 +10,7 @@ import { truncateToolDetail } from '../../../../shared/native-chat/tools/native-chat-tool-summary' import { isPlanApprovalSubject } from '../../../../shared/agent-session/agent-session-approval-subject' +import { askOptionPreview } from '../../../../shared/native-chat/input/native-chat-ask-option-preview' import { boundJournalPromptBody } from '../../../native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' @@ -77,16 +78,20 @@ function questionOptions( if (!Array.isArray(question.options)) { return [] } + // Claude draws previews only on single-select questions. + const singleSelect = question.multiSelect !== true return question.options.flatMap((value, index) => { const option = claudeRecord(value) const label = claudeText(option?.label) const description = claudeText(option?.description) + const preview = singleSelect ? askOptionPreview(option) : null return label ? [ { id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`), label, - ...(description ? { description } : {}) + ...(description ? { description } : {}), + ...(preview ? { preview } : {}) } ] : [] diff --git a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts index 34945214586..d8114bb097d 100644 --- a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-body-bounds.ts @@ -13,6 +13,7 @@ export const MAX_JOURNAL_PROMPT_OPTIONS = 64 export const MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS = 4 const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 } +const JOURNAL_PROMPT_OPTION_PREVIEW_LIMITS = { inlineHeadBytes: 8 * 1024 } // Agent prompt content permits larger bounds than client replies but must still fit settlement batches. const JOURNAL_PROMPT_TEXT_LIMITS = { inlineHeadBytes: 128 * 1024 } const JOURNAL_PROMPT_ID_MAX_BYTES = 1024 @@ -133,7 +134,10 @@ function boundPromptOptions( label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text, ...(option.description === undefined ? {} - : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }) + : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }), + ...(option.preview === undefined + ? {} + : { preview: boundInlineText(option.preview, JOURNAL_PROMPT_OPTION_PREVIEW_LIMITS).text }) })) } diff --git a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts new file mode 100644 index 00000000000..e4f2a12f4f8 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalQuestionItem +} from '../../../../shared/agent-session/journal/agent-session-journal-types' +import { codexProviderHandle } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' +import { openAgentSessionJournal } from '../journal-store-factory' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../store-lifecycle/journal-host-database-test-support' +import { boundJournalPromptBody, cancelledJournalPromptBody } from './journal-prompt-body-bounds' + +let root: string | undefined + +afterEach(async () => { + await closeTestJournalHostDatabases() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +// Control characters escape to six bytes each, so this question only fits a journal row on its own. +const CONTROL = '\u0001' +// Each preview fits the 8 KiB bound, so it must come back exactly as written. +const PREVIEWS = [1, 2, 3, 4].map((q) => + [1, 2, 3, 4].map((o) => `layout ${q}.${o}${CONTROL.repeat(8_192 - 12)}`) +) + +function largestQuestion(): AgentJournalQuestionItem { + const questions = [1, 2, 3, 4].map((q) => ({ + id: `q${q}`, + question: `Question ${q}${CONTROL.repeat(30_000)}`, + multiSelect: false, + options: [1, 2, 3, 4].map((o) => ({ + id: `q${q}:choice-${o}`, + label: `Layout ${o}`, + preview: PREVIEWS[q - 1]![o - 1]! + })) + })) + return boundJournalPromptBody({ + kind: 'question', + question: '4 grouped questions from Claude', + options: [], + questions, + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) +} + +it('saves, cancels and reopens a question whose previews make it larger than one batch', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-prompt-previews-')) + const open = () => + openAgentSessionJournal({ + identity: { + sessionId: 'session-previews', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + database: openTestJournalHostDatabase(root!), + now: () => 1_000 + }) + const identity = { provider: 'orca' as const, clientMessageId: 'claude-prompt:session:req-1' } + const body = largestQuestion() + const journal = await open() + await journal.appendItem(identity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await journal.appendLifecycleBatch({ + settlementId: 'cancel-prompt', + fence: 1, + mutations: [ + { + kind: 'item', + identity, + body: cancelledJournalPromptBody(body)!, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + await journal.close() + + const reopened = await open() + const saved = reopened + .snapshot() + .items.map((item) => item.body) + .find((item): item is AgentJournalQuestionItem => item.kind === 'question') + await reopened.close() + expect(saved).toMatchObject({ kind: 'question', resolution: { state: 'cancelled' } }) + const previews = saved!.questions!.flatMap((question) => + question.options.map((option) => option.preview) + ) + expect(previews).toEqual(PREVIEWS.flat()) +}) diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx index df35dd0e807..5e68681bfc9 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.fixture.tsx @@ -231,3 +231,44 @@ export const historyLinks = defineFixture({ ), interact: (frame) => frame.querySelector('button[aria-expanded]')?.click() }) + +export const previews = defineFixture({ + width: 560, + render: () => ( +
+ {}} + onCancel={() => {}} + /> +
+ ), + interact: async (frame) => { + frame.querySelectorAll('button[aria-pressed]')[0]?.click() + await new Promise(requestAnimationFrame) + } +}) diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx index cf50c7ec441..1e89b19d5aa 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.test.tsx @@ -86,4 +86,44 @@ describe('NativeChatQuestionCard', () => { expect(pressAt(10, 10, 'ArrowDown')).toBe(true) expect(document.activeElement).toBe(rows[0]) }) + + it('shows a picked preview, keeps it on a second click, and confirms the digit-named option with Enter', () => { + vi.useFakeTimers() + container.setAttribute('data-native-chat-root', 'true') + const onAnswer = vi.fn() + act(() => { + root.render( + {}} + shouldFocus + /> + ) + }) + const rows = container.querySelectorAll('button[aria-pressed]') + act(() => rows[0]!.focus()) + pressKey({ key: '2' }, rows[0]!) + act(() => rows[1]!.click()) + act(() => vi.runAllTimers()) + expect(container.querySelector('pre')?.textContent).toBe('| nav |\n| main |') + expect(document.activeElement).toBe(rows[1]) + expect(onAnswer).not.toHaveBeenCalled() + pressKey({ key: 'ArrowUp' }, rows[1]!) + pressKey({ key: '2' }, rows[0]!) + expect(document.activeElement).toBe(rows[1]) + pressKey({ key: 'Enter' }, rows[1]!) + expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }]) + }) }) diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx index 91986107d6e..0f2e27541f1 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionCard.tsx @@ -18,6 +18,7 @@ import type { AgentJournalFreeTextInput } from '../../../../../shared/agent-sess import { askAcceptsEmptyAnswer, askConfirmAction, + askQuestionShowsPreviews, askConfirmLabel, askTypedText, toggleAskOption @@ -82,6 +83,7 @@ export function NativeChatQuestionCard({ const questionAllowsOther = Array.isArray(allowOther) ? (allowOther[index] ?? false) : allowOther const freeTextInput = freeTextInputs?.[index] const fieldTakesFocus = q.options.length === 0 || undefined + const showsPreviews = askQuestionShowsPreviews(q) const answerPlaceholder = freeTextInput?.placeholder ?? t('components.native-chat.question.otherPlaceholder', 'Type your answer') @@ -156,15 +158,31 @@ export function NativeChatQuestionCard({ } } - // Briefly show a single-select pick before advancing; picking again retracts it. - const pickOption = (optionIndex: number): void => { + // Briefly show a single-select pick before advancing; picking again retracts it. With previews, + // a pick stays put: a digit focuses its row, and Enter on the chosen row confirms. + const pickOption = ( + optionIndex: number, + via: 'pointer' | 'digit' | 'enter' = 'pointer' + ): void => { const cur = selections[index] ?? [] + // Focus first, so Enter after any digit acts on the row that digit named. + if (showsPreviews && via === 'digit') { + cardRef.current + ?.querySelectorAll('button[aria-pressed]') + [optionIndex]?.focus() + } + if (showsPreviews && cur.includes(optionIndex)) { + if (via === 'enter') { + confirm(true) + } + return + } const picked = !cur.includes(optionIndex) const chosen = toggleAskOption(cur, optionIndex, q.multiSelect) const next = selections.map((s, i) => (i === index ? chosen : s)) setSelections(next) autoAdvance.cancel() - if (picked && !q.multiSelect) { + if (picked && !q.multiSelect && !showsPreviews) { autoAdvance.schedule(() => advanceOrSubmit(next, otherText)) } } @@ -298,6 +316,7 @@ export function NativeChatQuestionCard({ initialFocus={i === 0} label={opt.label} description={opt.description} + preview={opt.preview} selected={(selections[index] ?? []).includes(i)} disabled={isSubmitting} onSelect={() => pickOption(i)} diff --git a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx index 9887464590f..ef575dc5ba4 100644 --- a/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx +++ b/src/renderer/src/components/native-chat/prompts/NativeChatQuestionOption.tsx @@ -1,11 +1,17 @@ import { Check } from 'lucide-react' import { cn } from '@/lib/utils' +// Stable, so it runs when the preview opens rather than on every render; the option list is height-capped. +function revealPreview(element: HTMLPreElement | null): void { + element?.scrollIntoView?.({ block: 'nearest' }) +} + export function NativeChatQuestionOption({ badge, initialFocus, label, description, + preview, selected, disabled, onSelect @@ -14,47 +20,60 @@ export function NativeChatQuestionOption({ initialFocus: boolean label: string description?: string + /** Shown under the row while it is selected, as the agent wrote it. */ + preview?: string selected: boolean disabled: boolean onSelect: () => void }): React.JSX.Element { return ( - + + {selected ? : badge} + + + {label} + {description ? ( + + {description} + + ) : null} + + + {preview && selected ? ( +
+          {preview}
+        
+ ) : null} + ) } diff --git a/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts b/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts index e0696c342d2..39f41cdb290 100644 --- a/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts +++ b/src/renderer/src/components/native-chat/prompts/use-native-chat-question-keys.ts @@ -7,7 +7,7 @@ export function useNativeChatQuestionKeys( cardRef: RefObject, enabled: boolean, optionCount: number, - onPick: (optionIndex: number) => void, + onPick: (optionIndex: number, via: 'digit' | 'enter') => void, onStep: (delta: 1 | -1) => boolean ): void { const onKeyDown = useEffectEvent((event: KeyboardEvent): void => { @@ -48,7 +48,7 @@ export function useNativeChatQuestionKeys( return } event.preventDefault() - onPick(optionIndex) + onPick(optionIndex, 'digit') } else if (isArrow) { const up = event.key === 'ArrowUp' if ( @@ -82,7 +82,7 @@ export function useNativeChatQuestionKeys( } } else if (event.key === 'Enter' && focusedIndex !== -1) { event.preventDefault() - onPick(focusedIndex) + onPick(focusedIndex, 'enter') } }) useEffect(() => { diff --git a/src/shared/agent-session/journal/agent-session-journal-schemas.ts b/src/shared/agent-session/journal/agent-session-journal-schemas.ts index 0538c3616b3..d8d5be4abfd 100644 --- a/src/shared/agent-session/journal/agent-session-journal-schemas.ts +++ b/src/shared/agent-session/journal/agent-session-journal-schemas.ts @@ -116,7 +116,8 @@ const Block = openDiscriminatedUnion( const PromptOption = z.object({ id: z.string(), label: z.string(), - description: z.string().optional() + description: z.string().optional(), + preview: z.string().optional() }) const FreeTextInput = z.object({ diff --git a/src/shared/agent-session/journal/agent-session-journal-types.ts b/src/shared/agent-session/journal/agent-session-journal-types.ts index 6ee590b3aec..5b9658505a3 100644 --- a/src/shared/agent-session/journal/agent-session-journal-types.ts +++ b/src/shared/agent-session/journal/agent-session-journal-types.ts @@ -137,6 +137,8 @@ export type AgentJournalPromptOption = { id: string label: string description?: string + /** A block the user compares before choosing (a mockup or snippet), shown as plain monospace text. */ + preview?: string } export type AgentJournalFreeTextInput = { diff --git a/src/shared/agent-status/contracts/agent-status-field-normalization.test.ts b/src/shared/agent-status/contracts/agent-status-field-normalization.test.ts new file mode 100644 index 00000000000..1f08034e25c --- /dev/null +++ b/src/shared/agent-status/contracts/agent-status-field-normalization.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { normalizeInteractivePromptField } from './agent-status-field-normalization' +import { parseAskFromStatus } from '../../native-chat/input/native-chat-ask' + +function layoutQuestion(preview: (index: number) => string): string { + return JSON.stringify({ + questions: [ + { + question: 'Which layout?', + header: 'Layout', + multiSelect: false, + options: ['Sidebar', 'Top bar', 'Split', 'Tabs'].map((label, index) => ({ + label, + description: `Layout ${index + 1}`, + preview: preview(index) + })) + } + ] + }) +} + +describe('normalizeInteractivePromptField', () => { + it('shortens oversize previews and keeps every option and the preview layout', () => { + const fitted = normalizeInteractivePromptField( + layoutQuestion((index) => `+--${index}--+\n`.repeat(600)), + 4000 + ) + const ask = parseAskFromStatus(fitted, 'AskUserQuestion') + const question = ask!.questions[0]! + expect(question.options.map((option) => option.label)).toEqual([ + 'Sidebar', + 'Top bar', + 'Split', + 'Tabs' + ]) + expect(question.hasPreviews).toBe(true) + expect(question.options.every((option) => option.preview?.endsWith('…'))).toBe(true) + }) + + it('never turns blank previews into previews', () => { + const fitted = normalizeInteractivePromptField( + layoutQuestion(() => ' '.repeat(1200)), + 4000 + ) + const question = parseAskFromStatus(fitted, 'AskUserQuestion')!.questions[0]! + expect(question.options).toHaveLength(4) + expect(question.hasPreviews).toBeUndefined() + }) +}) diff --git a/src/shared/agent-status/contracts/agent-status-field-normalization.ts b/src/shared/agent-status/contracts/agent-status-field-normalization.ts index 984658fcbcc..06a5982f306 100644 --- a/src/shared/agent-status/contracts/agent-status-field-normalization.ts +++ b/src/shared/agent-status/contracts/agent-status-field-normalization.ts @@ -154,7 +154,7 @@ function isEcmaTrimWhitespace(code: number): boolean { ) } -// Only provided strings update optional fields; absence must not erase cached state. Preserve option labels and shorten presentation fields only. +// Only provided strings update optional fields; absence must not erase cached state. Preserve option labels and shorten presentation fields (and previews) only. const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([ 'description', 'detail', @@ -163,6 +163,14 @@ const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([ 'summary' ]) +// A cut preview stays non-blank: whether Claude drew its preview layout decides the answer keystrokes. +function compactPreview(preview: string, maxLength: number): string { + if (preview.length <= maxLength || !/\S/.test(preview)) { + return truncatePreservingSurrogates(preview, maxLength) + } + return `${truncatePreservingSurrogates(preview, Math.max(maxLength - 1, 0))}…` +} + function compactInteractivePromptValue(value: unknown, maxStringLength: number): unknown { if (Array.isArray(value)) { return value.map((item) => compactInteractivePromptValue(item, maxStringLength)) @@ -173,9 +181,13 @@ function compactInteractivePromptValue(value: unknown, maxStringLength: number): return Object.fromEntries( Object.entries(value).map(([key, item]) => [ key, - typeof item === 'string' && COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key) - ? truncatePreservingSurrogates(item, maxStringLength) - : compactInteractivePromptValue(item, maxStringLength) + typeof item !== 'string' + ? compactInteractivePromptValue(item, maxStringLength) + : key === 'preview' + ? compactPreview(item, maxStringLength) + : COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key) + ? truncatePreservingSurrogates(item, maxStringLength) + : item ]) ) } diff --git a/src/shared/native-chat/input/native-chat-ask-card-rules.ts b/src/shared/native-chat/input/native-chat-ask-card-rules.ts index 98dab1c2ff8..397cebca85c 100644 --- a/src/shared/native-chat/input/native-chat-ask-card-rules.ts +++ b/src/shared/native-chat/input/native-chat-ask-card-rules.ts @@ -3,14 +3,23 @@ import type { AskQuestion } from './native-chat-ask-types' // How a stepped question card picks, labels and confirms; desktop and mobile draw it differently. -/** Multi-select keeps picks in option order so the answer reads as the options do. */ +/** A single-select whose pick reveals a preview: picking only selects, and the user confirms. */ +export function askQuestionShowsPreviews( + question: { multiSelect: boolean; options: readonly { preview?: string }[] } | undefined +): boolean { + return question !== undefined && !question.multiSelect && question.options.some((o) => o.preview) +} + +/** Multi-select keeps picks in option order so the answer reads as the options do. `keepChosen`: + * picking the chosen option again keeps it, as a preview question's pick does. */ export function toggleAskOption( picked: readonly number[], optionIndex: number, - multiSelect: boolean + multiSelect: boolean, + keepChosen = false ): number[] { if (picked.includes(optionIndex)) { - return picked.filter((index) => index !== optionIndex) + return keepChosen ? [...picked] : picked.filter((index) => index !== optionIndex) } return multiSelect ? [...picked, optionIndex].sort((a, b) => a - b) : [optionIndex] } diff --git a/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts b/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts index 27be080a71a..6e2e3261c47 100644 --- a/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts +++ b/src/shared/native-chat/input/native-chat-ask-dismiss-key.ts @@ -19,7 +19,8 @@ export function nativeChatAskDismissKey(prompt: AskPrompt | null): string | null ...question, question: askDismissKeyText(question.question), header: askDismissKeyText(question.header), - options: question.options.map((option) => ({ + // Status and transcript copies shorten previews differently; labels already identify the option. + options: question.options.map(({ preview: _preview, ...option }) => ({ ...option, description: askDismissKeyText(option.description) })) diff --git a/src/shared/native-chat/input/native-chat-ask-option-preview.ts b/src/shared/native-chat/input/native-chat-ask-option-preview.ts new file mode 100644 index 00000000000..f33053c6d31 --- /dev/null +++ b/src/shared/native-chat/input/native-chat-ask-option-preview.ts @@ -0,0 +1,11 @@ +/** The option's preview as Claude shows it: a non-blank string, kept verbatim. */ +export function askOptionPreview(option: unknown): string | null { + return option && + typeof option === 'object' && + 'preview' in option && + typeof option.preview === 'string' && + // Why not trim(): history rescans run this per option, and trim copies the whole preview. + /\S/.test(option.preview) + ? option.preview + : null +} diff --git a/src/shared/native-chat/input/native-chat-ask-types.ts b/src/shared/native-chat/input/native-chat-ask-types.ts index 66db1b23082..b978c6a0e43 100644 --- a/src/shared/native-chat/input/native-chat-ask-types.ts +++ b/src/shared/native-chat/input/native-chat-ask-types.ts @@ -1,4 +1,4 @@ -export type AskOption = { label: string; description?: string } +export type AskOption = { label: string; description?: string; preview?: string } export type AskQuestion = { question: string header?: string diff --git a/src/shared/native-chat/input/native-chat-ask.ts b/src/shared/native-chat/input/native-chat-ask.ts index a402c3b0236..a76ee2cf177 100644 --- a/src/shared/native-chat/input/native-chat-ask.ts +++ b/src/shared/native-chat/input/native-chat-ask.ts @@ -1,4 +1,5 @@ import { nativeChatAskScanOrder } from './native-chat-ask-order' +import { askOptionPreview } from './native-chat-ask-option-preview' import type { AskOption, AskPrompt, @@ -39,12 +40,12 @@ function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null { } const text = 'question' in raw && typeof raw.question === 'string' ? raw.question : '' const rawOptions = 'options' in raw ? raw.options : undefined - const options = parseOptions(rawOptions) + const multiSelect = + 'multiSelect' in raw + ? raw.multiSelect === true + : ('multiple' in raw && raw.multiple === true) || ('multi' in raw && raw.multi === true) + const options = parseOptions(rawOptions, multiSelect) if (text || options.length > 0) { - const multiSelect = - 'multiSelect' in raw - ? raw.multiSelect === true - : ('multiple' in raw && raw.multiple === true) || ('multi' in raw && raw.multi === true) questions.push({ question: text, header: 'header' in raw && typeof raw.header === 'string' ? raw.header : undefined, @@ -59,20 +60,10 @@ function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null { /** Claude only switches a single-select to its preview layout for a non-blank preview. */ export function askOptionsHavePreview(rawOptions: unknown): boolean { - return ( - Array.isArray(rawOptions) && - rawOptions.some( - (option) => - option && - typeof option === 'object' && - 'preview' in option && - typeof option.preview === 'string' && - option.preview.trim().length > 0 - ) - ) + return Array.isArray(rawOptions) && rawOptions.some((option) => askOptionPreview(option) !== null) } -function parseOptions(raw: unknown): AskOption[] { +function parseOptions(raw: unknown, multiSelect: boolean): AskOption[] { if (!Array.isArray(raw)) { return [] } @@ -87,12 +78,14 @@ function parseOptions(raw: unknown): AskOption[] { 'label' in option && typeof option.label === 'string' ) { + const preview = multiSelect ? null : askOptionPreview(option) return { label: option.label, description: 'description' in option && typeof option.description === 'string' ? option.description - : undefined + : undefined, + ...(preview ? { preview } : {}) } } return null diff --git a/src/shared/structured-agent-question-card.ts b/src/shared/structured-agent-question-card.ts index 3c98b68c5f2..19487c33e7b 100644 --- a/src/shared/structured-agent-question-card.ts +++ b/src/shared/structured-agent-question-card.ts @@ -25,7 +25,8 @@ export function structuredQuestionCard( multiSelect: question.multiSelect, options: question.options.map((option) => ({ label: option.label, - ...(option.description ? { description: option.description } : {}) + ...(option.description ? { description: option.description } : {}), + ...(option.preview ? { preview: option.preview } : {}) })) })) },