Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3d7ab15
fix(web): stop pending questions overflowing the header row
bmdavis419 Oct 21, 2026
d7d4a23
feat(web): clearer pending question hierarchy with keycap selection
bmdavis419 Oct 21, 2026
3339104
fix(web): center pending question content and drop generic header
bmdavis419 Oct 21, 2026
6e55d6f
fix(web): pending questions lead with their content
bmdavis419 Oct 21, 2026
e72ff0c
fix(web): pending questions get a title slot and calmer selection
bmdavis419 Oct 21, 2026
e75d268
fix(web): pending questions become a question-first menu
bmdavis419 Oct 22, 2026
9b73683
fix(web): question count spans simultaneous question requests
bmdavis419 Oct 22, 2026
9eadfc6
fix(web): a little more room above expanded questions
bmdavis419 Oct 22, 2026
5f3b755
fix(web): ground the question header with a subtle band
bmdavis419 Oct 22, 2026
74c0658
test(web): render pending question panel with the disposable render h…
bmdavis419 Oct 22, 2026
3507f16
feat(contracts): mark custom-answer question options
bmdavis419 Oct 22, 2026
071b968
fix(web): render custom-answer options as downward hints
bmdavis419 Oct 22, 2026
6864eab
test(web): follow browser-test setup conventions in question panel tests
bmdavis419 Oct 22, 2026
7d4a0aa
fix(mobile): keep mobile question queue positions stable
bmdavis419 Oct 24, 2026
62982db
perf(client-runtime): skip settled question history when nothing is p…
bmdavis419 Oct 24, 2026
6c45576
test(client-runtime): add required options to queue position fixture
bmdavis419 Oct 24, 2026
4c5137d
fix(server): preserve customAnswer in Claude question options
bmdavis419 Oct 24, 2026
57dc788
fix(client-runtime): order pending questions by creation time
bmdavis419 Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 96 additions & 25 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -201,10 +205,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
/>
</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
Expand All @@ -216,16 +221,33 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
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">
<Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">
{question.header}
</Text>
<QuestionText markdown={question.question} />
<View className="gap-2">
{question.options.map((option) => {
<View className="-mx-4 gap-1.5 bg-foreground/3 px-4 py-2">
{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) => {
const optionValue = option.value ?? option.label.trim();
const selected = isPendingUserInputOptionSelected(question, draft, optionValue);
const description =
Expand All @@ -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(
Expand All @@ -248,24 +270,73 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
)
}
>
<View className="min-w-0 flex-1 gap-0.5">
<Text
className={cn(
"font-t3-bold text-sm",
selected ? "text-foreground" : "text-foreground-secondary",
)}
>
{/* 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>
{description ? (
<Text className="font-sans text-sm leading-5 text-foreground-muted">
{description}
</Text>
) : null}
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
className="h-[19px] w-4 items-center justify-center"
>
{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"
className="h-[19px] w-4 items-center justify-center"
>
<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>
);
})}
</View>
{question.allowCustomAnswer !== false ? (
<QuestionAttachments
Expand Down
34 changes: 34 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1413,6 +1413,40 @@ describe("provider question values", () => {
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(
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3930,6 +3930,7 @@ export function claudeUserInputQuestions(
...(typeof optionRecord.preview === "string"
? { preview: optionRecord.preview }
: {}),
...(optionRecord.customAnswer === true ? { customAnswer: true } : {}),
},
];
})
Expand Down
Loading
Loading