diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 89edbd7a44aa1..3a9e2ad7cabbe 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,7 +1,10 @@ import { Button } from "../../components/ui/Button"; import { QuestionAttachments } from "./QuestionAttachments"; import type { RuntimeRequestId } from "@t3tools/contracts"; -import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; +import { + isGenericUserInputQuestionHeader, + type ThreadUserInputQuestion, +} from "@t3tools/client-runtime/state/thread-requests"; import { useCallback, useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { @@ -72,7 +75,8 @@ function QuestionText(props: { readonly markdown: string }) { } export function PendingUserInputCard(props: PendingUserInputCardProps) { - const questionCount = props.pendingUserInput.questions.length; + const questionCount = + props.pendingUserInput.queueQuestionTotal ?? props.pendingUserInput.questions.length; const canRespond = props.pendingUserInput.responseCapability !== "not_resumable"; const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId; const responseDisabled = !canRespond || isResponding; @@ -201,10 +205,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { /> + {/* Spans the card's full width so question bands can reach its edges; content keeps the card's 16px inset. */} ) : null} - {props.pendingUserInput.questions.map((question) => { + {props.pendingUserInput.questions.map((question, questionIndex) => { const draft = props.drafts[question.id]; + const eyebrow = [ + isGenericUserInputQuestionHeader(question.header) ? null : question.header, + questionCount > 1 + ? `${(props.pendingUserInput.queueQuestionIndex ?? 0) + questionIndex + 1}/${questionCount}` + : null, + question.multiSelect ? "Select any" : null, + ] + .filter((part) => part !== null) + .join(" · "); + const selectableOptions = question.options.filter( + (option) => option.customAnswer !== true, + ); + const customAnswerOptions = question.options.filter( + (option) => option.customAnswer === true, + ); return ( - - {question.header} - - - - {question.options.map((option) => { + + {eyebrow ? ( + {eyebrow} + ) : null} + + + + {selectableOptions.map((option) => { const optionValue = option.value ?? option.label.trim(); const selected = isPendingUserInputOptionSelected(question, draft, optionValue); const description = @@ -237,8 +259,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { accessibilityState={{ checked: selected, disabled: responseDisabled }} disabled={responseDisabled} className={cn( - "min-h-12 w-full rounded-2xl border px-3.5 py-3", - selected ? "border-primary bg-primary/10" : "border-border bg-input", + "min-h-12 w-full justify-center gap-0.5 rounded-xl px-2 py-2", + selected ? "bg-foreground/8" : "active:bg-row-hover", )} onPress={() => props.onSelectOption( @@ -248,24 +270,73 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ) } > - - + {/* The check shares the label's first line and keeps its box when unselected, so selecting never rewraps the label. */} + + {option.label} - {description ? ( - - {description} - - ) : null} + + {selected ? ( + + ) : null} + + {description ? ( + + {description} + + ) : null} ); })} + {customAnswerOptions.map((option) => { + const optionValue = option.value ?? option.label.trim(); + const description = + option.description !== option.label ? option.description : undefined; + return ( + + + + {option.label} + + + + + + {description ? ( + + {description} + + ) : null} + + ); + })} {question.allowCustomAnswer !== false ? ( { expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first); }); + it("does not toggle or submit custom-answer options", () => { + const customQuestion = { + ...singleSelectQuestion, + options: [ + ...singleSelectQuestion.options, + { + label: "Write my own answer", + description: "Write my own answer", + customAnswer: true, + }, + ], + }; + expect( + togglePendingUserInputOptionSelection(customQuestion, undefined, "Write my own answer"), + ).toEqual({}); + expect( + isPendingUserInputOptionSelected( + customQuestion, + { selectedOptionValues: ["Write my own answer"] }, + "Write my own answer", + ), + ).toBe(false); + expect( + buildPendingUserInputAnswers([customQuestion], { + runtime: { selectedOptionValues: ["Write my own answer"] }, + }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers([customQuestion], { + runtime: { customAnswer: "Bun" }, + }), + ).toEqual({ runtime: "Bun" }); + }); + it("rejects arbitrary text when the provider only accepts offered options", () => { expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({}); expect( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index b9fe76a8b07b2..9ac0919b3ad87 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -421,13 +421,14 @@ function resolvePendingUserInputOptionValue( question: ThreadUserInputQuestion, value: string, ): string | null { - if (question.options.some((option) => option.value === value)) { + const selectableOptions = question.options.filter((option) => option.customAnswer !== true); + if (selectableOptions.some((option) => option.value === value)) { return value; } const label = value.trim(); return label.length > 0 && - question.options.some((option) => option.value === undefined && option.label.trim() === label) + selectableOptions.some((option) => option.value === undefined && option.label.trim() === label) ? label : null; } diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index e7c4f56c30f24..89157911c07ea 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -135,6 +135,7 @@ const question = Schema.Struct({ label: Schema.String, description: Schema.String, value: Schema.optional(Schema.String), + customAnswer: Schema.optional(Schema.Boolean), }), ), multiSelect: Schema.optional(Schema.Boolean), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5f2caa3f3e26a..edcd7ad0a8b25 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -318,6 +318,11 @@ describe("ClaudeAdapterV2 runtime query policy", () => { options: [ { label: "Simple", description: "Use fewer moving parts", preview }, { label: "Safe", description: "Keep the current setup" }, + { + label: "Write my own answer", + description: "Write my own answer", + customAnswer: true, + }, ], multiSelect: true, }, @@ -331,6 +336,11 @@ describe("ClaudeAdapterV2 runtime query policy", () => { options: [ { label: "Simple", description: "Use fewer moving parts", preview }, { label: "Safe", description: "Keep the current setup" }, + { + label: "Write my own answer", + description: "Write my own answer", + customAnswer: true, + }, ], multiSelect: true, }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f8cbbdabaf2ef..c2454bcbb9043 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3930,6 +3930,7 @@ export function claudeUserInputQuestions( ...(typeof optionRecord.preview === "string" ? { preview: optionRecord.preview } : {}), + ...(optionRecord.customAnswer === true ? { customAnswer: true } : {}), }, ]; }) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.layout.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.layout.browser.test.tsx new file mode 100644 index 0000000000000..5132f6c600f30 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.layout.browser.test.tsx @@ -0,0 +1,390 @@ +import "../../index.css"; + +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ + ...(await importOriginal()), + default: (({ text, className }: { text: string; className?: string }) => ( +

{text}

+ )) as never, +})); + +import { act, render } from "../../../test/render"; +import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import { ComposerBanner } from "./ComposerBanner"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; + +async function renderPanel( + { + header, + question = "Pick one.", + questionCount = 1, + multiSelect = false, + width = 320, + options = [{ label: "Rolling", description: "One node at a time" }], + queue, + }: { + header: string; + question?: string; + questionCount?: number; + multiSelect?: boolean; + width?: number; + options?: { label: string; description: string; customAnswer?: boolean }[]; + queue?: { index: number; total: number }; + }, + run: ( + container: HTMLDivElement, + rerender: (answers: Record) => Promise, + ) => Promise, +) { + const container = document.createElement("div"); + container.style.width = `${width}px`; + const panel = (answers: Record) => ( + + ({ + id: index === 0 ? "strategy" : `strategy-${index}`, + header, + question, + options, + multiSelect, + })), + }, + ]} + cwd="/repo" + threadRef={threadRef} + respondingRequestIds={[]} + answers={answers} + questionIndex={0} + onToggleOption={() => {}} + onAdvance={() => {}} + onDismiss={() => {}} + /> + + ); + // router: false because the pending-input panel never navigates. + await using screen = await render(panel({}), { container, router: false }); + await run(container, (answers) => screen.rerender(panel(answers))); +} + +const centerX = (element: Element) => { + const rect = element.getBoundingClientRect(); + return rect.left + rect.width / 2; +}; + +function firstOption(container: HTMLElement) { + const row = container.querySelector("button[aria-pressed]")!; + const [label, description] = row.querySelectorAll("span"); + const key = row.querySelector("[data-pending-user-input-option-key]")!; + return { row, label: label!, description: description!, key }; +} + +it("keeps a long question header inside the collapsed trigger row", async () => { + const header = "Which of these deployment strategies should the extension use? ".repeat(4); + await renderPanel({ header }, async (container) => { + const trigger = container.querySelector("[data-pending-user-input-toggle]")!; + await act(async () => trigger.click()); + const headerElement = [...trigger.querySelectorAll("span")].find( + (element) => element.textContent === header, + )!; + expect(headerElement.getBoundingClientRect().right).toBeLessThanOrEqual( + trigger.getBoundingClientRect().right, + ); + expect(trigger.scrollWidth).toBeLessThanOrEqual(trigger.clientWidth); + }); +}); + +it("selects an option by swapping its number for a check without resizing the row", async () => { + await renderPanel({ header: "Strategy" }, async (container, rerender) => { + const measure = () => { + const { row, key } = firstOption(container); + return { + row: row.getBoundingClientRect().toJSON(), + key: key.getBoundingClientRect().toJSON(), + }; + }; + const key = () => container.querySelector("[data-pending-user-input-option-key]")!; + const unselected = measure(); + expect(key().textContent).toBe("1"); + await rerender({ strategy: { selectedOptionValues: ["Rolling"] } }); + expect(container.querySelector("button[aria-pressed]")?.getAttribute("aria-pressed")).toBe( + "true", + ); + expect(key().querySelector("svg")).not.toBeNull(); + expect(measure()).toEqual(unselected); + }); +}); + +it.each([ + ["mobile", 414], + ["sm", 1024], +])( + "aligns the header band, question, labels and indicators inside the banner surface at %s width", + async (_, width) => { + await page.viewport(width, 900); + const longQuestion = "Explain which rollout you prefer and why it fits this service. ".repeat( + 40, + ); + for (const header of ["Strategy", "Question"]) { + for (const question of ["Pick one.", longQuestion]) { + await renderPanel({ header, question }, async (container) => { + const viewport = container.querySelector("[data-slot=scroll-area-viewport]")!; + const overflowing = question === longQuestion; + if (overflowing) { + await vi.waitFor(() => expect(viewport.hasAttribute("data-has-overflow-y")).toBe(true)); + } else { + expect(viewport.hasAttribute("data-has-overflow-y")).toBe(false); + } + const surface = container.querySelector("[data-slot=composer-banner]")!; + const bounds = surface.getBoundingClientRect(); + const gaps = (element: Element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left - bounds.left, right: bounds.right - rect.right }; + }; + const paragraphs = [...container.querySelectorAll("p")]; + const eyebrow = header === "Strategy" ? paragraphs[0] : undefined; + const questionParagraph = paragraphs.at(-1)!; + const { row, label, key } = firstOption(container); + for (const text of [eyebrow, questionParagraph, label]) { + if (text) expect(gaps(text).left).toBeCloseTo(16, 0); + } + // The row fill hangs out past the text column on both sides, into the scrollbar gutter when overflowing. + expect(gaps(row)).toEqual({ left: 4, right: 4 }); + // Indicators share the trailing action's axis, 22px in from the surface. + const trailing = [ + ...container.querySelectorAll("[data-slot=composer-banner-actions] svg"), + ].at(-1)!; + expect(bounds.right - centerX(trailing)).toBeCloseTo(22, 0); + expect(centerX(key)).toBeCloseTo(centerX(trailing), 0); + // The header band runs under the actions to the surface's sides and top, ending 10px below the question. + const band = container + .querySelector("[data-pending-user-input-header-band]")! + .getBoundingClientRect(); + const questionRect = questionParagraph.getBoundingClientRect(); + expect(band.left).toBeCloseTo(bounds.left, 0); + expect(band.right).toBeCloseTo(bounds.right, 0); + expect(band.top).toBeCloseTo(bounds.top, 0); + expect(band.bottom - questionRect.bottom).toBeCloseTo(10, 0); + // The 12px eyebrow sits 6px above the question; the first row's fill 6px below the band. + if (eyebrow) { + expect(questionRect.top - eyebrow.getBoundingClientRect().bottom).toBeCloseTo(6, 0); + } + expect(row.getBoundingClientRect().top - band.bottom).toBeCloseTo(6, 0); + expect(row.getBoundingClientRect().top - questionRect.bottom).toBeCloseTo(16, 0); + // The last row's fill ends the body's 4px inset above the banner's own padding. + const body = row.parentElement!.parentElement!; + expect(body.getBoundingClientRect().bottom - row.getBoundingClientRect().bottom).toBe(4); + }); + } + } + }, +); + +it.each([ + ["generic", "Question", false, ["Pick one."], "Pick one."], + ["distinct", "Strategy", false, ["Strategy", "Pick one."], "StrategyPick one."], + ["distinct multi-select", "Strategy", true, ["Strategy · Select any", "Pick one."], null], +])( + "leads with the question under a %s header and summarizes it when collapsed", + async (_, header, multiSelect, expandedLines, collapsedText) => { + await renderPanel({ header, multiSelect }, async (container) => { + const trigger = container.querySelector("[data-pending-user-input-toggle]")!; + const paragraphs = () => [...container.querySelectorAll("p")]; + expect(trigger.dataset.pendingUserInputToggle).toBe("expanded"); + expect(trigger.textContent).toBe(""); + expect(trigger.getAttribute("aria-label")).toBe("Hide the question and its options"); + expect(paragraphs().map((paragraph) => paragraph.textContent)).toEqual(expandedLines); + // The question is always the primary 14px medium line; an eyebrow is a muted 12px label. + const questionStyle = getComputedStyle(paragraphs().at(-1)!); + expect(questionStyle.fontSize).toBe("14px"); + expect(questionStyle.fontWeight).toBe("500"); + if (expandedLines.length > 1) { + const eyebrowStyle = getComputedStyle(paragraphs()[0]!); + expect(eyebrowStyle.fontSize).toBe("12px"); + expect(eyebrowStyle.fontWeight).toBe("500"); + expect(eyebrowStyle.textTransform).toBe("none"); + expect(eyebrowStyle.color).toBe(getComputedStyle(firstOption(container).description).color); + expect(eyebrowStyle.color).not.toBe(questionStyle.color); + } + + if (collapsedText === null) return; + await act(async () => trigger.click()); + expect(trigger.dataset.pendingUserInputToggle).toBe("collapsed"); + expect(trigger.textContent).toBe(collapsedText); + + await act(async () => trigger.click()); + expect(trigger.dataset.pendingUserInputToggle).toBe("expanded"); + expect(trigger.textContent).toBe(""); + }); + }, +); + +it.each([ + ["generic header", false, "mobile", 414], + ["generic header", false, "sm", 1024], + ["generic multi-select header", true, "mobile", 414], + ["generic multi-select header", true, "sm", 1024], + ["eyebrow", false, "mobile", 414], + ["eyebrow", false, "sm", 1024], +])( + "starts the leading %s beside the actions without overlapping them at %s width", + async (lead, multiSelect, _, width) => { + await page.viewport(width, 900); + // One unbreakable run fills every line, so only the first line's width reflects the actions. + const run = "x".repeat(200); + await renderPanel( + lead === "eyebrow" + ? { header: run, question: "Pick one.", questionCount: 3 } + : { header: "Question", question: run, questionCount: 3, multiSelect }, + async (container) => { + const trigger = container.querySelector("[data-pending-user-input-toggle]")!; + const actions = trigger.querySelector("[data-slot=composer-banner-actions]")!; + const paragraph = container.querySelector("p")!; + expect(paragraph.textContent).toBe(run); + const range = document.createRange(); + range.selectNodeContents(paragraph); + const [firstLine, secondLine] = range.getClientRects(); + const actionsRect = actions.getBoundingClientRect(); + const paragraphRect = paragraph.getBoundingClientRect(); + expect(paragraphRect.top).toBeLessThan(trigger.getBoundingClientRect().bottom); + const firstLineHeight = Number.parseFloat(getComputedStyle(paragraph).lineHeight); + expect(firstLineHeight).toBe(lead === "eyebrow" ? 16 : 20); + expect(paragraphRect.top + firstLineHeight / 2).toBeCloseTo( + actionsRect.top + actionsRect.height / 2, + 0, + ); + expect(firstLine!.right).toBeLessThanOrEqual(actionsRect.left); + expect(secondLine!.right).toBeGreaterThan(actionsRect.left); + + const actionText = [...actions.querySelectorAll("span")].map((span) => span.textContent); + expect(actionText.includes("Select any")).toBe(multiSelect); + const count = [...actions.querySelectorAll("span")].find( + (element) => element.textContent === "1/3", + )!; + expect(getComputedStyle(count).fontSize).toBe("12px"); + const caret = actions.querySelector("svg")!; + const countToCaret = + caret.getBoundingClientRect().left - count.getBoundingClientRect().right; + expect(countToCaret).toBeGreaterThanOrEqual(0); + expect(countToCaret).toBeLessThanOrEqual(4); + }, + ); + }, +); + +it("counts a single-question prompt across its simultaneous queue", async () => { + await page.viewport(1024, 900); + await renderPanel({ header: "Strategy", queue: { index: 2, total: 5 } }, async (container) => { + const actions = container.querySelector("[data-slot=composer-banner-actions]")!; + const spans = [...actions.querySelectorAll("span")].map((span) => span.textContent); + expect(spans).toContain("3/5"); + }); +}); + +it.each([ + ["wide", 640, true], + ["narrow", 320, false], +])( + "keeps descriptions on the label's line when %s and pins the indicator to line one", + async (_, width, sharesLine) => { + await page.viewport(1024, 900); + const longLabel = "Roll out one availability zone at a time ".repeat(3); + await renderPanel( + { + header: "Strategy", + width, + options: [ + { label: "Rolling", description: "One node at a time" }, + { label: longLabel, description: "Slower, but every step is reversible" }, + { label: "Same", description: "Same" }, + ], + }, + async (container) => { + const [short, long, bare] = [...container.querySelectorAll("button[aria-pressed]")].map( + (row) => { + const [label, description] = row.querySelectorAll("span"); + return { + row: row.getBoundingClientRect(), + label: label!.getBoundingClientRect(), + description: description!, + key: row.querySelector("[data-pending-user-input-option-key]")!, + }; + }, + ); + const descriptionRect = short!.description.getBoundingClientRect(); + if (sharesLine) { + // 12/20 description shares the label's 14/20 line; baseline alignment offsets its box by a pixel. + expect(Math.abs(descriptionRect.top - short!.label.top)).toBeLessThanOrEqual(1); + expect(descriptionRect.left - short!.label.right).toBeGreaterThanOrEqual(16); + expect(getComputedStyle(short!.description).lineHeight).toBe("20px"); + } else { + expect(descriptionRect.left).toBe(short!.label.left); + expect(descriptionRect.top - short!.label.bottom).toBe(2); + expect(getComputedStyle(short!.description).lineHeight).toBe("16px"); + } + // A wrapped label keeps its indicator beside the first line. + expect(long!.label.height).toBeGreaterThan(20); + expect(long!.key.getBoundingClientRect().top).toBe(long!.label.top); + expect(centerX(long!.key)).toBe(centerX(short!.key)); + expect(short!.row.height).toBe(sharesLine ? 36 : 54); + // A description that repeats the label is dropped. + expect(bare!.description.textContent).toBe(""); + expect(bare!.row.height).toBe(36); + }, + ); + }, +); + +it("renders a custom-answer option as a static downward hint after selectable options", async () => { + await page.viewport(1024, 900); + await renderPanel( + { + header: "Theme", + options: [ + { label: "System", description: "Follow the OS" }, + { label: "Dark", description: "Always dark" }, + { + label: "Write my own answer", + description: "Write my own answer", + customAnswer: true, + }, + ], + }, + async (container) => { + const selectableRows = [...container.querySelectorAll("button[aria-pressed]")]; + expect( + selectableRows.map( + (row) => row.querySelector("[data-pending-user-input-option-key]")?.textContent, + ), + ).toEqual(["1", "2"]); + + const customAnswerRow = container.querySelector( + "[data-pending-user-input-custom-answer-option]", + )!; + expect(customAnswerRow.tagName).toBe("DIV"); + expect(customAnswerRow.getAttribute("aria-pressed")).toBeNull(); + expect(customAnswerRow.textContent).toContain("Write my own answer"); + expect(customAnswerRow.querySelector("svg")).not.toBeNull(); + expect(customAnswerRow.getBoundingClientRect().top).toBeGreaterThanOrEqual( + selectableRows.at(-1)!.getBoundingClientRect().bottom, + ); + + const selectableKey = selectableRows[0]!.querySelector( + "[data-pending-user-input-option-key]", + )!; + const customAnswerIcon = customAnswerRow.querySelector("svg")!; + expect(centerX(customAnswerIcon)).toBe(centerX(selectableKey)); + }, + ); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index e6eecd6ad6f86..6dcd07e0079d1 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -6,9 +6,15 @@ import { TurnItemId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; -import { useLayoutEffect, useRef, type ComponentProps } from "react"; +import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { + togglePendingUserInputOptionSelection, + type PendingUserInputDraftAnswer, +} from "../../pendingUserInput"; +import type { PendingUserInput } from "../../session-logic"; + vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ ...(await importOriginal()), default: (({ text }: { text: string }) =>

{text}

) as never, @@ -16,11 +22,12 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ import { act, fakeTimers, render } from "../../../test/render"; import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { usePendingThreadRequests } from "./usePendingThreadRequests"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; -const prompt = { +const defaultPrompt: PendingUserInput = { requestId: RuntimeRequestId.make("question-navigation"), createdAt: "2026-10-09T00:00:00.000Z", dismissible: false, @@ -34,6 +41,157 @@ const prompt = { })), }; +function Questions({ prompt }: { prompt: PendingUserInput }) { + const [questionIndex, setQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + return ( + <> + {questionIndex} + setQuestionIndex(questionIndex + 1)} + onToggleOption={(questionId, value) => { + const question = prompt.questions.find((entry) => entry.id === questionId)!; + setAnswers((existing) => ({ + ...existing, + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[questionId], + value, + ), + })); + }} + onDismiss={() => {}} + /> + + ); +} + +function optionKeys(container: HTMLElement) { + return [...container.querySelectorAll("button[aria-pressed]")].map((button) => { + const key = button.querySelector("[data-pending-user-input-option-key]")!; + return { + pressed: button.getAttribute("aria-pressed"), + key: key.querySelector("svg") ? "check" : key.textContent, + }; + }); +} + +function pressKey(key: string) { + return act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); +} + +it("swaps the chosen option's number for a check in place and restores it when the choice moves", async () => { + const options = [ + { label: "Rolling", description: "One node at a time" }, + { label: "Canary", description: "A slice of traffic first" }, + { label: "Big bang", description: "Everything at once" }, + ]; + const choicePrompt = { + ...defaultPrompt, + questions: [ + { ...defaultPrompt.questions[0]!, options }, + { ...defaultPrompt.questions[1]!, options, multiSelect: true }, + ], + }; + await using screen = await render(); + using _timers = fakeTimers(); + const keys = () => optionKeys(screen.container); + const activeIndex = () => screen.container.querySelector("output")!.textContent; + const unpressed = [ + { pressed: "false", key: "1" }, + { pressed: "false", key: "2" }, + { pressed: "false", key: "3" }, + ]; + expect(keys()).toEqual(unpressed); + + await pressKey("2"); + expect(keys()).toEqual([ + { pressed: "false", key: "1" }, + { pressed: "true", key: "check" }, + { pressed: "false", key: "3" }, + ]); + + await pressKey("1"); + expect(keys()).toEqual([ + { pressed: "true", key: "check" }, + { pressed: "false", key: "2" }, + { pressed: "false", key: "3" }, + ]); + + await act(() => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); + expect(keys()).toEqual(unpressed); + await pressKey("1"); + await pressKey("3"); + expect(keys()).toEqual([ + { pressed: "true", key: "check" }, + { pressed: "false", key: "2" }, + { pressed: "true", key: "check" }, + ]); + await pressKey("1"); + expect(keys()).toEqual([ + { pressed: "false", key: "1" }, + { pressed: "false", key: "2" }, + { pressed: "true", key: "check" }, + ]); + await act(() => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); +}); + +it("does not select or advance from a custom-answer hint", async () => { + const prompt: PendingUserInput = { + ...defaultPrompt, + questions: [ + { + ...defaultPrompt.questions[0]!, + options: [ + { label: "Rolling", description: "One node at a time" }, + { label: "Canary", description: "A slice of traffic first" }, + { + label: "Write my own answer", + description: "Write my own answer", + customAnswer: true, + }, + ], + }, + ], + }; + await using screen = await render(); + using _timers = fakeTimers(); + const keys = () => optionKeys(screen.container); + const activeIndex = () => screen.container.querySelector("output")!.textContent; + const unpressed = [ + { pressed: "false", key: "1" }, + { pressed: "false", key: "2" }, + ]; + expect(keys()).toEqual(unpressed); + + const customAnswer = screen.container.querySelector( + "[data-pending-user-input-custom-answer-option]", + )!; + expect(customAnswer.tagName).toBe("DIV"); + await act(() => customAnswer.click()); + await pressKey("3"); + await act(() => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("0"); + expect(keys()).toEqual(unpressed); + + await pressKey("2"); + expect(keys()).toEqual([ + { pressed: "false", key: "1" }, + { pressed: "true", key: "check" }, + ]); +}); + function questionProjection(threadId: ThreadId, requestIds = ["first", "second"]) { const projection = makeThreadProjectionFixture(); return { @@ -67,7 +225,7 @@ function questionProjection(threadId: ThreadId, requestIds = ["first", "second"] updatedAt: projection.updatedAt, type: "user_input_request" as const, requestId: RuntimeRequestId.make(id), - questions: prompt.questions, + questions: defaultPrompt.questions, })), } satisfies OrchestrationV2ThreadProjection; } diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 13d3d22aa8827..a3d59b9b123bc 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,3 +1,4 @@ +import { isGenericUserInputQuestionHeader } from "@t3tools/client-runtime/state/thread-requests"; import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; @@ -7,9 +8,8 @@ import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; -import { CheckboxMark } from "../ui/checkbox"; +import { ArrowDownIcon, CheckIcon } from "lucide-react"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; -import { Kbd } from "../ui/kbd"; import { cn } from "~/lib/utils"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; @@ -128,6 +128,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handleOptionSelection = (questionId: string, optionValue: string) => { if (disabled || isResponding) return; + if ( + activeQuestion?.options.some( + (option) => option.customAnswer === true && (option.value ?? option.label) === optionValue, + ) + ) { + return; + } if (activeQuestion?.multiSelect) { onToggleOption(questionId, optionValue); return; @@ -162,9 +169,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return; + const selectableOptions = activeQuestion.options.filter( + (option) => option.customAnswer !== true, + ); const optionIndex = digit - 1; - if (optionIndex >= activeQuestion.options.length) return; - const option = activeQuestion.options[optionIndex]; + if (optionIndex >= selectableOptions.length) return; + const option = selectableOptions[optionIndex]; if (!option) return; event.preventDefault(); selectOptionFromShortcut(activeQuestion.id, option.value ?? option.label); @@ -176,6 +186,27 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( if (!activeQuestion) { return null; } + const selectableOptionEntries = activeQuestion.options.flatMap((option, index) => + option.customAnswer === true ? [] : [{ option, index }], + ); + const customAnswerOptions = activeQuestion.options.filter( + (option) => option.customAnswer === true, + ); + const showHeader = !isGenericUserInputQuestionHeader(activeQuestion.header); + const toggleLabel = isCollapsed + ? "Show the question and its options" + : "Hide the question and its options"; + const queueTotal = prompt.queueQuestionTotal ?? prompt.questions.length; + const questionCount = + queueTotal > 1 ? `${(prompt.queueQuestionIndex ?? 0) + questionIndex + 1}/${queueTotal}` : null; + // Pulls the caret's icon-box padding in so the count and caret read as one cluster. + const questionCountClassName = + "-me-2 text-xs/4 font-medium text-muted-foreground tabular-nums sm:-me-1.5"; + // Without an eyebrow to carry it, the multi-select hint rides in the actions row. + const actionsHint = + !isCollapsed && !showHeader && activeQuestion.multiSelect ? ( + Select any + ) : null; return ( + {/* Expanded, the trigger shrinks to its actions and the question rises beside them. + Its bottom margin also cancels its top margin and the banner's 4px padding, so the panel starts at the surface's top edge. */} } />} - title={ - isCollapsed ? "Show the question and its options" : "Hide the question and its options" - } + className={cn( + !isCollapsed && + "relative z-10 ms-auto mt-1 -mb-[calc(var(--composer-banner-icon-column)+--spacing(2))] flex w-fit", + )} + title={toggleLabel} + aria-label={isCollapsed ? undefined : toggleLabel} data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"} - className="px-4 pt-1.5" > - - {activeQuestion.header} - {isCollapsed ? ( - <> - + {isCollapsed ? ( + <> + + + {showHeader ? ( + + {activeQuestion.header} + + ) : null} {activeQuestion.question} - - ) : null} - - - {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} - - ) : null} + + + ) : null} + {/* The end margin puts the trailing control's center 22px in, on the option indicators' axis. */} + + {actionsHint} + {questionCount ? {questionCount} : null} {prompt.dismissible ? ( - - - - - {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */} -
- {activeQuestion.options.map((option, index) => { + {/* The panel cancels the banner's px-1 so the header band can reach the surface's edges; it clips its overflow. + The vertical scrollbar keeps the inset it had inside that padding. */} + + + {/* Both sides sit 16px in from the surface; the overflow scrollbar gutter takes 8px of the right. */} + + {/* Bleeds back to the surface's sides and top, under the actions, squaring off with the surface under another attachment. + The top inset centers the first line (16px eyebrow or 20px question) on the 28px (24px from sm) actions row. */} +
+ {/* Mirrors the overlaid actions so only the first line wraps clear of them; the end margin cancels 12px of the band's 16px inset. + Its height matches that first line, so a 16px eyebrow does not push its second line clear too. */} + + {actionsHint} + {questionCount ? ( + {questionCount} + ) : null} + + {prompt.dismissible ? : null} + + {showHeader ? ( +

+ {activeQuestion.multiSelect + ? `${activeQuestion.header} · Select any` + : activeQuestion.header} +

+ ) : null} + +
+ {/* Row fills hang 12px past the text column so labels stay on its edge. */} +
+ {selectableOptionEntries.map(({ option, index }, selectableIndex) => { const optionValue = option.value ?? option.label; const isOptimisticallySelected = optimisticSingleSelect?.questionId === activeQuestion.id && optimisticSingleSelect.optionValue === optionValue; const isSelected = isOptimisticallySelected || draftSelectedOptions?.[index] === true; - const className = cn( - "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 text-left sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", - isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30", - (disabled || isResponding) && "opacity-50 cursor-not-allowed", - !disabled && !isResponding && "cursor-pointer", - ); - // The h-5 slots match the label's line box, so the indicator and shortcut stay on its first line. - const content = ( - <> - - {activeQuestion.multiSelect ? ( - - ) : ( - - )} - -
- {option.label} - {option.description && option.description !== option.label ? ( - {option.description} - ) : null} -
- {index < 9 ? ( - - {index + 1} - - ) : null} - - ); return ( ); })} + {customAnswerOptions.map((option) => { + const optionValue = option.value ?? option.label; + return ( +
+ + {option.label} + + + {option.description && option.description !== option.label + ? option.description + : null} + + + + +
+ ); + })}
@@ -301,21 +402,3 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ); }); - -// Drawn with ui/radio-group's look instead of using its Radio: Radio only checks inside a -// RadioGroup, whose arrow keys select options and would trip the single-select auto-advance. -function OptionRadio({ checked }: { checked: boolean }) { - return ( - - {checked ? ( - - ) : null} - - ); -} diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 2fbbca8b70785..2fc064a2098ed 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -134,9 +134,51 @@ describe("resolvePendingUserInputAnswer", () => { }), ).toBeNull(); }); + + it("does not treat a custom-answer option as a selected answer", () => { + const question = { + ...singleSelectQuestion, + options: [ + ...singleSelectQuestion.options, + { + label: "Write my own answer", + description: "Write my own answer", + customAnswer: true, + }, + ], + }; + expect( + resolvePendingUserInputAnswer(question, { + selectedOptionValues: ["Write my own answer"], + }), + ).toBeNull(); + expect( + resolvePendingUserInputAnswer(question, { + customAnswer: "Start with the user-visible behavior", + }), + ).toBe("Start with the user-visible behavior"); + }); }); describe("togglePendingUserInputOptionSelection", () => { + it("ignores custom-answer options", () => { + const question = { + ...singleSelectQuestion, + options: [ + ...singleSelectQuestion.options, + { + label: "Write my own answer", + description: "Write my own answer", + customAnswer: true, + }, + ], + }; + const draft = { selectedOptionValues: ["Orchestration-first"] }; + expect(togglePendingUserInputOptionSelection(question, draft, "Write my own answer")).toBe( + draft, + ); + }); + it("selects and removes options with the same label independently", () => { const question = { ...nativeChoiceQuestion, multiSelect: true }; const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 22d5521f5e78c..e00dca7e84b5c 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -80,7 +80,10 @@ export function resolvePendingUserInputAnswer( } const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( - (value) => question.options.some((option) => (option.value ?? option.label) === value), + (value) => + question.options.some( + (option) => option.customAnswer !== true && (option.value ?? option.label) === value, + ), ); if (question.multiSelect) { return selectedOptionValues.length > 0 @@ -162,6 +165,13 @@ export function togglePendingUserInputOptionSelection( draft: PendingUserInputDraftAnswer | undefined, optionValue: string, ): PendingUserInputDraftAnswer { + if ( + question.options.some( + (option) => option.customAnswer === true && (option.value ?? option.label) === optionValue, + ) + ) { + return draft ?? {}; + } if (question.multiSelect) { const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) diff --git a/packages/client-runtime/src/state/threadDetail.test.ts b/packages/client-runtime/src/state/threadDetail.test.ts index 8fc01839183fe..228a0ca60588c 100644 --- a/packages/client-runtime/src/state/threadDetail.test.ts +++ b/packages/client-runtime/src/state/threadDetail.test.ts @@ -2,7 +2,9 @@ import { EnvironmentId, EventId, MessageId, + NodeId, RunId, + RuntimeRequestId, TurnItemId, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, @@ -313,3 +315,60 @@ it("matches full structure comparisons across streamed, batched, and reordered u [assistant.id, 1], ]); }); + +describe("pendingRequestsAtom", () => { + it("keeps queue positions after an earlier question is answered", () => { + const nodeId = NodeId.make("question-node"); + const ids = ["a", "b", "c"].map((id) => RuntimeRequestId.make(id)); + const projection: OrchestrationV2ThreadProjection = { + ...v2Projection, + runtimeRequests: ids.map((id, index) => ({ + id, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + responseCapability: { type: "message" }, + createdAt: v2Now, + ...(index === 0 + ? { status: "resolved", resolvedAt: later } + : { status: "pending", resolvedAt: null }), + })), + turnItems: ids.map((id) => ({ + ...assistant, + id: TurnItemId.make(`${id}-item`), + status: "completed", + type: "user_input_request", + requestId: id, + responseMode: "message", + questions: [ + { + id, + header: "Next", + question: "Next?", + options: [], + required: true, + allowCustomAnswer: true, + }, + ], + })), + }; + const state = Atom.make( + AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(projection) }), + ); + const pending = createEnvironmentThreadDetailAtoms(() => state).pendingRequestsAtom({ + environmentId: EnvironmentId.make("env"), + threadId: v2ThreadId, + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(pending); + + expect(registry.get(pending)?.userInputs[0]).toMatchObject({ + requestId: ids[1], + queueQuestionIndex: 1, + queueQuestionTotal: 3, + }); + unmount(); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index a53494ebc5929..2d61e875e479f 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -379,8 +379,13 @@ export function createEnvironmentThreadDetailAtoms( value = null; return value; } + // Settled questions stay in so queue positions hold while the user answers. + const hasPendingUserInput = projection.runtimeRequests.some( + (request) => request.kind === "user_input" && request.status === "pending", + ); const runtimeRequests = projection.runtimeRequests.filter( - (request) => request.status === "pending", + (request) => + request.status === "pending" || (hasPendingUserInput && request.kind === "user_input"), ); const ids = new Set(runtimeRequests.map((request) => request.id)); const turnItems = diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index b4a6447c2fced..887af1619eae3 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -5,6 +5,7 @@ import { TurnItemId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { describe, expect, it, vi } from "vite-plus/test"; import { AsyncResult } from "effect/reactivity"; import * as Cause from "effect/Cause"; @@ -203,6 +204,8 @@ describe("pending v2 questions", () => { responseCapability: "message", responseMode: "message", dismissible: true, + queueQuestionIndex: 0, + queueQuestionTotal: 1, questions: [ { id: "next", @@ -236,6 +239,134 @@ describe("pending v2 questions", () => { }); }); +describe("pending question queues", () => { + const baseRequest = projection.runtimeRequests[0]!; + const baseItem = projection.turnItems[0]!; + if (baseItem.type !== "user_input_request") throw new Error("expected a question item"); + const question = baseItem.questions[0]!; + const at = (minute: number) => DateTime.add(v2Now, { minutes: minute }); + + const queue = ( + requests: ReadonlyArray<{ + readonly id: string; + readonly createdAt: number; + readonly resolvedAt?: number; + readonly questions?: number; + }>, + ) => { + const projected = { + ...projection, + runtimeRequests: requests.map((request) => ({ + ...baseRequest, + id: RuntimeRequestId.make(request.id), + createdAt: at(request.createdAt), + ...(request.resolvedAt === undefined + ? {} + : { status: "resolved" as const, resolvedAt: at(request.resolvedAt) }), + })), + turnItems: requests.map((request) => ({ + ...baseItem, + id: TurnItemId.make(`${request.id}-item`), + requestId: RuntimeRequestId.make(request.id), + questions: Array.from({ length: request.questions ?? 1 }, (_, index) => ({ + ...question, + id: `${request.id}-${index}`, + })), + })), + }; + return derivePendingThreadRequests(projected).userInputs.map((input) => [ + input.requestId, + input.queueQuestionIndex, + input.queueQuestionTotal, + ]); + }; + + it("orders pending questions by creation time, not projection order", () => { + expect( + queue([ + { id: "newer", createdAt: 2 }, + { id: "older", createdAt: 1 }, + ]), + ).toEqual([ + ["older", 0, 2], + ["newer", 1, 2], + ]); + }); + + it("counts simultaneous single-question requests as one queue", () => { + const ids = ["a", "b", "c", "d", "e"]; + expect(queue(ids.map((id, index) => ({ id, createdAt: index })))).toEqual( + ids.map((id, index) => [id, index, 5]), + ); + }); + + it("keeps queue positions after earlier questions are answered", () => { + expect( + queue([ + { id: "a", createdAt: 0, resolvedAt: 10 }, + { id: "b", createdAt: 0, resolvedAt: 11 }, + { id: "c", createdAt: 0 }, + { id: "d", createdAt: 0 }, + { id: "e", createdAt: 0 }, + ]), + ).toEqual([ + ["c", 2, 5], + ["d", 3, 5], + ["e", 4, 5], + ]); + }); + + it("starts a new queue for questions asked after the previous one resolved", () => { + expect( + queue([ + { id: "a", createdAt: 0, resolvedAt: 1 }, + { id: "b", createdAt: 2, resolvedAt: 3 }, + { id: "c", createdAt: 4 }, + ]), + ).toEqual([["c", 0, 1]]); + }); + + it("flattens multi-question prompts into the queue count", () => { + expect( + queue([ + { id: "a", createdAt: 0, questions: 3 }, + { id: "b", createdAt: 1 }, + ]), + ).toEqual([ + ["a", 0, 4], + ["b", 3, 4], + ]); + }); + + it("joins requests that overlap through a chain", () => { + expect( + queue([ + { id: "a", createdAt: 0, resolvedAt: 10 }, + { id: "b", createdAt: 5, resolvedAt: 20 }, + { id: "c", createdAt: 15 }, + ]), + ).toEqual([["c", 2, 3]]); + }); + + it("does not hold a queue open for a settled request without a resolution time", () => { + const projected = { + ...projection, + runtimeRequests: [ + { ...baseRequest, id: RuntimeRequestId.make("old"), status: "expired" as const }, + { ...baseRequest, id: requestId, createdAt: at(1) }, + ], + turnItems: [ + { ...baseItem, id: TurnItemId.make("old-item"), requestId: RuntimeRequestId.make("old") }, + baseItem, + ], + }; + expect(derivePendingThreadRequests(projected).userInputs[0]).toMatchObject({ + queueQuestionIndex: 0, + queueQuestionTotal: 1, + }); + }); +}); + it("restores old text answers without mutating history or replacing unchanged rows", () => { const project = createQuestionHistoryProjector(); const item = projection.turnItems[0]!; diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index e14f547157123..bcee11facf8f2 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -79,6 +79,14 @@ export interface ThreadPendingUserInput { readonly responseCapability: OrchestrationV2RuntimeRequest["responseCapability"]["type"]; readonly responseMode?: "message"; readonly dismissible: boolean; + /** Questions asked before this prompt among requests whose pending intervals overlap it. */ + readonly queueQuestionIndex?: number; + readonly queueQuestionTotal?: number; +} + +// Adapters fall back to "Question" or "Question N" when the provider sends no title. +export function isGenericUserInputQuestionHeader(header: string) { + return /^question(?:\s+\d+)?$/i.test(header.trim()); } export interface PendingThreadRequests { @@ -111,16 +119,23 @@ export function derivePendingThreadRequests( ): PendingThreadRequests { const approvals: ThreadPendingApproval[] = []; const userInputs: ThreadPendingUserInput[] = []; + const hasPendingUserInput = projection.runtimeRequests.some( + (request) => request.kind === "user_input" && request.status === "pending", + ); + const userInputItems = hasPendingUserInput + ? userInputItemsByRequestId(projection.turnItems) + : undefined; + const queuePositions = userInputItems + ? userInputQueuePositions(projection.runtimeRequests, userInputItems) + : undefined; for (const request of projection.runtimeRequests) { if (request.status !== "pending") continue; const responseCapability = request.responseCapability.type; if (request.kind === "user_input") { - const item = projection.turnItems.findLast( - (candidate) => - candidate.type === "user_input_request" && candidate.requestId === request.id, - ); - if (item === undefined || item.type !== "user_input_request") continue; + const item = userInputItems?.get(request.id); + if (item === undefined) continue; + const position = queuePositions?.get(request.id); userInputs.push({ requestId: request.id, createdAt: DateTime.formatIso(request.createdAt), @@ -133,6 +148,9 @@ export function derivePendingThreadRequests( ...(item.responseMode === "message" || responseCapability === "message" ? { responseMode: "message" as const } : {}), + ...(position + ? { queueQuestionIndex: position.index, queueQuestionTotal: position.total } + : {}), }); continue; } @@ -161,9 +179,75 @@ export function derivePendingThreadRequests( }); } + // Consumers activate the first entry; queue positions count in creation order. + userInputs.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); return { approvals, userInputs }; } +type UserInputRequestItem = Extract< + ThreadStructure["turnItems"][number], + { type: "user_input_request" } +>; + +function userInputItemsByRequestId(turnItems: ThreadStructure["turnItems"]) { + const items = new Map(); + // Later items win, matching the previous findLast lookup. + for (const item of turnItems) { + if (item.type === "user_input_request") items.set(item.requestId, item); + } + return items; +} + +// Agents like Pi ask several one-question requests at once. Requests whose pending intervals +// overlap form one queue, so the composer can count across them; a question asked after the +// previous one resolved starts a fresh queue. +function userInputQueuePositions( + requests: ThreadStructure["runtimeRequests"], + items: ReadonlyMap, +) { + const intervals = requests.flatMap((request, order) => { + if (request.kind !== "user_input") return []; + const start = DateTime.toEpochMillis(request.createdAt); + const end = + request.resolvedAt !== null + ? DateTime.toEpochMillis(request.resolvedAt) + : request.status === "pending" + ? Number.POSITIVE_INFINITY + : // A settled request without a resolution time must not hold its queue open forever. + start; + // A request without its question item contributes nothing countable. + const questionCount = items.get(request.id)?.questions.length ?? 0; + return [ + { id: request.id, pending: request.status === "pending", start, end, order, questionCount }, + ]; + }); + intervals.sort((left, right) => left.start - right.start || left.order - right.order); + + const positions = new Map(); + let batchStart = 0; + let batchEnd = Number.NEGATIVE_INFINITY; + const flush = (batchStop: number) => { + const batch = intervals.slice(batchStart, batchStop); + if (!batch.some((interval) => interval.pending)) return; + const total = batch.reduce((sum, interval) => sum + interval.questionCount, 0); + let index = 0; + for (const interval of batch) { + if (interval.pending) positions.set(interval.id, { index, total }); + index += interval.questionCount; + } + }; + for (const [position, interval] of intervals.entries()) { + if (position > batchStart && interval.start > batchEnd) { + flush(position); + batchStart = position; + batchEnd = Number.NEGATIVE_INFINITY; + } + batchEnd = Math.max(batchEnd, interval.end); + } + flush(intervals.length); + return positions; +} + export function createQuestionHistoryProjector< Row extends ThreadStructureVisibleTurnItem = OrchestrationV2ThreadProjection["visibleTurnItems"][number], diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b3a85fa3700d8..f3b7a51b04561 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1211,6 +1211,7 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ description: TrimmedNonEmptyString, value: Schema.optional(Schema.String), preview: Schema.optional(Schema.String), + customAnswer: Schema.optional(Schema.Boolean), }), ), multiSelect: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 84742a2ad883b..91deccb950838 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -469,6 +469,7 @@ const UserInputQuestionOption = Schema.Struct({ label: TrimmedNonEmptyStringSchema, description: Schema.String, value: Schema.optional(Schema.String), + customAnswer: Schema.optional(Schema.Boolean), }); export type UserInputQuestionOption = typeof UserInputQuestionOption.Type;