diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c3c9b4e7ce8..1dc28a06fa8 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,18 +1,23 @@ -import type { ApprovalRequestId } from "@t3tools/contracts"; -import { Pressable, View } from "react-native"; +import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; +import { Pressable, ScrollView, View } from "react-native"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; -import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; +import { + isPendingUserInputOptionSelected, + type PendingUserInput, + type PendingUserInputDraftAnswer, +} from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; + readonly maxHeight: number; readonly drafts: Record; - readonly answers: Record | null; + readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly onSelectOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeCustomAnswer: ( @@ -28,69 +33,81 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { // with no blur behind it, so a translucent background renders the questions // on top of whatever message happens to sit underneath. return ( - + User input needed Fill in the pending answers - {props.pendingUserInput.questions.map((question) => { - const draft = props.drafts[question.id]; - return ( - - - {question.header} - - - {question.question} - - - {question.options.map((option) => { - const selected = - draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; - return ( - - props.onSelectOption( - props.pendingUserInput.requestId, - question.id, - option.label, - ) - } - > - + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = isPendingUserInputOptionSelected(draft, option.label); + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question, + option.label, + ) + } > - {option.label} - - - ); - })} + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> - - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" - /> - - ); - })} + ); + })} + ; - readonly activePendingUserInputAnswers: Record | null; + readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; @@ -89,7 +105,7 @@ export interface ThreadDetailScreenProps { ) => Promise; readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeUserInputCustomAnswer: ( @@ -173,6 +189,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray state.isVisible); + const keyboardHeight = useKeyboardState((state) => state.height); + const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); @@ -204,6 +224,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; + const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ + windowHeight, + keyboardHeight: keyboardVisible ? keyboardHeight : 0, + navigationHeaderHeight, + composerOverlapHeight, + }); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -383,6 +409,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( @@ -407,6 +434,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingUserInput ? ( { + it("caps a tall portrait viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 0, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(560); + }); + + it("subtracts the keyboard while editing a custom answer", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 336, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(387); + }); + + it("keeps the fixed action area usable in a short keyboard-open viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 375, + keyboardHeight: 240, + navigationHeaderHeight: 44, + composerOverlapHeight: 94, + }), + ).toBe(160); + }); +}); diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts new file mode 100644 index 00000000000..631e3d7713a --- /dev/null +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -0,0 +1,22 @@ +const PENDING_USER_INPUT_MAX_HEIGHT = 560; +const PENDING_USER_INPUT_MIN_HEIGHT = 160; +const PENDING_USER_INPUT_VERTICAL_GAP = 12; + +export function derivePendingUserInputMaxHeight(input: { + readonly windowHeight: number; + readonly keyboardHeight: number; + readonly navigationHeaderHeight: number; + readonly composerOverlapHeight: number; +}): number { + const availableHeight = + input.windowHeight - + Math.max(0, input.keyboardHeight) - + Math.max(0, input.navigationHeaderHeight) - + Math.max(0, input.composerOverlapHeight) - + PENDING_USER_INPUT_VERTICAL_GAP; + + return Math.min( + PENDING_USER_INPUT_MAX_HEIGHT, + Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight), + ); +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ae9a93e9fc3..e1d46fd858e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -12,12 +12,107 @@ import { } from "@t3tools/contracts"; import { + buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, + isPendingUserInputOptionSelected, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; +const singleSelectQuestion = { + id: "runtime", + header: "Runtime", + question: "Which runtime should be used?", + options: [ + { label: "Go", description: "One binary" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const multiSelectQuestion = { + id: "scope", + header: "Scope", + question: "Which data should be collected?", + options: [ + { label: "Orders", description: "Receipts" }, + { label: "Listings", description: "Inventory" }, + ], + multiSelect: true, +} as const; + +describe("pending user input answers", () => { + it("replaces single-select options and toggles multi-select options", () => { + expect( + togglePendingUserInputOptionSelection( + singleSelectQuestion, + { selectedOptionLabels: ["Go"] }, + "Node.js", + ), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] }); + + const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); + const ordersAndListings = togglePendingUserInputOptionSelection( + multiSelectQuestion, + orders, + "Listings", + ); + expect(ordersAndListings).toEqual({ + customAnswer: "", + selectedOptionLabels: ["Orders", "Listings"], + }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); + + const paddedOrders = togglePendingUserInputOptionSelection( + multiSelectQuestion, + undefined, + " Orders ", + ); + expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), + ).toEqual({ customAnswer: "" }); + }); + + it("builds array answers for multi-select questions", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { + runtime: { selectedOptionLabels: ["Go"] }, + scope: { selectedOptionLabels: ["Orders", "Listings"] }, + }), + ).toEqual({ + runtime: "Go", + scope: ["Orders", "Listings"], + }); + }); + + it("clears selected options while a custom answer is active", () => { + expect( + setPendingUserInputCustomAnswer( + { selectedOptionLabels: ["Orders", "Listings"] }, + "Orders first", + ), + ).toEqual({ customAnswer: "Orders first" }); + }); + + it("matches selected chips against normalized option labels", () => { + expect( + isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "), + ).toBe(true); + expect( + isPendingUserInputOptionSelected( + { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" }, + " Orders ", + ), + ).toBe(false); + }); +}); + function makeActivity( input: Partial & Pick, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cd8e8cad212..fbcb2e1c7e2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -26,7 +26,7 @@ export interface PendingUserInput { } export interface PendingUserInputDraftAnswer { - readonly selectedOptionLabel?: string; + readonly selectedOptionLabels?: ReadonlyArray; readonly customAnswer?: string; } @@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeSelectedOptionLabels( + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), + ); +} + function resolvePendingUserInputAnswer( + question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, -): string | null { +): string | ReadonlyArray | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } - return normalizeDraftAnswer(draft?.selectedOptionLabel); + + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + if (question.multiSelect) { + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + } + return selectedOptionLabels[0] ?? null; } /** Codex children settle via task.updated (idle/failed/interrupted), never @@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabel = - customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + const selectedOptionLabels = + customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); return { customAnswer, - ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): boolean { + if (normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); +} + +export function togglePendingUserInputOptionSelection( + question: UserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): PendingUserInputDraftAnswer { + const normalizedOptionLabel = optionLabel.trim(); + + if (question.multiSelect) { + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) + ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) + : [...selectedOptionLabels, normalizedOptionLabel]; + + return { + customAnswer: "", + ...(nextSelectedOptionLabels.length > 0 + ? { selectedOptionLabels: nextSelectedOptionLabels } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionLabels: [normalizedOptionLabel], }; } export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, -): Record | null { - const answers: Record = {}; +): Record> | null { + const answers: Record> = {}; for (const question of questions) { - const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { return null; } diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 82ff42f247a..30b3a0704f8 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { + ApprovalRequestId, + type ProviderApprovalDecision, + type UserInputQuestion, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -12,6 +16,7 @@ import { derivePendingUserInputs, setPendingUserInputCustomAnswer, sortThreadActivities, + togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; @@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); -function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { +function setUserInputDraftOption( + requestKey: string, + question: UserInputQuestion, + label: string, +): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], - [questionId]: { - selectedOptionLabel: label, - }, + [question.id]: togglePendingUserInputOptionSelection( + question, + current[requestKey]?.[question.id], + label, + ), }, }); } @@ -97,13 +108,13 @@ export function useSelectedThreadRequests() { : null; const onSelectUserInputOption = useCallback( - (requestId: ApprovalRequestId, questionId: string, label: string) => { + (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => { if (!selectedThreadShell) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftOption(requestKey, questionId, label); + setUserInputDraftOption(requestKey, question, label); }, [selectedThreadShell], );