From f74462c8271adc306c0b942134f8491e8de9ff2b Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:39:13 +0100 Subject: [PATCH 1/3] fix(pi): render input dialogs as free-text questions Co-authored-by: GPT 6.1 Sol --- apps/web/src/components/chat/ChatComposer.tsx | 4 +- .../provider-pi/src/server/adapter.test.ts | 71 ++++++++++++------- packages/provider-pi/src/server/adapter.ts | 26 +++---- 3 files changed, 59 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2f20c4810d994..a11589880a976 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3472,7 +3472,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : activePendingProgress ? isChoiceOnlyPendingQuestion ? "Choose an option above" - : "Type your own answer, or leave this blank to use the selected option" + : activePendingProgress.activeQuestion?.options.length === 0 + ? "Type your answer" + : "Type your own answer, or leave this blank to use the selected option" : projectSelectionRequired ? "Choose a project above to start a thread" : showProviderUnavailable diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 06ad17f0568bb..3c00290806b0e 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -3954,32 +3954,51 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("keeps an input dialog's title as the header above its placeholder", () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - const { runtime, takeEvent } = yield* openRuntime(fake); - yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); - yield* fake.emit({ - type: "extension_ui_request", - id: "ui-input-placeholder", - method: "input", - title: "Name", - placeholder: "Your name", - }); - const event = yield* takeEvent( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", - ); - if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { - return assert.fail("expected a user input request"); - } - assert.equal(event.turnItem.questions[0]?.header, "Name"); - assert.equal(event.turnItem.questions[0]?.question, "Your name"); - }).pipe(Effect.scoped, Effect.provide(layerTest)), + it.effect.each([" SQLite\n ", "", undefined])( + "asks the complete input title as free text and returns the exact answer %j", + (answer) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-input-placeholder", + method: "input", + title: "Which database?\n1. Postgres\n2. SQLite", + placeholder: "1,3; custom answer", + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { + return assert.fail("expected a user input request"); + } + assert.equal(event.turnItem.questions[0]?.header, "Question"); + assert.equal( + event.turnItem.questions[0]?.question, + "Which database?\n1. Postgres\n2. SQLite", + ); + assert.deepEqual(decodeUserInputQuestions(event.turnItem.questions)[0]?.options, []); + assert.equal(event.turnItem.questions[0]?.initialAnswer, ""); + yield* runtime.respondToRuntimeRequest({ + requestId: event.turnItem.requestId, + answers: answer === undefined ? {} : { "ui-input-placeholder": answer }, + }); + const response = yield* fake.takeRequest("extension_ui_response"); + if (answer === undefined) { + assert.isTrue(response["cancelled"]); + assert.isUndefined(response["value"]); + } else { + assert.equal(response["value"], answer); + assert.isUndefined(response["cancelled"]); + } + }).pipe(Effect.scoped, Effect.provide(layerTest)), ); it.effect("emits a valid empty select option and returns its original value", () => diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index a82a7f45f4e47..ea5e70ad7b39a 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3594,25 +3594,21 @@ function piQuestion( description: option || "Empty value", value: option, })) - : method === "editor" - ? [] - : [ - { - label: "Submit empty value", - description: "Send an empty string to the extension.", - value: "", - }, - ]; - // Pi's select and editor dialogs carry only a title, so show it once, as the question. - // Extensions may send a blank placeholder, which is not a valid question. - const detail = [recordString(event, "message"), recordString(event, "placeholder")].find( - (text) => text !== undefined && text.trim().length > 0, - ); + : []; + // Input/editor placeholders are hints, not question content. + const detail = + method === "select" + ? [recordString(event, "message"), recordString(event, "placeholder")].find( + (text) => text !== undefined && text.trim().length > 0, + ) + : undefined; return { id: questionId, header: detail === undefined ? "Question" : title, question: detail ?? title, - ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), + ...(method !== "select" + ? { initialAnswer: method === "editor" ? (recordString(event, "prefill") ?? "") : "" } + : {}), options, }; } From 60a90e7fe8bdd3e86c910fc604044d412a63eb25 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:41:58 +0100 Subject: [PATCH 2/3] fix(clients): use the existing literal-answer composer hint Co-authored-by: GPT 6.1 Sol --- apps/mobile/src/features/threads/QuestionAttachments.tsx | 6 +++++- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index 2c369c1e5b7bc..f56a3bfd33fbe 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -224,7 +224,11 @@ export function QuestionAttachments(props: { focusedRef.current = false; onInputFocusChange?.(false); }} - placeholder="Or type a custom answer" + placeholder={ + props.question.initialAnswer !== undefined + ? "Type your answer" + : "Or type a custom answer" + } className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a11589880a976..4ba56ab5ce25d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3472,7 +3472,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : activePendingProgress ? isChoiceOnlyPendingQuestion ? "Choose an option above" - : activePendingProgress.activeQuestion?.options.length === 0 + : isLiteralPendingAnswer ? "Type your answer" : "Type your own answer, or leave this blank to use the selected option" : projectSelectionRequired From 03a1b9ad9807bc41faf93e87ff0a13fc0e44320a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:48:09 +0100 Subject: [PATCH 3/3] chore: credit the models that reviewed this PR Co-authored-by: GPT 6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 (Grok)