# Files changed · PR \#23855 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23855/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): pending questions read as a question-first menu](https://git.cafe/pingdotgg/t3code/pull/23855.md)

Version: 14

Head commit: a39c1c2eae37b5a439de0a44f53ae4d9005624fd

Base commit: 45cedfe23e5b3bc513eb32c4c8f5ac7ab2453de9

Comparison base: 45cedfe23e5b3bc513eb32c4c8f5ac7ab2453de9

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/threads/PendingUserInputCard.tsx

Status: modified; +96; −25

```diff
@@ -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, {
@@ -79,7 +82,8 @@
 }
 
 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;
@@ -221,10 +225,11 @@
           />
         </View>
       </Pressable>
+      {/* Spans the card's full width so question bands can reach its edges; content keeps the card's 16px inset. */}
       <ScrollView
         bounces={false}
-        className="min-h-0"
-        contentContainerClassName="gap-2.5 pb-1"
+        className="-mx-4 min-h-0"
+        contentContainerClassName="gap-2.5 px-4 pb-1"
         keyboardShouldPersistTaps="handled"
         nestedScrollEnabled
         showsVerticalScrollIndicator
@@ -236,16 +241,33 @@
             run to continue.
           </Text>
         ) : 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 (
             <View key={question.id} className="gap-2 pt-1">
+              <View className="-mx-4 gap-1.5 bg-foreground/3 px-4 py-2">
-              <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">
+                {eyebrow ? (
+                  <Text className="font-t3-medium text-2xs text-foreground-muted">{eyebrow}</Text>
+                ) : null}
+                <QuestionText markdown={question.question} />
+              </View>
+              <View className="gap-0.5">
+                {selectableOptions.map((option) => {
-                {question.header}
-              </Text>
-              <QuestionText markdown={question.question} />
-              <View className="gap-2">
-                {question.options.map((option) => {
                   const optionValue = option.value ?? option.label.trim();
                   const selected = isPendingUserInputOptionSelected(question, draft, optionValue);
                   const description =
@@ -257,8 +279,8 @@
                       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(
@@ -268,22 +290,71 @@
                         )
                       }
                     >
+                      {/* The check shares the label's first line and keeps its box when unselected, so selecting never rewraps the label. */}
+                      <View className="flex-row items-start gap-3">
+                        <Text className="min-w-0 flex-1 font-t3-medium text-sm text-foreground">
+                          {option.label}
+                        </Text>
+                        <View
-                      <View className="min-w-0 flex-1 gap-0.5">
+                          accessibilityElementsHidden
+                          importantForAccessibility="no-hide-descendants"
+                          className="h-[19px] w-4 items-center justify-center"
-                        <Text
-                          className={cn(
-                            "font-t3-bold text-sm",
-                            selected ? "text-foreground" : "text-foreground-secondary",
-                          )}
                         >
+                          {selected ? (
+                            <SymbolView
+                              name="checkmark"
+                              size={16}
+                              tintColorClassName="accent-primary-text"
+                              type="monochrome"
+                              weight="bold"
+                            />
+                          ) : null}
+                        </View>
+                      </View>
+                      {description ? (
+                        <Text className="pr-7 font-sans text-xs text-foreground-muted">
+                          {description}
+                        </Text>
+                      ) : null}
+                    </Pressable>
+                  );
+                })}
+                {customAnswerOptions.map((option) => {
+                  const optionValue = option.value ?? option.label.trim();
+                  const description =
+                    option.description !== option.label ? option.description : undefined;
+                  return (
+                    <View
+                      key={`custom-answer:${optionValue}`}
+                      className={cn(
+                        "min-h-12 w-full justify-center gap-0.5 rounded-xl px-2 py-2",
+                        responseDisabled && "opacity-50",
+                      )}
+                    >
+                      <View className="flex-row items-start gap-3">
+                        <Text className="min-w-0 flex-1 font-t3-medium text-sm text-foreground">
                           {option.label}
                         </Text>
+                        <View
+                          accessibilityElementsHidden
+                          importantForAccessibility="no-hide-descendants"
-                        {description ? (
-                          <Text className="font-sans text-sm leading-5 text-foreground-muted">
+                          className="h-[19px] w-4 items-center justify-center"
+                        >
-                            {description}
-                          </Text>
-                        ) : null}
+                          <SymbolView
+                            name="arrow.down"
+                            size={16}
+                            tintColorClassName="accent-foreground-muted"
+                            type="monochrome"
+                            weight="bold"
+                          />
+                        </View>
                       </View>
+                      {description ? (
+                        <Text className="pr-7 font-sans text-xs text-foreground-muted">
+                          {description}
+                        </Text>
+                      ) : null}
+                    </View>
-                    </Pressable>
                   );
                 })}
               </View>
```

