Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
<Text className="font-sans text-base leading-snug text-foreground">
{question.question}
</Text>
{question.multiSelect ? (
<Text className="font-sans text-xs text-foreground-muted">
{question.minItems !== undefined && question.maxItems !== undefined
? `Select between ${question.minItems} and ${question.maxItems} options.`
: question.maxItems !== undefined
? `Select up to ${question.maxItems} option${question.maxItems === 1 ? "" : "s"}.`
: question.minItems !== undefined
? `Select at least ${question.minItems} option${question.minItems === 1 ? "" : "s"}.`
: "Select one or more options."}
</Text>
) : null}
<View className="gap-2">
{question.options.map((option) => {
const optionValue = option.value ?? option.label.trim();
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/QuestionAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages";
import { useThreadSelection } from "../../state/use-thread-selection";
import { useNavigation } from "@react-navigation/native";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import { pendingUserInputKeyboardType } from "./pendingUserInputLayout";
import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
import { useServerConfigs } from "../../state/entities";
import { appAtomRegistry } from "../../state/atom-registry";
Expand Down Expand Up @@ -193,6 +194,7 @@ export function QuestionAttachments(props: {
onChangeText={props.onChangeText}
onFocus={() => props.onInputFocusChange?.(true)}
onBlur={() => props.onInputFocusChange?.(false)}
keyboardType={pendingUserInputKeyboardType(props.question)}
placeholder="Or type a custom answer"
className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground"
/>
Expand Down
34 changes: 33 additions & 1 deletion apps/mobile/src/features/threads/pendingUserInputLayout.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,38 @@
import { describe, expect, it } from "vite-plus/test";

import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout";
import {
derivePendingUserInputMaxHeight,
pendingUserInputKeyboardType,
} from "./pendingUserInputLayout";

describe("pendingUserInputKeyboardType", () => {
it("keeps the number-pad for nonnegative integer questions", () => {
expect(pendingUserInputKeyboardType({ valueType: "integer", minimum: 0 })).toBe("number-pad");
expect(pendingUserInputKeyboardType({ valueType: "integer", minimum: 1024 })).toBe(
"number-pad",
);
});

it("uses a punctuation layout when integers can be negative", () => {
expect(pendingUserInputKeyboardType({ valueType: "integer" })).toBe("numbers-and-punctuation");
expect(pendingUserInputKeyboardType({ valueType: "integer", minimum: -10 })).toBe(
"numbers-and-punctuation",
);
});

it("keeps the decimal-pad only for nonnegative number questions", () => {
expect(pendingUserInputKeyboardType({ valueType: "number", minimum: 0 })).toBe("decimal-pad");
expect(pendingUserInputKeyboardType({ valueType: "number" })).toBe("numbers-and-punctuation");
expect(pendingUserInputKeyboardType({ valueType: "number", minimum: -1.5 })).toBe(
"numbers-and-punctuation",
);
});

it("preserves the default layout for everything else", () => {
expect(pendingUserInputKeyboardType({ valueType: "string" })).toBe("default");
expect(pendingUserInputKeyboardType({})).toBe("default");
});
});

describe("derivePendingUserInputMaxHeight", () => {
it("caps a tall portrait viewport", () => {
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/features/threads/pendingUserInputLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,22 @@ export function derivePendingUserInputMaxHeight(input: {
Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight),
);
}

/**
* The iOS number-pad and decimal-pad have no minus key, so negative-capable numeric questions
* get the numbers-and-punctuation layout; Android resolves unknown layouts to the default
* keyboard, which still offers a minus sign.
*/
export function pendingUserInputKeyboardType(question: {
readonly valueType?: "string" | "number" | "integer" | "boolean" | "array" | undefined;
readonly minimum?: number | undefined;
}): "default" | "number-pad" | "decimal-pad" | "numbers-and-punctuation" {
const excludesNegatives = question.minimum !== undefined && question.minimum >= 0;
if (question.valueType === "integer") {
return excludesNegatives ? "number-pad" : "numbers-and-punctuation";
}
if (question.valueType === "number") {
return excludesNegatives ? "decimal-pad" : "numbers-and-punctuation";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return "default";
}
35 changes: 35 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,7 @@ const multiSelectQuestion = {
options: [
{ label: "Orders", description: "Receipts" },
{ label: "Listings", description: "Inventory" },
{ label: "Customers", description: "Accounts" },
],
multiSelect: true,
} as const;
Expand Down Expand Up @@ -1977,6 +1978,32 @@ describe("pending user input answers", () => {
).toEqual({ customAnswer: "" });
});

it("prevents selecting more options than question.maxItems while allowing deselection", () => {
const question = { ...multiSelectQuestion, maxItems: 2 };
const one = togglePendingUserInputOptionSelection(question, undefined, "Orders");
const two = togglePendingUserInputOptionSelection(question, one, "Listings");
expect(two.selectedOptionValues).toEqual(["Orders", "Listings"]);

const three = togglePendingUserInputOptionSelection(question, two, "Customers");
expect(three.selectedOptionValues).toEqual(["Orders", "Listings"]);

const withCustom = { selectedOptionValues: ["Orders", "Listings"], customAnswer: "keep this" };
const cappedTap = togglePendingUserInputOptionSelection(question, withCustom, "Customers");
expect(cappedTap).toBe(withCustom);
expect(cappedTap.selectedOptionValues).toEqual(["Orders", "Listings"]);
expect(cappedTap.customAnswer).toBe("keep this");

const deselected = togglePendingUserInputOptionSelection(question, two, "Orders");
expect(deselected.selectedOptionValues).toEqual(["Listings"]);
});

it("submits empty array for required multi-select questions with minItems 0", () => {
const zeroMin = { ...multiSelectQuestion, minItems: 0 };
expect(buildPendingUserInputAnswers([zeroMin], {})).toEqual({
scope: [],
});
});

it("builds array answers for multi-select questions", () => {
expect(
buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
Expand Down Expand Up @@ -2375,3 +2402,11 @@ it.each(["provider_error", "usage_limit"] as const)(
});
},
);

describe("optional pending user input answers", () => {
it("skips unanswered optional questions when building answers", () => {
expect(
buildPendingUserInputAnswers([{ ...singleSelectQuestion, required: false }], {}),
).toEqual({});
});
});
23 changes: 17 additions & 6 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,13 @@ function resolvePendingUserInputAnswer(

const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
if (question.multiSelect) {
return selectedOptionValues.length > 0
? selectedOptionValues
: question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
? ""
: null;
if (selectedOptionValues.length > 0) {
return selectedOptionValues;
}
if (question.minItems === 0) {
return [];
}
return question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null;
}
return (
selectedOptionValues[0] ??
Expand Down Expand Up @@ -1607,7 +1609,15 @@ export function togglePendingUserInputOptionSelection(
question,
draft?.selectedOptionValues,
);
const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue)
const isSelected = selectedOptionValues.includes(resolvedOptionValue);
if (
!isSelected &&
question.maxItems !== undefined &&
selectedOptionValues.length >= question.maxItems
) {
return draft ?? {};
}
const nextSelectedOptionValues = isSelected
? selectedOptionValues.filter((value) => value !== resolvedOptionValue)
: [...selectedOptionValues, resolvedOptionValue];

Expand All @@ -1634,6 +1644,7 @@ export function buildPendingUserInputAnswers(
for (const question of questions) {
const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
if (answer === null) {
if (question.required === false) continue;
return null;
}
answers[question.id] = answer;
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/state/use-selected-thread-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import {
import { useAtomValue } from "@effect/atom-react";
import { useCallback, useEffect, useRef, useState } from "react";

import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts";
import {
type ProviderApprovalDecision,
type RuntimeRequestId,
validateUserInputAnswers,
} from "@t3tools/contracts";
import {
type PendingThreadRequests,
type ThreadUserInputQuestion,
Expand Down Expand Up @@ -245,6 +249,16 @@ export function useSelectedThreadRequests() {
) {
return;
}
if (activePendingUserInput.responseCapability === "live") {
const validation = validateUserInputAnswers(
activePendingUserInput.questions,
activePendingUserInputAnswers,
);
if (!validation.ok) {
Alert.alert("Check your answer", validation.message);
return;
}
}

const responseKey = questionAttachmentDraftKey(
selectedThreadShell.environmentId,
Expand Down
Loading
Loading