From cbf211c5d2b552a950833ccf01090d3be6f696af Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Mon, 15 Jun 2026 14:54:15 +0100 Subject: [PATCH 1/5] style: render Studio Code interview options as buttons not links --- .../conversation/style.module.css | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 a5aeae85ca..fc8b0ba2c7 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 @@ -174,17 +174,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 { @@ -198,7 +205,9 @@ .questionOptionPicked, .questionOptionPicked:disabled { font-weight: 600; - color: var(--color-frame-theme); + background-color: var(--color-frame-theme); + border-color: var(--color-frame-theme); + color: var(--color-frame-bg); opacity: 1; } From d8c0e1997af46c9a81d8bda2b0e329c32c0cbf20 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Fri, 19 Jun 2026 13:49:25 +0100 Subject: [PATCH 2/5] Fix unreadable selected interview option in dark mode The picked option used a filled accent background with inverted text, which rendered as dark text on light blue in dark mode (muddy) and could drop out entirely if the accent fill failed to resolve. Mark the selected option with a bold label, an accent ring, and a raised surface instead, keeping the normal text color so the choice stays legible in both light and dark mode. --- .../studio-code-session/conversation/style.module.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 0ce3a9dbb6..2ef73a66d6 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 @@ -209,9 +209,10 @@ .questionOptionPicked, .questionOptionPicked:disabled { font-weight: 600; - background-color: var(--color-frame-theme); + background-color: var(--color-frame-surface-alt); border-color: var(--color-frame-theme); - color: var(--color-frame-bg); + box-shadow: inset 0 0 0 1px var(--color-frame-theme); + color: var(--color-frame-text); opacity: 1; } From 543c2d92722e1dd6d5003061a08f741384660201 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Fri, 19 Jun 2026 15:27:36 +0100 Subject: [PATCH 3/5] fix: keep picked interview option highlighted in Studio Code chat history --- .../conversation/index.test.tsx | 62 +++++++++++++++++++ .../conversation/index.tsx | 28 ++++++++- .../conversation/style.module.css | 8 ++- 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 apps/studio/src/components/studio-code-session/conversation/index.test.tsx 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..fe721a1718 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; } @@ -359,7 +383,7 @@ export function Conversation( { question={ item.question } options={ item.options } isInteractive={ pendingQuestions.has( item.question ) } - pickedLabel={ pendingAnswers[ item.question ] } + pickedLabel={ pendingAnswers[ 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 2ef73a66d6..1823517b71 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 @@ -211,11 +211,17 @@ font-weight: 600; background-color: var(--color-frame-surface-alt); border-color: var(--color-frame-theme); - box-shadow: inset 0 0 0 1px var(--color-frame-theme); color: var(--color-frame-text); opacity: 1; } +/* Once finalized, the picked option still reads as the selection (accent + ring) 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; From 9b21870b70bc1b553f31911e88bf638a5c2ecf0c Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Fri, 19 Jun 2026 16:20:21 +0100 Subject: [PATCH 4/5] fix: keep picked option highlighted live after answering --- .../conversation/index.tsx | 8 ++++- .../components/studio-code-session/index.tsx | 2 ++ .../studio-code-session/use-agent-run.tsx | 31 ++++++++++++++++--- 3 files changed, 35 insertions(+), 6 deletions(-) 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 fe721a1718..bbdc8bbb81 100644 --- a/apps/studio/src/components/studio-code-session/conversation/index.tsx +++ b/apps/studio/src/components/studio-code-session/conversation/index.tsx @@ -341,6 +341,7 @@ export function Conversation( { startedAt, pendingQuestions, pendingAnswers, + answeredQuestions, onAnswerQuestion, }: { data: LoadedAiSession; @@ -348,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; @@ -383,7 +385,11 @@ export function Conversation( { question={ item.question } options={ item.options } isInteractive={ pendingQuestions.has( item.question ) } - pickedLabel={ pendingAnswers[ item.question ] ?? item.answer } + 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/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..13a58874ec 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, @@ -183,9 +196,15 @@ function reducer( state: State, action: Action ): State { return { ...state, pendingAnswers: { ...state.pendingAnswers, [ action.question ]: action.answer }, + answeredQuestions: { ...state.answeredQuestions, [ 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 +580,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 +624,7 @@ export function useAgentRun( sessionId: string | undefined ): LiveAgentEvents { isInterrupting, pendingQuestions, pendingAnswers, + answeredQuestions, queuedPrompts, } = state; @@ -703,6 +723,7 @@ export function useAgentRun( sessionId: string | undefined ): LiveAgentEvents { error, pendingQuestions, pendingAnswers, + answeredQuestions, queuedPrompts, sendMessage, interrupt, From 4a20903205cf5902758a63d51132cb093482424a Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Fri, 19 Jun 2026 16:48:04 +0100 Subject: [PATCH 5/5] refactor: drop redundant answered-question write, fix stale comment --- .../studio-code-session/conversation/style.module.css | 2 +- .../src/components/studio-code-session/use-agent-run.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 1823517b71..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 @@ -216,7 +216,7 @@ } /* Once finalized, the picked option still reads as the selection (accent - ring) but dims so it clearly looks disabled rather than clickable. */ + border) but dims so it clearly looks disabled rather than clickable. */ .questionOptionPicked:disabled { opacity: 0.6; cursor: default; 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 13a58874ec..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 @@ -193,10 +193,11 @@ 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 }, - answeredQuestions: { ...state.answeredQuestions, [ action.question ]: action.answer }, }; case 'batch_dispatched': return {