Files
paseo/packages/app/src/components/question-form-card-core.ts
T
paseo-bot[bot]andGabriel Giordano 019e5279e9 Show Claude option previews in the question card, readable on Android (#6171)
* Show Claude option previews in the question card

* Wrap long lines in question option previews

* Check option previews under the option that owns them

* Show the whole option preview on native so every line is reachable

On Android, a slow drag inside a preview capped at 240px did not scroll
it, so lines past the cap could not be read. The preview's nested
scroll sits inside the option's Pressable; the same drag scrolls it
once the preview is outside the Pressable. Native now renders the
preview at full height so the chat scroll reaches every line, and a tap
on it still selects the option. Web keeps the 240px cap.

The mock agent's rollout preview is now taller than the cap so the
Android replay can check the last line.

---------

Co-authored-by: Gabriel Giordano <hello@gabrielgiordano.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 07:13:06 +02:00

151 lines
4.5 KiB
TypeScript

export interface QuestionOption {
label: string;
description?: string;
preview?: string;
}
export interface QuestionFormQuestion {
question: string;
header: string;
options: QuestionOption[];
multiSelect: boolean;
allowOther: boolean;
allowEmpty: boolean;
placeholder?: string;
dismissLabel?: string;
}
export type QuestionSelections = Record<number, ReadonlySet<number>>;
export type QuestionOtherTexts = Record<number, string>;
function readOptionalString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion[] | null {
if (
typeof input !== "object" ||
input === null ||
!("questions" in input) ||
!Array.isArray((input as Record<string, unknown>).questions)
) {
return null;
}
const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: QuestionFormQuestion[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return null;
const q = item as Record<string, unknown>;
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
if (!Array.isArray(q.options)) return null;
const options: QuestionOption[] = [];
for (const opt of q.options as unknown[]) {
if (typeof opt !== "object" || opt === null) return null;
const o = opt as Record<string, unknown>;
if (typeof o.label !== "string") return null;
options.push({
label: o.label,
description: typeof o.description === "string" ? o.description : undefined,
preview: readOptionalString(o, "preview"),
});
}
questions.push({
question: q.question,
header: q.header,
options,
multiSelect: q.multiSelect === true,
allowOther: q.allowOther === true || q.isOther === true,
allowEmpty: q.allowEmpty === true,
placeholder: readOptionalString(q, "placeholder"),
dismissLabel: readOptionalString(q, "dismissLabel"),
});
}
return questions.length > 0 ? questions : null;
}
export function questionShowsTextInput(question: QuestionFormQuestion): boolean {
return question.options.length === 0 || question.allowOther;
}
export function isQuestionAnswered(
question: QuestionFormQuestion,
qIndex: number,
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): boolean {
const selected = selections[qIndex];
if (selected && selected.size > 0) {
return true;
}
if (!questionShowsTextInput(question)) {
return false;
}
const otherText = otherTexts[qIndex]?.trim();
if (otherText && otherText.length > 0) {
return true;
}
return question.allowEmpty;
}
export function areQuestionsAnswered(
questions: QuestionFormQuestion[] | null,
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): boolean {
return (
questions?.every((question, qIndex) =>
isQuestionAnswered(question, qIndex, selections, otherTexts),
) ?? false
);
}
export function buildQuestionFormAnswers(
questions: QuestionFormQuestion[],
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): Record<string, string> {
const answers: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
// Multi-select keeps the checked options and appends the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
continue;
}
if (q.allowEmpty && q.options.length === 0) {
answers[q.header] = "";
continue;
}
}
if (labels.length > 0) {
answers[q.header] = labels.join(", ");
}
}
return answers;
}
export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): boolean {
return (
questions.length > 0 &&
questions.every((question) => question.allowEmpty && question.options.length === 0)
);
}
export function resolveDismissLabel(
questions: QuestionFormQuestion[],
fallbackLabel = "Dismiss",
): string {
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? fallbackLabel;
}