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
157 changes: 87 additions & 70 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type ViewStyle,
} from "react-native";
import ImageViewing from "react-native-image-viewing";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import { useThemeColor } from "../../lib/useThemeColor";
import { scopedThreadKey } from "../../lib/scopedEntities";

Expand Down Expand Up @@ -112,6 +113,10 @@ export interface ThreadComposerProps {
* The pill / card container — renders as LiquidGlassView on supported
* iOS 26+ devices (progressive blur, native morph), opaque View otherwise.
*/
// One timing for every piece of the expanded↔compact morph so the surface,
// toolbar, and siblings move together instead of popping between layouts.
const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220);

function ComposerSurface(props: {
readonly children: ReactNode;
readonly style: ViewStyle;
Expand All @@ -130,7 +135,7 @@ function ComposerSurface(props: {

if (isLiquidGlassSupported) {
return (
<View style={shadowStyle}>
<Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>
<LiquidGlassView
effect="regular"
interactive
Expand All @@ -139,12 +144,12 @@ function ComposerSurface(props: {
>
{props.children}
</LiquidGlassView>
</View>
</Animated.View>
);
}

return (
<View style={shadowStyle}>
<Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>
<View
style={[
props.style,
Expand All @@ -157,7 +162,7 @@ function ComposerSurface(props: {
>
{props.children}
</View>
</View>
</Animated.View>
);
}

Expand Down Expand Up @@ -669,7 +674,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

return (
<View
<Animated.View
layout={COMPOSER_LAYOUT_TRANSITION}
style={{
paddingHorizontal: 16,
paddingTop: isExpanded ? 8 : 6,
Expand All @@ -679,8 +685,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
: "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)",
}}
>
<View
<Animated.View
className="w-full"
layout={COMPOSER_LAYOUT_TRANSITION}
style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }}
>
{composerTrigger && composerMenuItems.length > 0 ? (
Expand Down Expand Up @@ -733,13 +740,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
>
{/* Attachment strip — inside the card, above the text input */}
{isExpanded ? (
<View style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }}>
<Animated.View
entering={FadeIn.duration(160)}
exiting={FadeOut.duration(120)}
style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }}
>
<ComposerAttachmentStrip
attachments={props.draftAttachments}
onRemove={props.onRemoveDraftImage}
onPressImage={onPressImage}
/>
</View>
</Animated.View>
) : null}

<View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}>
Expand Down Expand Up @@ -812,81 +823,87 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</View>
) : null}
{!isExpanded ? (
showStopAction ? (
<ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
) : (
<ControlPill
icon="arrow.up"
variant="primary"
disabled={!canSend}
onPress={handleSend}
/>
)
<Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}>
{showStopAction ? (
<ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
) : (
<ControlPill
icon="arrow.up"
variant="primary"
disabled={!canSend}
onPress={handleSend}
/>
)}
</Animated.View>
) : null}
</ComposerSurface>

{/* Toolbar row — matches draft page layout (expanded only) */}
{isExpanded ? (
<ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
<ComposerToolbarScroller
fadeOpaque={toolbarFadeOpaque}
fadeTransparent={toolbarFadeTransparent}
>
<ComposerToolbarButton
icon="plus"
onPress={() => void props.onPickDraftImages()}
showChevron={false}
/>
<ControlPillMenu
actions={modelMenuActions}
onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
>
<ComposerToolbarTrigger
accessibilityLabel="Model"
iconNode={
<ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
}
label={currentModelOption?.label ?? currentModelSelection.model}
/>
</ControlPillMenu>
<ControlPillMenu
actions={optionsMenuActions}
onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
<Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}>
<ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
<ComposerToolbarScroller
fadeOpaque={toolbarFadeOpaque}
fadeTransparent={toolbarFadeTransparent}
>
<ComposerToolbarTrigger
accessibilityLabel="Configuration"
icon="slider.horizontal.3"
label={configurationLabel}
/>
</ControlPillMenu>
{showStopAction ? (
<ComposerToolbarButton
icon="stop.fill"
variant="danger"
onPress={props.onStopThread}
icon="plus"
onPress={() => void props.onPickDraftImages()}
showChevron={false}
/>
) : null}
</ComposerToolbarScroller>
<ComposerToolbarButton
accessibilityLabel={sendLabel}
icon="arrow.up"
variant="primary"
disabled={!canSend}
onPress={handleSend}
showChevron={false}
/>
</ComposerToolbarRow>
<ControlPillMenu
actions={modelMenuActions}
onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
>
<ComposerToolbarTrigger
accessibilityLabel="Model"
iconNode={
<ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
}
label={currentModelOption?.label ?? currentModelSelection.model}
/>
</ControlPillMenu>
<ControlPillMenu
actions={optionsMenuActions}
onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
>
<ComposerToolbarTrigger
accessibilityLabel="Configuration"
icon="slider.horizontal.3"
label={configurationLabel}
/>
</ControlPillMenu>
{showStopAction ? (
<ComposerToolbarButton
icon="stop.fill"
variant="danger"
onPress={props.onStopThread}
showChevron={false}
/>
) : null}
</ComposerToolbarScroller>
<ComposerToolbarButton
accessibilityLabel={sendLabel}
icon="arrow.up"
variant="primary"
disabled={!canSend}
onPress={handleSend}
showChevron={false}
/>
</ComposerToolbarRow>
</Animated.View>
) : null}

{/* Queue count */}
{props.queueCount > 0 ? (
<Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
{props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
automatically.
</Text>
<Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(120)}>
<Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}>
{props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
automatically.
</Text>
</Animated.View>
) : null}
</View>
</Animated.View>

<ImageViewing
images={previewImageUri ? [{ uri: previewImageUri }] : []}
Expand All @@ -896,6 +913,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
swipeToCloseEnabled
doubleTapToZoomEnabled
/>
</View>
</Animated.View>
);
});
40 changes: 32 additions & 8 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import type {
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { View, type GestureResponderEvent } from "react-native";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { runOnJS } from "react-native-reanimated";

Expand Down Expand Up @@ -190,7 +191,12 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";

return (
<View className="px-4 pb-2" style={{ flexShrink: 0 }}>
<Animated.View
className="px-4 pb-2"
entering={FadeInDown.duration(200)}
exiting={FadeOut.duration(140)}
style={{ flexShrink: 0 }}
>
<View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]">
<View className="flex-row items-center gap-2">
<View className="flex-row items-center gap-1">
Expand All @@ -203,7 +209,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
</Text>
</View>
</View>
</View>
</Animated.View>
);
});

