diff --git a/apps/studio/src/components/studio-code-session/conversation/index.test.tsx b/apps/studio/src/components/studio-code-session/conversation/index.test.tsx new file mode 100644 index 0000000000..6c6b56cd4a --- /dev/null +++ b/apps/studio/src/components/studio-code-session/conversation/index.test.tsx @@ -0,0 +1,62 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from 'vitest'; +import { entriesToRenderItems } from './index'; +import type { SessionEntry } from '@earendil-works/pi-coding-agent'; + +function customEntry( customType: string, data: unknown ): SessionEntry { + return { + type: 'custom', + id: Math.random().toString( 36 ).slice( 2 ), + parentId: null, + timestamp: '2026-06-19T00:00:00.000Z', + customType, + data, + } as unknown as SessionEntry; +} + +function question( q: string, options: string[] ): SessionEntry { + return customEntry( 'studio.agent_question', { + question: q, + options: options.map( ( label ) => ( { label, description: '' } ) ), + } ); +} + +function answer( text: string ): SessionEntry { + return customEntry( 'studio.user_prompt', { text, source: 'ask_user' } ); +} + +describe( 'entriesToRenderItems – persisted picked answers', () => { + it( 'pairs a question with its persisted ask_user answer', () => { + const items = entriesToRenderItems( [ question( 'Pick one', [ 'A', 'B' ] ), answer( 'B' ) ] ); + const q = items.find( ( i ) => i.kind === 'agent-question' ); + expect( q ).toMatchObject( { kind: 'agent-question', question: 'Pick one', answer: 'B' } ); + } ); + + it( 'pairs a batch of questions with answers by order', () => { + // The CLI persists all questions first, then all answers, in question order. + const items = entriesToRenderItems( [ + question( 'Q1', [ 'A', 'B' ] ), + question( 'Q2', [ 'C', 'D' ] ), + answer( 'A' ), + answer( 'D' ), + ] ); + const questions = items.filter( ( i ) => i.kind === 'agent-question' ); + expect( questions ).toMatchObject( [ + { question: 'Q1', answer: 'A' }, + { question: 'Q2', answer: 'D' }, + ] ); + } ); + + it( 'leaves answer undefined for an unanswered question', () => { + const items = entriesToRenderItems( [ question( 'Q1', [ 'A', 'B' ] ) ] ); + const q = items.find( ( i ) => i.kind === 'agent-question' ); + expect( q ).toMatchObject( { question: 'Q1', answer: undefined } ); + } ); + + it( 'does not render ask_user prompts as user text', () => { + const items = entriesToRenderItems( [ question( 'Q1', [ 'A' ] ), answer( 'A' ) ] ); + expect( items.some( ( i ) => i.kind === 'user-text' ) ).toBe( false ); + } ); +} ); diff --git a/apps/studio/src/components/studio-code-session/conversation/index.tsx b/apps/studio/src/components/studio-code-session/conversation/index.tsx index e32e8f6abd..bbdc8bbb81 100644 --- a/apps/studio/src/components/studio-code-session/conversation/index.tsx +++ b/apps/studio/src/components/studio-code-session/conversation/index.tsx @@ -39,6 +39,7 @@ type RenderItem = key: string; question: string; options: Array< { label: string; description: string } >; + answer?: string; } | { kind: 'interrupted-marker'; key: string }; @@ -64,7 +65,7 @@ interface PiToolResultLike { const HIDDEN_TOOL_ROWS = new Set( [ 'studio_present' ] ); -function entriesToRenderItems( entries: SessionEntry[] ): RenderItem[] { +export function entriesToRenderItems( entries: SessionEntry[] ): RenderItem[] { // First pass: collect tool_call_id → tool_result pairings so each // `toolCall` row can render its output inline. const resultsByToolCallId = new Map< string, NormalizedToolResult >(); @@ -82,6 +83,27 @@ function entriesToRenderItems( entries: SessionEntry[] ): RenderItem[] { } ); } + // Answers picked for `studio.agent_question` entries are persisted as + // `studio.user_prompt` entries with `source: 'ask_user'` (the CLI writes all + // questions in a batch first, then all answers, in question order). They are + // not rendered as prompts, but we reuse them to keep the picked option + // highlighted in history after the turn moves on — and after a reload, since + // this is disk-backed (unlike the ephemeral `pendingAnswers` state). + const askUserAnswers: string[] = []; + for ( const entry of entries ) { + if ( isStudioCustomEntryOfType( entry, 'studio.user_prompt' ) ) { + const data = ( entry as StudioCustomEntry< 'studio.user_prompt' > ).data; + if ( data?.source === 'ask_user' ) { + askUserAnswers.push( data.text ); + } + } + } + // ponytail: ordinal pairing — i-th question ↔ i-th ask_user answer. A skipped + // question (empty answer isn't persisted) would shift later pairings, but such + // batches are interrupted and become non-interactive. Upgrade to label-matching + // only if that case ever shows wrong highlights. + let questionOrdinal = 0; + const items: RenderItem[] = []; entries.forEach( ( entry, entryIndex ) => { if ( isStudioCustomEntryOfType( entry, 'studio.user_prompt' ) ) { @@ -139,7 +161,9 @@ function entriesToRenderItems( entries: SessionEntry[] ): RenderItem[] { key: `${ entryIndex }:question`, question: data.question, options: data.options, + answer: askUserAnswers[ questionOrdinal ], } ); + questionOrdinal += 1; return; } @@ -317,6 +341,7 @@ export function Conversation( { startedAt, pendingQuestions, pendingAnswers, + answeredQuestions, onAnswerQuestion, }: { data: LoadedAiSession; @@ -324,6 +349,7 @@ export function Conversation( { startedAt: number | null; pendingQuestions: Set< string >; pendingAnswers: Record< string, string >; + answeredQuestions: Record< string, string >; onAnswerQuestion: ( question: string, label: string ) => void; } ) { const entries = data.entries; @@ -359,7 +385,11 @@ export function Conversation( { question={ item.question } options={ item.options } isInteractive={ pendingQuestions.has( item.question ) } - pickedLabel={ pendingAnswers[ item.question ] } + pickedLabel={ + pendingAnswers[ item.question ] ?? + answeredQuestions[ item.question ] ?? + item.answer + } onAnswer={ ( label ) => onAnswerQuestion( item.question, label ) } /> ); diff --git a/apps/studio/src/components/studio-code-session/conversation/style.module.css b/apps/studio/src/components/studio-code-session/conversation/style.module.css index 5c88fb917f..5ab27f9bfc 100644 --- a/apps/studio/src/components/studio-code-session/conversation/style.module.css +++ b/apps/studio/src/components/studio-code-session/conversation/style.module.css @@ -178,17 +178,24 @@ } .questionOption { - background: none; - border: none; - padding: 0; - color: var(--color-frame-theme); + display: inline-flex; + align-items: center; + background-color: var(--color-frame-surface); + border: 1px solid var(--color-frame-border); + border-radius: 6px; + padding: 4px 10px; + color: var(--color-frame-text); font: inherit; + font-size: var(--wpds-typography-font-size-sm); + line-height: 1.4; cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; } .questionOption:hover:not(:disabled), .questionOption:focus-visible:not(:disabled) { - text-decoration: underline; + background-color: var(--color-frame-surface-alt); + border-color: var(--color-frame-text-secondary); } .questionOption:disabled { @@ -202,10 +209,19 @@ .questionOptionPicked, .questionOptionPicked:disabled { font-weight: 600; - color: var(--color-frame-theme); + background-color: var(--color-frame-surface-alt); + border-color: var(--color-frame-theme); + color: var(--color-frame-text); opacity: 1; } +/* Once finalized, the picked option still reads as the selection (accent + border) but dims so it clearly looks disabled rather than clickable. */ +.questionOptionPicked:disabled { + opacity: 0.6; + cursor: default; +} + .interruptedMarker { align-self: center; margin: var(--wpds-dimension-padding-sm) 0; diff --git a/apps/studio/src/components/studio-code-session/index.tsx b/apps/studio/src/components/studio-code-session/index.tsx index 4adcf58eb4..24d226cdf6 100644 --- a/apps/studio/src/components/studio-code-session/index.tsx +++ b/apps/studio/src/components/studio-code-session/index.tsx @@ -253,6 +253,7 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) { error: runError, pendingQuestions, pendingAnswers, + answeredQuestions, queuedPrompts, sendMessage, interrupt, @@ -418,6 +419,7 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) { startedAt={ startedAt } pendingQuestions={ pendingQuestionTexts } pendingAnswers={ pendingAnswers } + answeredQuestions={ answeredQuestions } onAnswerQuestion={ answerQuestion } /> ) } diff --git a/apps/studio/src/components/studio-code-session/use-agent-run.tsx b/apps/studio/src/components/studio-code-session/use-agent-run.tsx index f1f14cf12b..e73520a19b 100644 --- a/apps/studio/src/components/studio-code-session/use-agent-run.tsx +++ b/apps/studio/src/components/studio-code-session/use-agent-run.tsx @@ -69,6 +69,12 @@ export interface LiveAgentEvents { // The user can re-click an option to change their pick until every // question is answered, at which point the batch is dispatched. pendingAnswers: Record< string, string >; + // Answers that have already been dispatched, kept for the lifetime of the + // session so the picked option stays highlighted in history. The persisted + // `ask_user` entry only reaches the transcript after a disk refetch (which + // lags the live run), so this in-memory map bridges that gap; on reload the + // transcript falls back to the disk-backed answer. + answeredQuestions: Record< string, string >; // Follow-up prompts the user staged while a turn was in flight. FIFO: // the head auto-dispatches when the current run ends. queuedPrompts: QueuedPrompt[]; @@ -92,6 +98,7 @@ interface State { isInterrupting: boolean; pendingQuestions: PendingQuestion[]; pendingAnswers: Record< string, string >; + answeredQuestions: Record< string, string >; queuedPrompts: QueuedPrompt[]; } @@ -103,6 +110,7 @@ const initialState: State = { isInterrupting: false, pendingQuestions: [], pendingAnswers: {}, + answeredQuestions: {}, queuedPrompts: [], }; @@ -116,7 +124,7 @@ type Action = | { type: 'interrupt_requested' } | { type: 'questions_added'; questions: PendingQuestion[] } | { type: 'question_answered'; question: string; answer: string } - | { type: 'batch_dispatched' } + | { type: 'batch_dispatched'; answers: Record< string, string > } | { type: 'queue_append'; prompt: QueuedPrompt } | { type: 'queue_remove'; id: string } | { type: 'queue_shift' } @@ -162,8 +170,13 @@ function reducer( state: State, action: Action ): State { }; case 'run_ended': // Preserve the queue across run boundaries so staged follow-ups - // survive the transition. Everything else resets. - return { ...initialState, queuedPrompts: state.queuedPrompts }; + // survive the transition, and the answered-question map so picked + // options stay highlighted in history. Everything else resets. + return { + ...initialState, + queuedPrompts: state.queuedPrompts, + answeredQuestions: state.answeredQuestions, + }; case 'interrupt_requested': return { ...state, @@ -180,12 +193,19 @@ function reducer( state: State, action: Action ): State { pendingQuestions: [ ...state.pendingQuestions, ...action.questions ], }; case 'question_answered': + // `answeredQuestions` is only written on dispatch; mid-batch the live + // highlight comes from `pendingAnswers`. return { ...state, pendingAnswers: { ...state.pendingAnswers, [ action.question ]: action.answer }, }; case 'batch_dispatched': - return { ...state, pendingQuestions: [], pendingAnswers: {} }; + return { + ...state, + pendingQuestions: [], + pendingAnswers: {}, + answeredQuestions: { ...state.answeredQuestions, ...action.answers }, + }; case 'queue_append': return { ...state, queuedPrompts: [ ...state.queuedPrompts, action.prompt ] }; case 'queue_remove': @@ -561,7 +581,7 @@ export function AgentRunProvider( { children }: PropsWithChildren ) { ( q ) => typeof nextAnswers[ q.question ] === 'string' ); if ( complete ) { - dispatchSession( sessionId, { type: 'batch_dispatched' } ); + dispatchSession( sessionId, { type: 'batch_dispatched', answers: nextAnswers } ); void getIpcApi().answerAiAgentQuestion( state.runId, nextAnswers ); } else { dispatchSession( sessionId, { type: 'question_answered', question, answer } ); @@ -605,6 +625,7 @@ export function useAgentRun( sessionId: string | undefined ): LiveAgentEvents { isInterrupting, pendingQuestions, pendingAnswers, + answeredQuestions, queuedPrompts, } = state; @@ -703,6 +724,7 @@ export function useAgentRun( sessionId: string | undefined ): LiveAgentEvents { error, pendingQuestions, pendingAnswers, + answeredQuestions, queuedPrompts, sendMessage, interrupt,