Skip to content
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type RenderItem =
key: string;
question: string;
options: Array< { label: string; description: string } >;
answer?: string;
}
| { kind: 'interrupted-marker'; key: string };

Expand All @@ -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 >();
Expand All @@ -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' ) ) {
Expand Down Expand Up @@ -139,7 +161,9 @@ function entriesToRenderItems( entries: SessionEntry[] ): RenderItem[] {
key: `${ entryIndex }:question`,
question: data.question,
options: data.options,
answer: askUserAnswers[ questionOrdinal ],
} );
questionOrdinal += 1;
return;
}

Expand Down Expand Up @@ -317,13 +341,15 @@ export function Conversation( {
startedAt,
pendingQuestions,
pendingAnswers,
answeredQuestions,
onAnswerQuestion,
}: {
data: LoadedAiSession;
isRunning: boolean;
startedAt: number | null;
pendingQuestions: Set< string >;
pendingAnswers: Record< string, string >;
answeredQuestions: Record< string, string >;
onAnswerQuestion: ( question: string, label: string ) => void;
} ) {
const entries = data.entries;
Expand Down Expand Up @@ -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 ) }
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/components/studio-code-session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) {
error: runError,
pendingQuestions,
pendingAnswers,
answeredQuestions,
queuedPrompts,
sendMessage,
interrupt,
Expand Down Expand Up @@ -418,6 +419,7 @@ function SessionContent( { selectedSite }: { selectedSite: SiteDetails } ) {
startedAt={ startedAt }
pendingQuestions={ pendingQuestionTexts }
pendingAnswers={ pendingAnswers }
answeredQuestions={ answeredQuestions }
onAnswerQuestion={ answerQuestion }
/>
) }
Expand Down
32 changes: 27 additions & 5 deletions apps/studio/src/components/studio-code-session/use-agent-run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -92,6 +98,7 @@ interface State {
isInterrupting: boolean;
pendingQuestions: PendingQuestion[];
pendingAnswers: Record< string, string >;
answeredQuestions: Record< string, string >;
queuedPrompts: QueuedPrompt[];
}

Expand All @@ -103,6 +110,7 @@ const initialState: State = {
isInterrupting: false,
pendingQuestions: [],
pendingAnswers: {},
answeredQuestions: {},
queuedPrompts: [],
};

Expand All @@ -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' }
Expand Down Expand Up @@ -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,
Expand All @@ -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':
Expand Down Expand Up @@ -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 } );
Expand Down Expand Up @@ -605,6 +625,7 @@ export function useAgentRun( sessionId: string | undefined ): LiveAgentEvents {
isInterrupting,
pendingQuestions,
pendingAnswers,
answeredQuestions,
queuedPrompts,
} = state;

Expand Down Expand Up @@ -703,6 +724,7 @@ export function useAgentRun( sessionId: string | undefined ): LiveAgentEvents {
error,
pendingQuestions,
pendingAnswers,
answeredQuestions,
queuedPrompts,
sendMessage,
interrupt,
Expand Down