## apps/mobile/src/lib/threadActivity.test.ts

Status: modified; +34; −0

```diff
@@ -1247,6 +1247,40 @@
     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(
```

## apps/mobile/src/lib/threadActivity.ts

Status: modified; +3; −2

```diff
@@ -379,13 +379,14 @@
   question: ThreadUserInputQuestion,
   value: string,
 ): string | null {
+  const selectableOptions = question.options.filter((option) => option.customAnswer !== true);
+  if (selectableOptions.some((option) => option.value === value)) {
-  if (question.options.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;
 }
```

## apps/server/src/mcp/toolkits/thread/tools.ts

Status: modified; +1; −0

```diff
@@ -137,6 +137,7 @@
       label: Schema.String,
       description: Schema.String,
       value: Schema.optional(Schema.String),
+      customAnswer: Schema.optional(Schema.Boolean),
     }),
   ),
   multiSelect: Schema.optional(Schema.Boolean),
```

## apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts

Status: modified; +10; −0

```diff
@@ -282,6 +282,11 @@
             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,
           },
@@ -295,6 +300,11 @@
           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,
         },
```

## apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts

Status: modified; +1; −0

```diff
@@ -3691,6 +3691,7 @@
               ...(typeof optionRecord.preview === "string"
                 ? { preview: optionRecord.preview }
                 : {}),
+              ...(optionRecord.customAnswer === true ? { customAnswer: true } : {}),
             },
           ];
         })
```

## apps/web/src/components/chat/ComposerPendingUserInputPanel.layout.browser.test.tsx

Status: added; +390; −0

```diff
@@ -1,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 }) => (
+    <p className={className}>{text}</p>
+  )) 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<string, PendingUserInputDraftAnswer>) => Promise<void>,
+  ) => Promise<void>,
+) {
+  const container = document.createElement("div");
+  container.style.width = `${width}px`;
+  const panel = (answers: Record<string, PendingUserInputDraftAnswer>) => (
+    <ComposerBanner.Root>
+      <ComposerPendingUserInputPanel
+        pendingUserInputs={[
+          {
+            requestId: RuntimeRequestId.make("long-header"),
+            createdAt: "2026-10-20T00:00:00.000Z",
+            dismissible: true,
+            responseCapability: "live",
+            ...(queue ? { queueQuestionIndex: queue.index, queueQuestionTotal: queue.total } : {}),
+            questions: Array.from({ length: questionCount }, (_, index) => ({
+              id: index === 0 ? "strategy" : `strategy-${index}`,
+              header,
+              question,
+              options,
+              multiSelect,
+            })),
+          },
+        ]}
+        cwd="/repo"
+        threadRef={threadRef}
+        respondingRequestIds={[]}
+        answers={answers}
+        questionIndex={0}
+        onToggleOption={() => {}}
+        onAdvance={() => {}}
+        onDismiss={() => {}}
+      />
+    </ComposerBanner.Root>
+  );
+  // 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<HTMLElement>("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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>(
+        "[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));
+    },
+  );
+});
```

## apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx

Status: modified; +161; −3

