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
131 changes: 74 additions & 57 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, PendingUserInputDraftAnswer>;
readonly answers: Record<string, string> | null;
readonly answers: Record<string, string | ReadonlyArray<string>> | null;
readonly respondingUserInputId: ApprovalRequestId | null;
readonly onSelectOption: (
requestId: ApprovalRequestId,
questionId: string,
question: UserInputQuestion,
label: string,
) => void;
readonly onChangeCustomAnswer: (
Expand All @@ -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 (
<View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900">
<View
className="overflow-hidden gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900"
style={{ maxHeight: props.maxHeight }}
>
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
User input needed
</Text>
<Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
Fill in the pending answers
</Text>
{props.pendingUserInput.questions.map((question) => {
const draft = props.drafts[question.id];
return (
<View key={question.id} className="gap-2 pt-1">
<Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
{question.header}
</Text>
<Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">
{question.question}
</Text>
<View className="flex-row flex-wrap gap-2.5">
{question.options.map((option) => {
const selected =
draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length;
return (
<Pressable
key={option.label}
className={cn(
"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",
)}
onPress={() =>
props.onSelectOption(
props.pendingUserInput.requestId,
question.id,
option.label,
)
}
>
<Text
<ScrollView
bounces={false}
className="min-h-0"
contentContainerClassName="gap-2.5 pb-1"
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
showsVerticalScrollIndicator
style={{ flexShrink: 1 }}
>
{props.pendingUserInput.questions.map((question) => {
const draft = props.drafts[question.id];
return (
<View key={question.id} className="gap-2 pt-1">
<Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
{question.header}
</Text>
<Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">
{question.question}
</Text>
<View className="flex-row flex-wrap gap-2.5">
{question.options.map((option) => {
const selected = isPendingUserInputOptionSelected(draft, option.label);
return (
<Pressable
key={option.label}
className={cn(
"font-t3-bold text-sm",
"rounded-full border px-3 py-2.5 ",
selected
? "text-sky-700 dark:text-sky-300"
: "text-neutral-600 dark:text-neutral-300",
? "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",
)}
onPress={() =>
props.onSelectOption(
props.pendingUserInput.requestId,
question,
option.label,
)
}
>
{option.label}
</Text>
</Pressable>
);
})}
<Text
className={cn(
"font-t3-bold text-sm",
selected
? "text-sky-700 dark:text-sky-300"
: "text-neutral-600 dark:text-neutral-300",
)}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<TextInput
value={draft?.customAnswer ?? ""}
onChangeText={(value) =>
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"
/>
</View>
<TextInput
value={draft?.customAnswer ?? ""}
onChangeText={(value) =>
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"
/>
</View>
);
})}
);
})}
</ScrollView>
<Pressable
className={cn(
"items-center justify-center rounded-2xl px-4 py-3.5",
Expand Down
38 changes: 33 additions & 5 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import type { LegendListRef } from "@legendapp/list/react-native";
import { HeaderHeightContext } from "@react-navigation/elements";
import type {
ApprovalRequestId,
EnvironmentId,
Expand All @@ -13,11 +14,25 @@ import type {
RuntimeMode,
ServerConfig as T3ServerConfig,
ThreadId,
UserInputQuestion,
} from "@t3tools/contracts";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import {
memo,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Platform, useWindowDimensions, View, type GestureResponderEvent } from "react-native";
import {
KeyboardController,
KeyboardStickyView,
useKeyboardState,
} from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

Expand All @@ -34,6 +49,7 @@ import type {
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout";
import {
COMPOSER_COLLAPSED_CHROME,
COMPOSER_EXPANDED_CHROME,
Expand All @@ -54,7 +70,7 @@ export interface ThreadDetailScreenProps {
readonly respondingApprovalId: ApprovalRequestId | null;
readonly activePendingUserInput: PendingUserInput | null;
readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
readonly activePendingUserInputAnswers: Record<string, string> | null;
readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;
readonly respondingUserInputId: ApprovalRequestId | null;
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
Expand Down Expand Up @@ -89,7 +105,7 @@ export interface ThreadDetailScreenProps {
) => Promise<unknown>;
readonly onSelectUserInputOption: (
requestId: ApprovalRequestId,
questionId: string,
question: UserInputQuestion,
label: string,
) => void;
readonly onChangeUserInputCustomAnswer: (
Expand Down Expand Up @@ -173,6 +189,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE

export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const insets = useSafeAreaInsets();
const windowHeight = useWindowDimensions().height;
const keyboardVisible = useKeyboardState((state) => 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<ComposerEditorHandle>(null);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -383,6 +409,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
{/* Floating composer — sticks to keyboard via KeyboardStickyView */}
{showContent ? (
<KeyboardStickyView
enabled={keyboardVisible}
style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
offset={{ closed: 0, opened: 0 }}
>
Expand All @@ -407,6 +434,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
{props.activePendingUserInput ? (
<PendingUserInputCard
pendingUserInput={props.activePendingUserInput}
maxHeight={pendingUserInputMaxHeight}
drafts={props.activePendingUserInputDrafts}
answers={props.activePendingUserInputAnswers}
respondingUserInputId={props.respondingUserInputId}
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/src/features/threads/pendingUserInputLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vite-plus/test";

import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout";

describe("derivePendingUserInputMaxHeight", () => {
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);
});
});
22 changes: 22 additions & 0 deletions apps/mobile/src/features/threads/pendingUserInputLayout.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
Loading
Loading