Expand Down Expand Up @@ -244,10 +250,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const composerOverlapHeight = composerChrome + composerBottomInset;
const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8;
// The overlay's measured height includes the home-indicator inset (the
// composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
// UIKit add the safe-area bottom to the content inset AGAIN — leaving a
// dead strip between the resting content and the composer. Report the
// overlay height minus the safe area; UIKit adds it back, and ThreadFeed
// hands LegendList the same delta via contentInsetEndStaticAdjustment so
// its end-scroll math matches the real resting position.
const nativeInsetOvercount =
props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0;
const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
listRef,
composerOverlayRef,
estimatedOverlayHeight,
Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
-nativeInsetOvercount,
);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const showContent = props.showContent ?? true;
Expand Down Expand Up @@ -426,13 +442,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onLayout={onComposerLayout}
style={{ width: "100%", paddingTop: 8 }}
>
<View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}>
<Animated.View
layout={LinearTransition.duration(220)}
style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}
>
{props.activeWorkStartedAt ? (
<WorkingDurationPill startedAt={props.activeWorkStartedAt} />
) : null}

{props.activePendingApproval || props.activePendingUserInput ? (
<View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
<Animated.View
className="gap-3 px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
style={{ flexShrink: 0 }}
>
{props.activePendingApproval ? (
<PendingApprovalCard
approval={props.activePendingApproval}
Expand All @@ -451,9 +475,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onSubmit={props.onSubmitUserInput}
/>
) : null}
</View>
</Animated.View>
) : null}
</View>
</Animated.View>

<ThreadComposer
editorRef={composerEditorRef}
Expand Down
Loading
Loading