```diff
@@ -6,9 +6,15 @@
   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 }) => <p>{text}</p>) as never,
@@ -16,11 +22,12 @@
 
 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 @@
   })),
 };
 
+function Questions({ prompt }: { prompt: PendingUserInput }) {
+  const [questionIndex, setQuestionIndex] = useState(0);
+  const [answers, setAnswers] = useState<Record<string, PendingUserInputDraftAnswer>>({});
+  return (
+    <>
+      <output>{questionIndex}</output>
+      <ComposerPendingUserInputPanel
+        pendingUserInputs={[prompt]}
+        cwd="/repo"
+        threadRef={threadRef}
+        disabled={false}
+        respondingRequestIds={[]}
+        answers={answers}
+        questionIndex={questionIndex}
+        onAdvance={() => 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(<Questions prompt={choicePrompt} />);
+  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(<Questions prompt={prompt} />);
+  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<HTMLElement>(
+    "[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 @@
       updatedAt: projection.updatedAt,
       type: "user_input_request" as const,
       requestId: RuntimeRequestId.make(id),
-      questions: prompt.questions,
+      questions: defaultPrompt.questions,
     })),
   } satisfies OrchestrationV2ThreadProjection;
 }
```

## apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx

Status: modified; +170; −69

```diff
@@ -1,3 +1,4 @@
+import { isGenericUserInputQuestionHeader } from "@t3tools/client-runtime/state/thread-requests";
 import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts";
 import { memo, useEffect, useEffectEvent, useRef, useState } from "react";
 import { useStableCallback } from "~/hooks/useStableCallback";
@@ -6,7 +7,7 @@
   derivePendingUserInputProgress,
   type PendingUserInputDraftAnswer,
 } from "../../pendingUserInput";
-import { CheckIcon } from "lucide-react";
+import { ArrowDownIcon, CheckIcon } from "lucide-react";
 import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
 import { cn } from "~/lib/utils";
 import { ComposerBanner } from "./ComposerBanner";
@@ -129,6 +130,13 @@
 
   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 +170,12 @@
       }
       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 +187,27 @@
   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 ? (
+      <span className="text-xs/4 text-muted-foreground/70">Select any</span>
+    ) : null;
 
   return (
     <Collapsible
@@ -184,30 +216,37 @@
         setCollapsedQuestionId(open ? null : activeQuestion.id);
       }}
     >
+      {/* 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. */}
       <CollapsibleTrigger
         render={<ComposerBanner.Row render={<button type="button" />} />}
+        className={cn(
+          !isCollapsed &&
-        title={
-          isCollapsed ? "Show the question and its options" : "Hide the question and its options"
-        }
+            "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"}
       >
-        <ComposerBanner.Icon />
-        <ComposerBanner.Content>
-          <span className="shrink-0 font-medium text-muted-foreground">
-            {activeQuestion.header}
-          </span>
-          {isCollapsed ? (
-            <span className="min-w-0 flex-1 truncate text-secondary-label">
-              {activeQuestion.question}
-            </span>
-          ) : null}
-        </ComposerBanner.Content>
+        {isCollapsed ? (
+          <>
+            <ComposerBanner.Icon />
+            <ComposerBanner.Content>
+              {showHeader ? (
+                <span className="min-w-0 truncate font-medium text-muted-foreground">
+                  {activeQuestion.header}
+                </span>
-        <ComposerBanner.Actions>
-          {prompt.questions.length > 1 ? (
-            <span className="text-3xs font-medium text-muted-foreground tabular-nums">
+              ) : null}
-              {questionIndex + 1}/{prompt.questions.length}
-            </span>
-          ) : null}
+              <span className="min-w-0 flex-1 truncate text-secondary-label">
+                {activeQuestion.question}
+              </span>
+            </ComposerBanner.Content>
+          </>
+        ) : null}
+        {/* The end margin puts the trailing control's center 22px in, on the option indicators' axis. */}
+        <ComposerBanner.Actions className="me-1 sm:me-1.5">
+          {actionsHint}
+          {questionCount ? <span className={questionCountClassName}>{questionCount}</span> : null}
           <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
           {prompt.dismissible ? (
             <ComposerBanner.Dismiss
@@ -231,70 +270,132 @@
           ) : null}
         </ComposerBanner.Actions>
       </CollapsibleTrigger>
+      {/* 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. */}
+      <CollapsiblePanel className="-mx-1">
+        <ComposerBanner.Scroll className="[&>[data-slot=scroll-area-scrollbar][data-orientation=vertical]]:mt-3 [&>[data-slot=scroll-area-scrollbar][data-orientation=vertical]]:mr-[5px]">
+          {/* Both sides sit 16px in from the surface; the overflow scrollbar gutter takes 8px of the right. */}
+          <ComposerBanner.Body className="ps-4 pe-4 pb-1 wrap-anywhere sm:ps-4 [[data-has-overflow-y]>*>&]:pe-2">
+            {/* 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. */}
+            <div
+              data-pending-user-input-header-band
+              className={cn(
+                "-mx-4 rounded-t-2xl bg-foreground/3 px-4 pb-2.5 [[data-slot=composer-banner-attachment]+[data-slot=composer-banner-attachment]_&]:rounded-t-none",
+                showHeader ? "pt-3.5 sm:pt-3" : "pt-3 sm:pt-2.5",
+              )}
+            >
+              {/* 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. */}
+              <span
+                aria-hidden
+                className={cn(
+                  "invisible float-end ms-2 -me-3 flex items-center gap-1 pe-1 sm:pe-1.5",
+                  showHeader ? "h-4" : "h-5",
+                )}
+              >
+                {actionsHint}
+                {questionCount ? (
+                  <span className={questionCountClassName}>{questionCount}</span>
+                ) : null}
+                <span className="w-(--composer-banner-icon-column)" />
+                {prompt.dismissible ? <span className="w-(--composer-banner-icon-column)" /> : null}
+              </span>
+              {showHeader ? (
+                <p className="text-xs/4 font-medium text-muted-foreground">
+                  {activeQuestion.multiSelect
+                    ? `${activeQuestion.header} · Select any`
-      <CollapsiblePanel>
-        <ComposerBanner.Scroll>
-          <ComposerBanner.Body className="pe-1 pb-1 wrap-anywhere">
-            <ChatMarkdown
-              text={activeQuestion.question}
-              cwd={cwd}
-              threadRef={threadRef}
+                    : activeQuestion.header}
-              className="text-foreground/85"
-            />
-            {activeQuestion.multiSelect ? (
-              <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
-            ) : null}
-            <div className="mt-2 space-y-0.5">
-              {activeQuestion.options.map((option, index) => {
+                </p>
+              ) : null}
+              <ChatMarkdown
+                text={activeQuestion.question}
+                cwd={cwd}
+                threadRef={threadRef}
+                className={cn("text-sm/5 font-medium text-foreground", showHeader && "mt-1.5")}
+              />
+            </div>
+            {/* Row fills hang 12px past the text column so labels stay on its edge. */}
+            <div className="-mx-3 mt-1.5">
+              {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 shortcutKey = index < 9 ? index + 1 : null;
-                const className = cn(
-                  "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25",
-                  isSelected
-                    ? "bg-muted/55 text-foreground"
-                    : "bg-transparent text-foreground/85 hover:bg-muted/30",
-                  (disabled || isResponding) && "opacity-50 cursor-not-allowed",
-                  !disabled && !isResponding && "cursor-pointer",
-                );
-                const content = (
-                  <>
-                    <div className="min-w-0 flex-1 flex flex-col gap-0.5">
-                      <span className="text-sm font-medium">{option.label}</span>
-                      {option.description && option.description !== option.label ? (
-                        <span className="text-secondary-label text-2xs">{option.description}</span>
-                      ) : null}
-                    </div>
-                    {isSelected ? (
-                      <CheckIcon className="size-3.5 shrink-0 text-primary" />
-                    ) : shortcutKey !== null ? (
-                      <kbd
-                        className={cn(
-                          "flex size-5 shrink-0 items-center justify-center text-3xs font-medium text-muted-foreground tabular-nums",
-                        )}
-                      >
-                        {shortcutKey}
-                      </kbd>
-                    ) : null}
-                  </>
-                );
                 return (
                   <button
                     key={`${activeQuestion.id}:${optionValue}`}
                     type="button"
+                    aria-pressed={isSelected}
                     disabled={disabled || isResponding}
                     onClick={() => {
                       handleOptionSelection(activeQuestion.id, optionValue);
                     }}
+                    className={cn(
+                      "grid w-full grid-cols-[min(40%,16rem)_minmax(0,1fr)_--spacing(5)] items-baseline gap-x-4 rounded-lg ps-3 pe-2 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/40",
-                    className={className}
+                      "@max-[480px]:grid-cols-[minmax(0,1fr)_--spacing(5)] @max-[480px]:gap-y-0.5",
+                      isSelected ? "bg-foreground/8" : "bg-transparent hover:bg-foreground/4",
+                      (disabled || isResponding) && "opacity-50 cursor-not-allowed",
+                      !disabled && !isResponding && "cursor-pointer",
+                    )}
                   >
+                    <span
+                      className={cn(
+                        "col-start-1 row-start-1 text-sm/5",
+                        isSelected ? "text-foreground" : "text-foreground/90",
+                      )}
+                    >
+                      {option.label}
+                    </span>
+                    {/* Stays as an empty cell without a description so the columns never shift.
+                        Baseline alignment drops 12px text a pixel below the 14px label's box; the margin keeps rows 36px. */}
+                    <span className="col-start-2 row-start-1 -mb-px text-xs/5 text-muted-foreground @max-[480px]:col-start-1 @max-[480px]:row-start-2 @max-[480px]:mb-0 @max-[480px]:text-xs/4 @max-[480px]:empty:hidden">
+                      {option.description && option.description !== option.label
+                        ? option.description
+                        : null}
+                    </span>
+                    {/* The number and the check share one fixed box so selecting never resizes the row. */}
+                    <span
+                      data-pending-user-input-option-key
+                      className="col-start-3 row-start-1 flex size-5 items-center justify-center self-start text-xs/5 font-medium text-muted-foreground/60 tabular-nums @max-[480px]:col-start-2"
+                    >
+                      {isSelected ? (
+                        <CheckIcon aria-hidden className="size-4 text-primary" strokeWidth={2.5} />
+                      ) : selectableIndex < 9 ? (
+                        selectableIndex + 1
-                    {content}
+                      ) : null}
+                    </span>
                   </button>
                 );
               })}
+              {customAnswerOptions.map((option) => {
+                const optionValue = option.value ?? option.label;
+                return (
+                  <div
+                    key={`${activeQuestion.id}:custom-answer:${optionValue}`}
+                    data-pending-user-input-custom-answer-option
+                    className={cn(
+                      "grid w-full grid-cols-[min(40%,16rem)_minmax(0,1fr)_--spacing(5)] items-baseline gap-x-4 rounded-lg ps-3 pe-2 py-2 text-left",
+                      "@max-[480px]:grid-cols-[minmax(0,1fr)_--spacing(5)] @max-[480px]:gap-y-0.5",
+                      (disabled || isResponding) && "opacity-50",
+                    )}
+                  >
+                    <span className="col-start-1 row-start-1 text-sm/5 text-foreground/90">
+                      {option.label}
+                    </span>
+                    <span className="col-start-2 row-start-1 -mb-px text-xs/5 text-muted-foreground @max-[480px]:col-start-1 @max-[480px]:row-start-2 @max-[480px]:mb-0 @max-[480px]:text-xs/4 @max-[480px]:empty:hidden">
+                      {option.description && option.description !== option.label
+                        ? option.description
+                        : null}
+                    </span>
+                    <span className="col-start-3 row-start-1 flex size-5 items-center justify-center self-start text-muted-foreground/60 @max-[480px]:col-start-2">
+                      <ArrowDownIcon aria-hidden className="size-4" strokeWidth={2.5} />
+                    </span>
+                  </div>
+                );
+              })}
             </div>
           </ComposerBanner.Body>
         </ComposerBanner.Scroll>
```

