Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/pr-assets/7803-after-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions .github/pr-assets/7803-after-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions .github/pr-assets/7803-before-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions .github/pr-assets/7803-before-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 33 additions & 1 deletion apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200);

export function PendingUserInputCard(props: PendingUserInputCardProps) {
const iconSubtle = useThemeColor("--color-icon-subtle");
const checkboxTint = useThemeColor("--color-primary-foreground");
const questionCount = props.pendingUserInput.questions.length;

const cardCoverage = props.cardCoverage;
Expand Down Expand Up @@ -263,8 +264,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
return (
<Pressable
key={option.label}
// Only multi-select options toggle independently, so only
// they read as checkboxes; single-select pills keep their
// plain button semantics.
{...(question.multiSelect
? {
accessibilityRole: "checkbox" as const,
accessibilityState: { checked: selected },
}
: {})}
className={cn(
"rounded-full border px-3 py-2.5 ",
"flex-row items-center gap-2 rounded-full border px-3 py-2.5 ",
selected
? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"
: "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70",
Expand All @@ -277,6 +287,28 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
)
}
>
{/* A pill that can stay lit alongside its neighbours needs
to say so before it is tapped: the box marks the answer
as additive rather than exclusive. */}
{question.multiSelect ? (
<View
className={cn(
"h-4 w-4 items-center justify-center rounded-[4px] border",
selected
? "border-sky-600 bg-sky-600 dark:border-blue-400 dark:bg-blue-400"
: "border-neutral-300 dark:border-white/20",
)}
>
{selected ? (
<SymbolView
name="checkmark"
size={10}
tintColor={checkboxTint}
type="monochrome"
/>
) : null}
</View>
) : null}
<Text
className={cn(
"font-t3-bold text-sm",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,32 @@ const prompt: PendingUserInput = {
],
};

function renderPanel() {
const multiSelectPrompt: PendingUserInput = {
requestId: ApprovalRequestId.make("request-2"),
createdAt: "2026-08-15T00:00:00.000Z",
questions: [
{
id: "question-2",
header: "Surfaces",
question: "Which surfaces should ship the change?",
options: [
{ label: "Web", description: "The browser client" },
{ label: "Mobile", description: "The React Native client" },
],
multiSelect: true,
},
],
};

function renderPanel(
input: PendingUserInput = prompt,
answers: Record<string, { selectedOptionLabels?: string[]; customAnswer?: string }> = {},
) {
return renderToStaticMarkup(
<ComposerPendingUserInputPanel
pendingUserInputs={[prompt]}
pendingUserInputs={[input]}
respondingRequestIds={[]}
answers={{}}
answers={answers}
questionIndex={0}
onToggleOption={() => {}}
onAdvance={() => {}}
Expand Down Expand Up @@ -58,4 +78,26 @@ describe("ComposerPendingUserInputPanel", () => {
expect(markup).toContain("Incremental");
expect(markup).toContain("Big bang");
});

it("announces multi-select options as checkboxes and keeps their shortcuts", () => {
const markup = renderPanel(multiSelectPrompt, {
"question-2": { selectedOptionLabels: ["Web"] },
});

const optionButtons = markup.match(/<button[^>]*role="checkbox"[^>]*>/g) ?? [];
expect(optionButtons).toHaveLength(2);
expect(optionButtons[0]).toContain('aria-checked="true"');
expect(optionButtons[1]).toContain('aria-checked="false"');
// The trailing number shortcut survives selection, so every option stays
// reachable from the keyboard while the answer is being assembled.
expect(markup).toContain("<kbd");
expect(markup).toContain("Select one or more options.");
});

it("leaves single-select options without checkbox semantics", () => {
const markup = renderPanel(prompt, { "question-1": { selectedOptionLabels: ["Incremental"] } });

expect(markup).not.toContain('role="checkbox"');
expect(markup).not.toContain("Select one or more options.");
});
});
37 changes: 36 additions & 1 deletion apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,42 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
);
const content = (
<>
{/* Multi-select is the only mode where more than one option
can stay lit, so it is the only one that gets a box: a
leading checkbox reads as "toggle me", where the trailing
check of a single-select reads as "answered". */}
{activeQuestion.multiSelect ? (
<span
aria-hidden="true"
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-[.25rem] border transition-colors duration-150",
isSelected
? "border-primary bg-primary text-primary-foreground"
: // An empty box has no content to carry it, so it
// borrows the shortcut key's ink rather than the
// hairline border token, which fades to ~4% white in
// dark mode and disappears.
"border-secondary-label/70 bg-background/35 group-hover:border-foreground",
)}
>
{/* Mounted only when checked, like the shared checkbox and
the mobile pill: a glyph hidden by `text-transparent`
comes back in forced-colors mode, where the OS
overrides both the text and the background, and every
empty box would then read as selected. */}
{isSelected ? <CheckIcon className="size-3" strokeWidth={3} /> : null}
</span>
) : null}
<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-xs">{option.description}</span>
) : null}
</div>
{isSelected ? (
{/* Multi-select keeps every number shortcut on screen while
options are being picked: the checkbox already carries the
state, and the keys stay live for the whole list. */}
{isSelected && !activeQuestion.multiSelect ? (
<CheckIcon className="size-3.5 shrink-0 text-primary" />
) : shortcutKey !== null ? (
<kbd
Expand All @@ -266,6 +295,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
<button
key={`${activeQuestion.id}:${option.label}`}
type="button"
// Announced as a checkbox only when it behaves like one:
// multi-select options toggle independently, single-select
// options replace each other.
{...(activeQuestion.multiSelect
? { role: "checkbox", "aria-checked": isSelected }
: {})}
disabled={isResponding}
onClick={() => {
handleOptionSelection(activeQuestion.id, option.label);
Expand Down
Loading