mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
* 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>
151 lines
4.5 KiB
TypeScript
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;
|
|
}
|