## apps/web/src/pendingUserInput.test.ts

Status: modified; +42; −0

```diff
@@ -134,9 +134,51 @@
       }),
     ).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");
```

## apps/web/src/pendingUserInput.ts

Status: modified; +11; −1

```diff
@@ -80,7 +80,10 @@
   }
 
   const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter(
+    (value) =>
+      question.options.some(
-    (value) => question.options.some((option) => (option.value ?? option.label) === value),
+        (option) => option.customAnswer !== true && (option.value ?? option.label) === value,
+      ),
   );
   if (question.multiSelect) {
     return selectedOptionValues.length > 0
@@ -162,6 +165,13 @@
   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)
```

## packages/client-runtime/src/state/threadDetail.test.ts

Status: modified; +59; −0

```diff
@@ -2,7 +2,9 @@
   EnvironmentId,
   EventId,
   MessageId,
+  NodeId,
   RunId,
+  RuntimeRequestId,
   TurnItemId,
   type OrchestrationV2ThreadProjection,
   type OrchestrationV2TurnItem,
@@ -313,3 +315,60 @@
     [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();
+  });
+});
```

## packages/client-runtime/src/state/threadDetail.ts

Status: modified; +6; −1

```diff
@@ -379,8 +379,13 @@
         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" || (hasPendingUserInput && request.kind === "user_input"),
-        (request) => request.status === "pending",
       );
       const ids = new Set(runtimeRequests.map((request) => request.id));
       const turnItems =
```

