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
This commit is contained in:
Brennan Benson
2026-11-09 12:31:52 -08:00
committed by GitHub
parent c66199ab94
commit b602376e8b
22 changed files with 514 additions and 84 deletions
@@ -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<number[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() =>
@@ -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({
</ScrollView>
) : null}
<ScrollView
ref={scrollRef}
onLayout={onViewportLayout}
style={styles.scroll}
keyboardShouldPersistTaps="always"
>
<ScrollView {...scrollViewProps} style={styles.scroll} keyboardShouldPersistTaps="always">
<View style={styles.questionRow}>
<MobileNativeChatQuestionText title content={q.question} style={styles.questionText} />
<MobileNativeChatCardHeaderAction
@@ -270,6 +271,8 @@ export function MobileNativeChatAsk({
key={`${optIndex}:${opt.label}`}
label={opt.label}
description={opt.description}
preview={opt.preview}
onPreviewLayout={revealContent}
selected={(selections[index] ?? []).includes(optIndex)}
multi={q.multiSelect}
disabled={submitting}
@@ -1,4 +1,4 @@
import { Pressable, Text, View } from 'react-native'
import { Pressable, ScrollView, Text, View, type LayoutChangeEvent } from 'react-native'
import { Check } from 'lucide-react-native'
import {
colors,
@@ -14,6 +14,8 @@ import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
export function MobileNativeChatAskOptionRow({
label,
description,
preview,
onPreviewLayout,
selected,
multi,
disabled,
@@ -21,13 +23,17 @@ export function MobileNativeChatAskOptionRow({
}: {
label: string
description?: string
/** Shown under the row while it is selected, as the agent wrote it. */
preview?: string
/** The preview's edges in the card's scroll content; the row renders as its direct children. */
onPreviewLayout?: (top: number, bottom: number) => void
selected: boolean
multi?: boolean
disabled: boolean
onPress: () => void
}): React.JSX.Element {
useMobileColorScheme()
return (
const row = (
<Pressable
accessibilityRole={multi ? 'checkbox' : 'radio'}
accessibilityState={{ checked: selected, disabled }}
@@ -59,6 +65,30 @@ export function MobileNativeChatAskOptionRow({
</View>
</Pressable>
)
// One root shape whether or not the preview shows, so selecting never remounts the row.
return (
<>
{row}
{preview && selected ? (
<View
style={styles.preview}
onLayout={(event: LayoutChangeEvent) => {
const { y, height } = event.nativeEvent.layout
onPreviewLayout?.(y, y + height)
}}
>
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator>
{/* Mockups are drawn in columns, so they scroll sideways instead of wrapping at phone width. */}
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Text selectable style={styles.previewText}>
{preview}
</Text>
</ScrollView>
</ScrollView>
</View>
) : 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
}
}))
@@ -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<ScrollView>(null)
const inputRef = useRef<TextInput>(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<NativeScrollEvent>): 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
}
}
@@ -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])
})
})
@@ -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 } : {})
}
]
: []
@@ -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 })
}))
}
@@ -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())
})
@@ -231,3 +231,44 @@ export const historyLinks = defineFixture({
),
interact: (frame) => frame.querySelector<HTMLButtonElement>('button[aria-expanded]')?.click()
})
export const previews = defineFixture({
width: 560,
render: () => (
<div
className="native-chat-appearance bg-chat-canvas p-4"
style={nativeChatAppearanceStyle({})}
>
<NativeChatQuestionCard
prompt={{
questions: [
{
question: 'Which layout should the settings page use?',
multiSelect: false,
options: [
{
label: 'Sidebar',
description: 'Sections listed on the left.',
preview:
'┌──────────┬───────────────────────────┐\n│ General │ Theme [ Dark ▾ ] │\n│ Editor │ Font size [ 13 ] │\n│ Terminal │ │\n└──────────┴───────────────────────────┘'
},
{
label: 'Tabs',
description: 'Sections as tabs across the top.',
preview:
'┌─────────┬────────┬──────────┐\n│ General │ Editor │ Terminal │\n├─────────┴────────┴──────────┤\n│ Theme [ Dark ▾ ] │\n└─────────────────────────────┘'
}
]
}
]
}}
onAnswer={() => {}}
onCancel={() => {}}
/>
</div>
),
interact: async (frame) => {
frame.querySelectorAll<HTMLButtonElement>('button[aria-pressed]')[0]?.click()
await new Promise(requestAnimationFrame)
}
})
@@ -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(
<NativeChatQuestionCard
prompt={{
questions: [
{
question: 'Which layout?',
multiSelect: false,
options: [
{ label: 'Sidebar', preview: '| nav | main |' },
{ label: 'Top bar', preview: '| nav |\n| main |' }
]
}
]
}}
onAnswer={onAnswer}
onCancel={() => {}}
shouldFocus
/>
)
})
const rows = container.querySelectorAll<HTMLButtonElement>('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: '' }])
})
})
@@ -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<HTMLButtonElement>('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)}
@@ -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 (
<button
type="button"
disabled={disabled}
onClick={onSelect}
aria-pressed={selected}
data-native-chat-prompt-card-focus-target={initialFocus || undefined}
className={cn(
'group/option flex w-full items-start gap-3 px-3.5 py-2.5 text-left text-foreground transition-colors focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none',
selected
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
<span
// One root whether or not the preview shows, so selecting never remounts the focused row.
<div>
<button
type="button"
disabled={disabled}
onClick={onSelect}
aria-pressed={selected}
data-native-chat-prompt-card-focus-target={initialFocus || undefined}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-md text-xs font-medium',
selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
'group/option flex w-full items-start gap-3 px-3.5 py-2.5 text-left text-foreground transition-colors focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none',
selected
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
{selected ? <Check className="size-3.5" strokeWidth={3} /> : badge}
</span>
<span className="min-w-0">
<span className="block break-words text-sm">{label}</span>
{description ? (
<span
className={cn(
'block whitespace-pre-wrap break-words text-xs',
selected
? 'text-accent-foreground'
: 'text-muted-foreground group-hover/option:text-accent-foreground group-focus/option:text-accent-foreground'
)}
>
{description}
</span>
) : null}
</span>
</button>
<span
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-md text-xs font-medium',
selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
>
{selected ? <Check className="size-3.5" strokeWidth={3} /> : badge}
</span>
<span className="min-w-0">
<span className="block break-words text-sm">{label}</span>
{description ? (
<span
className={cn(
'block whitespace-pre-wrap break-words text-xs',
selected
? 'text-accent-foreground'
: 'text-muted-foreground group-hover/option:text-accent-foreground group-focus/option:text-accent-foreground'
)}
>
{description}
</span>
) : null}
</span>
</button>
{preview && selected ? (
<pre
ref={revealPreview}
className="mx-3.5 mb-2.5 max-h-56 overflow-auto whitespace-pre rounded-md border border-chat-code-border bg-chat-code-surface px-2.5 py-2 font-mono text-chat-code leading-snug text-chat-foreground scrollbar-sleek"
>
{preview}
</pre>
) : null}
</div>
)
}
@@ -7,7 +7,7 @@ export function useNativeChatQuestionKeys(
cardRef: RefObject<HTMLElement | null>,
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(() => {
@@ -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({
@@ -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 = {
@@ -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()
})
})
@@ -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
])
)
}
@@ -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]
}
@@ -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)
}))
@@ -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
}
@@ -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
+11 -18
View File
@@ -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
+2 -1
View File
@@ -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 } : {})
}))
}))
},