Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
81d3b5e
feat(mobile): narrow composers open the card on tap
flamboh Oct 23, 2026
6e814d5
refactor(mobile): share the composer's model display label
flamboh Oct 23, 2026
a0613f3
fix(mobile): re-measure the composer row after it is resized
flamboh Oct 23, 2026
7f7e08a
refactor(mobile): leave model label shortening to its own change
flamboh Oct 23, 2026
59008e9
fix(mobile): close the composer card on Android Back and wait for the…
flamboh Oct 24, 2026
7606cda
fix(mobile): pasting a long line no longer flashes the composer card
flamboh Oct 24, 2026
0c4d667
fix(mobile): Android composer card closes with the keyboard
flamboh Oct 24, 2026
be67a40
fix(mobile): open the card for wrapped drafts when an attachment alre…
flamboh Oct 24, 2026
00c7311
fix(mobile): keep the empty-thread message still when the composer ca…
flamboh Oct 25, 2026
0d64a22
fix(mobile): leave room for the floating status control under the emp…
flamboh Oct 25, 2026
d22a6d4
chore(mobile): justify the keyboard listener effect for the effect bu…
flamboh Oct 26, 2026
3890314
feat(mobile): the model name slides out of the icon and the draft sli…
flamboh Oct 26, 2026
737bfb1
fix(mobile): wide composers keep the model label and open the card as…
flamboh Oct 26, 2026
902feeb
fix(mobile): integrate current composer layout into narrow card behavior
maria-rcks Oct 26, 2026
2d2443f
Merge branch 'main' into t3/composer-card-on-tap
flamboh Oct 27, 2026
a933966
test(mobile): takeover tests for the compact composer card
juliusmarminge Oct 27, 2026
4a5b132
refactor(mobile): compact composer resolves its layout in one place
juliusmarminge Oct 27, 2026
9113191
Merge remote-tracking branch 'origin/main' into pr-23986
juliusmarminge Oct 27, 2026
de1ddf8
fix(mobile): wide composers re-measure when the model label resizes w…
juliusmarminge Oct 27, 2026
267b0dd
fix(mobile): dictation from a focused narrow composer keeps the row h…
juliusmarminge Oct 27, 2026
52ad83f
fix(mobile): a wider model label no longer bounces an open wide-row card
juliusmarminge Oct 27, 2026
9bb5ad2
fix(mobile): removing a thumbnail with the card open no longer strand…
juliusmarminge Oct 27, 2026
a50ca1c
fix(mobile): the Android card no longer closes while focus returns fr…
juliusmarminge Oct 27, 2026
033153d
Merge remote-tracking branch 'origin/main' into pr-23986
juliusmarminge Oct 27, 2026
4cad671
Merge remote-tracking branch 'origin/main' into t3/composer-card-on-tap
flamboh Nov 1, 2026
693d128
feat(mobile): the model name slides out of the icon as the composer c…
flamboh Nov 1, 2026
ddf250c
feat(mobile): the draft slides between the row and the card as the co…
flamboh Nov 1, 2026
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
203 changes: 163 additions & 40 deletions apps/mobile/src/components/ComposerToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ComponentProps, ReactNode } from "react";
import { useState } from "react";
import { useLayoutEffect, useState } from "react";
import {
PixelRatio,
Platform,
Pressable,
ScrollView,
View,
Expand All @@ -12,7 +14,16 @@ import {
type ViewStyle,
} from "react-native";

import Animated, { FadeIn, useReducedMotion } from "react-native-reanimated";
import Animated, {
FadeIn,
runOnJS,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withDelay,
withTiming,
type SharedValue,
} from "react-native-reanimated";

import { cn } from "../lib/cn";
import { AppText as Text } from "./ui/AppText";
Expand All @@ -23,6 +34,18 @@ const COMPOSER_TOOLBAR_GAP = 8;
const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4;
const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160);
type LabelPhase = "shown" | "hiding" | "hidden" | "showing";

