From b602376e8b3bb36a9e6af519e01ef4650ca0dffc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:52 -0800 Subject: [PATCH] Show AskUserQuestion option previews on desktop and phone (#40109) * Show AskUserQuestion option previews on desktop and phone Claude can attach a preview (a mockup or snippet) to each option of a single-select question. Orca dropped it, so users chose between bare labels. Options now carry the preview through the journal, terminal asks and the phone, and the question card shows the selected option's preview as plain monospace text. Questions with previews confirm explicitly instead of auto-advancing, so the preview stays readable. * Keep phone previews bounded and digit focus on the named option Phone previews get a capped height with their own vertical scroll, and opening one never scrolls past its top. A digit always focuses its row before the chosen-row check, so Enter confirms the option the user named. Tighter preview line height, a stable phone row root, and a persistence test whose expected previews come from the fixed input. * Narrow the reopened question with a type guard instead of a cast --- .../native-chat/input/MobileNativeChatAsk.tsx | 21 ++-- .../input/MobileNativeChatAskOptionRow.tsx | 48 +++++++++- .../use-mobile-native-chat-reply-scroll.ts | 37 ++++++- .../claude-structured-prompt-items.test.ts | 47 +++++++++ .../journal/claude-structured-prompt-items.ts | 7 +- .../journal-prompt-body-bounds.ts | 6 +- ...journal-prompt-preview-persistence.test.ts | 96 +++++++++++++++++++ .../NativeChatQuestionCard.fixture.tsx | 41 ++++++++ .../prompts/NativeChatQuestionCard.test.tsx | 40 ++++++++ .../prompts/NativeChatQuestionCard.tsx | 25 ++++- .../prompts/NativeChatQuestionOption.tsx | 87 ++++++++++------- .../prompts/use-native-chat-question-keys.ts | 6 +- .../journal/agent-session-journal-schemas.ts | 3 +- .../journal/agent-session-journal-types.ts | 2 + .../agent-status-field-normalization.test.ts | 49 ++++++++++ .../agent-status-field-normalization.ts | 20 +++- .../input/native-chat-ask-card-rules.ts | 15 ++- .../input/native-chat-ask-dismiss-key.ts | 3 +- .../input/native-chat-ask-option-preview.ts | 11 +++ .../input/native-chat-ask-types.ts | 2 +- .../native-chat/input/native-chat-ask.ts | 29 +++--- src/shared/structured-agent-question-card.ts | 3 +- 22 files changed, 514 insertions(+), 84 deletions(-) create mode 100644 src/main/claude/structured/journal/claude-structured-prompt-items.test.ts create mode 100644 src/main/native-chat/agent-session-journal/item-lifecycle/journal-prompt-preview-persistence.test.ts create mode 100644 src/shared/agent-status/contracts/agent-status-field-normalization.test.ts create mode 100644 src/shared/native-chat/input/native-chat-ask-option-preview.ts 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 } : {}) })) })) },