## packages/client-runtime/src/state/threadRequests.test.ts

Status: modified; +131; −0

```diff
@@ -5,6 +5,7 @@
   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 @@
         responseCapability: "message",
         responseMode: "message",
         dismissible: true,
+        queueQuestionIndex: 0,
+        queueQuestionTotal: 1,
         questions: [
           {
             id: "next",
@@ -236,6 +239,134 @@
   });
 });
 
+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]!;
```

## packages/client-runtime/src/state/threadRequests.ts

Status: modified; +89; −5

```diff
@@ -79,8 +79,16 @@
   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 {
   readonly approvals: ReadonlyArray<ThreadPendingApproval>;
   readonly userInputs: ReadonlyArray<ThreadPendingUserInput>;
@@ -111,16 +119,23 @@
 ): 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,
-      );
+      const item = userInputItems?.get(request.id);
+      if (item === undefined) continue;
+      const position = queuePositions?.get(request.id);
-      if (item === undefined || item.type !== "user_input_request") continue;
       userInputs.push({
         requestId: request.id,
         createdAt: DateTime.formatIso(request.createdAt),
@@ -133,6 +148,9 @@
         ...(item.responseMode === "message" || responseCapability === "message"
           ? { responseMode: "message" as const }
           : {}),
+        ...(position
+          ? { queueQuestionIndex: position.index, queueQuestionTotal: position.total }
+          : {}),
       });
       continue;
     }
@@ -161,9 +179,75 @@
     });
   }
 
+  // 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<RuntimeRequestId, UserInputRequestItem>();
+  // 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<RuntimeRequestId, UserInputRequestItem>,
+) {
+  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<RuntimeRequestId, { index: number; total: number }>();
+  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],
```

## packages/contracts/src/orchestrationV2.ts

Status: modified; +1; −0

```diff
@@ -1143,6 +1143,7 @@
       description: TrimmedNonEmptyString,
       value: Schema.optional(Schema.String),
       preview: Schema.optional(Schema.String),
+      customAnswer: Schema.optional(Schema.Boolean),
     }),
   ),
   multiSelect: Schema.optional(Schema.Boolean),
```

## packages/contracts/src/providerRuntime.ts

Status: modified; +1; −0

```diff
@@ -472,6 +472,7 @@
   label: TrimmedNonEmptyStringSchema,
   description: Schema.String,
   value: Schema.optional(Schema.String),
+  customAnswer: Schema.optional(Schema.Boolean),
 });
 export type UserInputQuestionOption = typeof UserInputQuestionOption.Type;
 
```