function slideLabel(
reveal: SharedValue<number>,
target: 0 | 1,
setPhase: (phase: LabelPhase) => void,
) {
const slide = withTiming(target, { duration: target === 1 ? 180 : 120 }, (finished) => {
if (finished) runOnJS(setPhase)(target === 1 ? "shown" : "hidden");
});
reveal.set(target === 1 ? withDelay(100, slide) : slide);
}
export function ComposerInlineControl(props: {
readonly size?: "regular" | "large";
readonly accessibilityHint?: string;
Expand All @@ -46,12 +69,77 @@ export function ComposerInlineControl(props: {
const reducedMotion = useReducedMotion();
// Keep the picker anchored to its parent while the editor changes height.
const animatesLabel = props.iconOnly !== undefined;
const revealsLabel = animatesLabel && Platform.OS !== "android" && !reducedMotion;
const iconSize = props.size === "large" ? 20 : smallIconSize;
const chevronSize = Math.round((props.size === "large" ? 12 : 10) * scale);
const labelShown = !props.iconOnly;
const [phase, setPhase] = useState<LabelPhase>(labelShown ? "shown" : "hidden");
const [labelWidth, setLabelWidth] = useState(0);
if (labelShown && (phase === "hidden" || phase === "hiding")) {
setPhase(revealsLabel ? "showing" : "shown");
}
if (!labelShown && (phase === "shown" || phase === "showing")) {
setPhase(revealsLabel && labelWidth > 0 ? "hiding" : "hidden");
}
const sliding = phase === "hiding" || phase === "showing";
const reveal = useSharedValue(labelShown ? 1 : 0);
const revealWidth = useSharedValue(0);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drives the Reanimated label slide on the UI thread when the label phase changes
useLayoutEffect(() => {
if (phase === "hiding") slideLabel(reveal, 0, setPhase);
else if (phase === "showing" && revealWidth.get() > 0) slideLabel(reveal, 1, setPhase);
else if (phase === "shown") reveal.set(1);
else if (phase === "hidden") reveal.set(0);
}, [phase, reveal, revealWidth]);
const [iconFrame, setIconFrame] = useState({ x: 8, y: 12, width: iconSize, height: iconSize });
const [labelBox, setLabelBox] = useState({ y: 0, height: 44 });
const pixelScale = PixelRatio.get();
const [labelX, setLabelX] = useState(16 + iconSize);
const labelGap = labelX - iconFrame.x - iconFrame.width;
const [chevronInset, setChevronInset] = useState(16 + chevronSize);
const overlayStyle = useAnimatedStyle(() => ({
width:
Math.round((iconFrame.width + reveal.value * (labelGap + revealWidth.value)) * pixelScale) /
pixelScale,
}));
const overlayLabelStyle = useAnimatedStyle(() => ({
width: Math.round(reveal.value * revealWidth.value * pixelScale) / pixelScale,
}));
const recordLabelWidth = (width: number) => {
revealWidth.set(width);
if (width !== labelWidth) setLabelWidth(width);
};
const tint = props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted";
const iconNode = props.renderIcon ? (
<View
className="size-4 shrink-0 items-center justify-center"
style={{ width: iconSize, height: iconSize }}
>
{props.renderIcon(iconSize)}
</View>
) : props.icon ? (
<SymbolView name={props.icon} size={iconSize} tintColorClassName={tint} type="monochrome" />
) : null;
const labelContent = (
<>
<Text
className={cn(
"shrink",
props.size === "large" ? "text-base font-t3-medium" : "text-sm font-t3-medium",
props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
>
{props.label}
</Text>
{props.suffix ? <View className="min-w-0 shrink">{props.suffix}</View> : null}
</>
);
return (
<Animated.View
style={{
maxWidth: props.maxWidth ?? 190,
overflow: animatesLabel ? "hidden" : undefined,
overflow: animatesLabel && !sliding ? "hidden" : undefined,
}}
>
<Pressable
Expand All @@ -66,51 +154,86 @@ export function ComposerInlineControl(props: {
onPress={props.onPress}
style={{ opacity: props.disabled ? 0.45 : 1 }}
>
{props.renderIcon ? (
<View
className="size-4 shrink-0 items-center justify-center"
style={{ width: iconSize, height: iconSize }}
>
{props.renderIcon(iconSize)}
</View>
) : props.icon ? (
<SymbolView
name={props.icon}
size={iconSize}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
<View
style={{ opacity: sliding ? 0 : 1 }}
onLayout={(event) => {
const { x, y, width, height } = event.nativeEvent.layout;
if (
x !== iconFrame.x ||
y !== iconFrame.y ||
width !== iconFrame.width ||
height !== iconFrame.height
) {
setIconFrame({ x, y, width, height });
}
type="monochrome"
/>
}}
>
{iconNode}
</View>
{phase === "shown" || phase === "showing" ? (
<Animated.View
className="min-w-0 shrink flex-row items-center gap-2"
entering={
animatesLabel && !revealsLabel && !reducedMotion
? INLINE_CONTROL_LABEL_ENTERING
: undefined
}
style={{ opacity: sliding ? 0 : 1 }}
onLayout={(event) => {
const { x, y, width, height } = event.nativeEvent.layout;
if (x !== labelX) setLabelX(x);
if (y !== labelBox.y || height !== labelBox.height) setLabelBox({ y, height });
const firstMeasure = phase === "showing" && revealWidth.get() === 0;
recordLabelWidth(width);
if (firstMeasure) slideLabel(reveal, 1, setPhase);
}}
>
{labelContent}
</Animated.View>
) : null}
{props.iconOnly ? null : (
{sliding ? (
<Animated.View
className="shrink"
entering={animatesLabel && !reducedMotion ? INLINE_CONTROL_LABEL_ENTERING : undefined}
className="absolute top-0 bottom-0"
pointerEvents="none"
style={[
{ right: props.showChevron === false ? iconFrame.x : chevronInset },
overlayStyle,
]}
>
<Text
className={cn(
props.size === "large" ? "text-base font-t3-medium" : "text-sm font-t3-medium",
props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
<View
className="absolute left-0"
style={{ top: iconFrame.y, width: iconFrame.width, height: iconFrame.height }}
>
{props.label}
</Text>
{iconNode}
</View>
<Animated.View
className="absolute top-0 right-0 bottom-0 overflow-hidden"
style={overlayLabelStyle}
>
<View
className="absolute right-0 flex-row items-center gap-2"
style={{ top: labelBox.y, height: labelBox.height, width: labelWidth }}
>
{labelContent}
</View>
</Animated.View>
</Animated.View>
)}
{!props.iconOnly && props.suffix ? (
<View className="min-w-0 shrink">{props.suffix}</View>
) : null}
{props.showChevron === false ? null : (
<SymbolView
name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
size={Math.round((props.size === "large" ? 12 : 10) * scale)}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
<View
onLayout={(event) => {
const { x, width } = event.nativeEvent.layout;
const inset = labelGap + width + iconFrame.x;
if (Math.abs(inset - chevronInset) > 0.5 && x > 0) setChevronInset(inset);
}}
>
<SymbolView
name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
size={chevronSize}
tintColorClassName={tint}
type="monochrome"
/>
</View>
)}
</Pressable>
</Animated.View>
Expand Down
26 changes: 3 additions & 23 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { AppBar } from "../../components/ui/AppBar";
import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { useAtomMount } from "@effect/atom-react";
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useMemo, useRef, useState } from "react";
import { Platform, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useKeyboardShowing } from "../../lib/keyboardShowing";
import { AppText as Text } from "../../components/ui/AppText";
import { TerminalContextSheet } from "./TerminalContextSheet";
import { hasNativeTerminalSurface, supportsNativeTerminalOutput } from "./nativeTerminalModule";
Expand All @@ -24,7 +25,6 @@ import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/reactivity";
import {
KeyboardController,
KeyboardEvents,
KeyboardStickyView,
useKeyboardState,
} from "react-native-keyboard-controller";
Expand Down Expand Up @@ -352,26 +352,6 @@ function readLiveTerminalSessions(
}));
}

// `useKeyboardState` only flips on willShow and didHide; the accessory must
// leave as soon as the keyboard starts hiding, so it follows the will-events.
let keyboardShowing = KeyboardController.isVisible();
function subscribeKeyboardShowing(onChange: () => void) {
keyboardShowing = KeyboardController.isVisible();
const willShow = KeyboardEvents.addListener("keyboardWillShow", () => {
keyboardShowing = true;
onChange();
});
const willHide = KeyboardEvents.addListener("keyboardWillHide", () => {
keyboardShowing = false;
onChange();
});
return () => {
willShow.remove();
willHide.remove();
};
}
const getKeyboardShowing = () => keyboardShowing;

function ThreadTerminal(props: {
readonly params: ThreadTerminalRouteScreenProps["route"]["params"];
readonly routeEnvironmentId: EnvironmentId | null;
Expand Down Expand Up @@ -589,7 +569,7 @@ function ThreadTerminal(props: {
height: state.height,
isVisible: state.isVisible,
}));
const keyboardShowing = useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowing);
const keyboardShowing = useKeyboardShowing();
// The accessory follows the keyboard, except that the dismiss and show
// buttons flip it ahead of the keyboard animation.
const [accessory, setAccessory] = useState({ keyboardShowing, dismissed: false });
Expand Down
Loading
Loading