authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-30 20:10:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 20:10:13-07:00
loge3dcc1615c9167048ecfe536f87ad1d353fa8a49
treeeeb4a79f1b732288e42833d73706f89aa9e4a81b
parent7963ac7404ff2196c3e8e4198ecc02a5e742b0a1
signature Signed by PGP key B5690EEEBB952194

Add mobile composer attachment menu with video support (#8843)


19 files changed, 851 insertions(+), 247 deletions(-)

apps/mobile/app.config.ts+1
......@@ -148,6 +148,7 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
148148 supportsText: true,
149149 supportsWebUrlWithMaxCount: 1,
150150 supportsImageWithMaxCount: 8,
151 supportsMovieWithMaxCount: 8,
151152 supportsFileWithMaxCount: 8,
152153 },
153154 },
apps/mobile/src/components/AppSymbol.tsx+2
......@@ -59,6 +59,7 @@ import IconMinus from "@tabler/icons-react-native/IconMinus";
5959import IconMoon from "@tabler/icons-react-native/IconMoon";
6060import IconNetwork from "@tabler/icons-react-native/IconNetwork";
6161import IconPalette from "@tabler/icons-react-native/IconPalette";
62import IconPhoto from "@tabler/icons-react-native/IconPhoto";
6263import IconPin from "@tabler/icons-react-native/IconPin";
6364import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff";
6465import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay";
......@@ -133,6 +134,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
133134 magnifyingglass: IconSearch,
134135 paintbrush: IconPalette,
135136 "person.crop.circle": IconUserCircle,
137 photo: IconPhoto,
136138 pin: IconPin,
137139 "pin.slash": IconPinnedOff,
138140 play: IconPlayerPlay,
apps/mobile/src/components/ComposerAttachmentButton.tsx created+55
......@@ -0,0 +1,55 @@
1import type { MenuAction } from "@react-native-menu/menu";
2import { Pressable } from "react-native";
3
4import { SymbolView } from "./AppSymbol";
5import { ControlPillMenu } from "./ControlPill";
6
7const ATTACHMENT_MENU_ACTIONS: MenuAction[] = [
8 { id: "photos", title: "Photo Library", image: "photo" },
9 { id: "files", title: "Choose Files", image: "folder" },
10];
11
12export function ComposerAttachmentButton(props: {
13 readonly disabled?: boolean;
14 readonly supportsFiles: boolean;
15 readonly onPickMedia: () => Promise<void>;
16 readonly onPickFiles: () => Promise<void>;
17}) {
18 const button = (
19 <Pressable
20 accessibilityLabel="Add attachment"
21 accessibilityRole="button"
22 accessibilityState={{ disabled: props.disabled }}
23 className="size-[44px] shrink-0 items-center justify-center rounded-full active:opacity-70 disabled:opacity-50"
24 disabled={props.disabled}
25 onPress={props.supportsFiles ? undefined : () => void props.onPickMedia()}
26 >
27 <SymbolView
28 name="plus"
29 size={20}
30 weight="regular"
31 tintColorClassName="accent-icon"
32 type="monochrome"
33 />
34 </Pressable>
35 );
36
37 if (props.disabled || !props.supportsFiles) {
38 return button;
39 }
40
41 return (
42 <ControlPillMenu
43 actions={ATTACHMENT_MENU_ACTIONS}
44 onPressAction={({ nativeEvent }) => {
45 if (nativeEvent.event === "photos") {
46 void props.onPickMedia();
47 } else if (nativeEvent.event === "files") {
48 void props.onPickFiles();
49 }
50 }}
51 >
52 {button}
53 </ControlPillMenu>
54 );
55}
apps/mobile/src/components/ComposerToolbar.tsx+43
......@@ -113,6 +113,7 @@ export function ComposerToolbarRow(props: {
113113
114114export function ComposerToolbarScroller(props: {
115115 readonly children: ReactNode;
116 readonly align?: "start" | "end";
116117 /** Only for non-Uniwind surfaces such as the native terminal palette. */
117118 readonly fadeOpaque?: string;
118119 /** Only for non-Uniwind surfaces such as the native terminal palette. */
......@@ -167,6 +168,8 @@ export function ComposerToolbarScroller(props: {
167168 showsHorizontalScrollIndicator={false}
168169 contentContainerStyle={{
169170 alignItems: "center",
171 flexGrow: props.align === "end" ? 1 : undefined,
172 justifyContent: props.align === "end" ? "flex-end" : undefined,
170173 gap: COMPOSER_TOOLBAR_GAP,
171174 paddingLeft: 0,
172175 paddingRight: props.contentPaddingRight ?? 1,
......@@ -214,6 +217,46 @@ export function ComposerToolbarScroller(props: {
214217 );
215218}
216219
220export function ComposerActionButton(props: {
221 readonly accessibilityLabel: string;
222 readonly disabled?: boolean;
223 readonly icon: ComponentProps<typeof SymbolView>["name"];
224 readonly onPress: () => void;
225 readonly variant?: "primary" | "danger";
226}) {
227 return (
228 <Pressable
229 accessibilityLabel={props.accessibilityLabel}
230 accessibilityRole="button"
231 accessibilityState={{ disabled: props.disabled }}
232 className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
233 disabled={props.disabled}
234 onPress={props.onPress}
235 >
236 <View
237 className={cn(
238 "size-[30px] items-center justify-center rounded-full",
239 props.variant === "danger"
240 ? "bg-danger"
241 : props.disabled
242 ? "bg-primary/15"
243 : "bg-primary",
244 )}
245 >
246 <SymbolView
247 name={props.icon}
248 size={16}
249 weight="semibold"
250 tintColorClassName={
251 props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground"
252 }
253 type="monochrome"
254 />
255 </View>
256 </Pressable>
257 );
258}
259
217260export function ComposerToolbarButton(props: {
218261 readonly icon?: ComponentProps<typeof SymbolView>["name"];
219262 readonly iconNode?: ReactNode;
apps/mobile/src/components/ControlPill.tsx+30-3
......@@ -6,16 +6,38 @@ import {
66 type ComponentProps,
77 type ReactElement,
88 type ReactNode,
9 useMemo,
910 useRef,
1011} from "react";
11import { Platform, Pressable, View, type PressableProps } from "react-native";
12import { Platform, Pressable, View, type ColorValue, type PressableProps } from "react-native";
13import { withUniwind } from "uniwind";
1214import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1315
1416import { cn } from "../lib/cn";
17import { withMenuActionIconColors } from "../lib/menu-action-colors";
1518import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
1619import { SymbolView } from "./AppSymbol";
1720import { AppText as Text } from "./AppText";
1821
22const ThemedMenuView = withUniwind(function NativeMenuView({
23 iconColor,
24 destructiveIconColor,
25 ...props
26}: ComponentProps<typeof MenuView> & {
27 readonly iconColor?: ColorValue;
28 readonly destructiveIconColor?: ColorValue;
29}) {
30 const actions = useMemo(
31 () =>
32 withMenuActionIconColors(props.actions, {
33 icon: iconColor,
34 destructiveIcon: destructiveIconColor,
35 }),
36 [props.actions, iconColor, destructiveIconColor],
37 );
38 return <MenuView {...props} actions={actions} />;
39});
40
1941export function ControlPill(props: {
2042 readonly icon?: ComponentProps<typeof SymbolView>["name"];
2143 readonly iconNode?: ReactNode;
......@@ -217,8 +239,13 @@ export function ControlPillMenu(
217239 };
218240 }
219241 return (
220 <MenuView {...menuProps} themeVariant={isDarkMode ? "dark" : "light"}>
242 <ThemedMenuView
243 {...menuProps}
244 iconColorClassName="accent-icon"
245 destructiveIconColorClassName="accent-danger-foreground"
246 themeVariant={isDarkMode ? "dark" : "light"}
247 >
221248 {children}
222 </MenuView>
249 </ThemedMenuView>
223250 );
224251}
apps/mobile/src/features/sharing/incoming-share-model.test.ts+48
......@@ -181,6 +181,54 @@ describe("incoming native shares", () => {
181181 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
182182 });
183183
184 it.each([
185 { value: "file:///shared/clip.MOV", mimeType: "video/quicktime", originalName: "clip.MOV" },
186 { value: "content://media/videos/12", mimeType: "video/mp4", originalName: "clip.mp4" },
187 ])("imports a shared video from $value without reading it as an image", async (video) => {
188 const sizeBytes = 20 * 1024 * 1024;
189 const fileUri = `file:///documents/${video.originalName}`;
190 const readBase64 = vi.fn(async () => "unused");
191 const persistFile = vi.fn(async () => fileUri);
192 const removeOwnedFile = vi.fn(async () => undefined);
193
194 const result = await buildIncomingShareDraft({
195 id: "share-video",
196 createdAt: "2026-08-30T10:00:00.000Z",
197 payloads: [{ ...video, shareType: "video" }],
198 resolvedPayloads: [],
199 fileReader: { readBase64, persistFile, readSize: async () => sizeBytes, removeOwnedFile },
200 });
201
202 expect(result.warnings).toEqual([]);
203 expect(result.attachments).toEqual([
204 {
205 id: "share-video:file:0",
206 type: "file",
207 name: video.originalName,
208 mimeType: video.mimeType,
209 sizeBytes,
210 fileUri,
211 },
212 ]);
213 expect(readBase64).not.toHaveBeenCalled();
214 expect(removeOwnedFile).toHaveBeenCalledWith(video.value);
215 expect(
216 selectIncomingShareAttachments({
217 attachments: result.attachments,
218 maxFileAttachmentBytes: 50 * 1024 * 1024,
219 }),
220 ).toEqual({ attachments: result.attachments, warnings: [] });
221 expect(
222 selectIncomingShareAttachments({
223 attachments: result.attachments,
224 maxFileAttachmentBytes: 10 * 1024 * 1024,
225 }),
226 ).toEqual({
227 attachments: [],
228 warnings: [`'${video.originalName}' exceeds the 10 MB attachment limit.`],
229 });
230 });
231
184232 it("reports an unreadable shared file without calling it oversized", async () => {
185233 const file: SharePayload = {
186234 shareType: "file",
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+64-59
......@@ -25,12 +25,13 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
2525
2626import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
2727import {
28 ComposerActionButton,
2829 ComposerInlineControl,
29 ComposerToolbarButton,
3030 ComposerToolbarRow,
3131 ComposerToolbarScroller,
3232} from "../../components/ComposerToolbar";
3333import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
34import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
3435import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
3536import { ProviderIcon } from "../../components/ProviderIcon";
3637import { SymbolView } from "../../components/AppSymbol";
......@@ -55,7 +56,7 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
5556import {
5657 convertPastedImagesToAttachments,
5758 pickComposerFiles,
58 pickComposerImages,
59 pickComposerMedia,
5960} from "../../lib/composerImages";
6061import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
6162import {
......@@ -715,12 +716,20 @@ export function NewTaskDraftScreen(props: {
715716 });
716717 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
717718
718 async function handlePickImages(): Promise<void> {
719 async function handlePickMedia(): Promise<void> {
719720 if (isComposerInteractionLocked || voiceInput.isBusy) {
720721 return;
721722 }
722 const result = await pickComposerImages({ existingCount: flow.attachments.length });
723 const rejectedCount = result.images.length > 0 ? flow.appendAttachments(result.images) : 0;
723 const capabilities = selectedEnvironmentServerConfig?.environment.capabilities;
724 const result = await pickComposerMedia({
725 existingCount: flow.attachments.length,
726 maxVideoBytes:
727 capabilities?.attachmentUploads === true
728 ? capabilities.fileAttachments?.maxUploadBytes
729 : undefined,
730 });
731 const rejectedCount =
732 result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0;
724733 const problems = [
725734 ...(result.error ? [result.error] : []),
726735 ...(rejectedCount > 0
......@@ -728,7 +737,7 @@ export function NewTaskDraftScreen(props: {
728737 : []),
729738 ];
730739 if (problems.length > 0) {
731 Alert.alert("Could not attach photo", problems.join("\n\n"));
740 Alert.alert("Could not attach photo or video", problems.join("\n\n"));
732741 }
733742 }
734743
......@@ -991,7 +1000,6 @@ export function NewTaskDraftScreen(props: {
9911000 style={{
9921001 minHeight: 72,
9931002 maxHeight: 160,
994 paddingHorizontal: 4,
9951003 paddingVertical: 4,
9961004 }}
9971005 textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}
......@@ -1115,7 +1123,7 @@ export function NewTaskDraftScreen(props: {
11151123 );
11161124
11171125 const composerDock = (
1118 <View className="bg-sheet px-4 pt-1" style={{ paddingBottom: controlsBottomPadding }}>
1126 <View className="bg-sheet px-[12px] pt-1" style={{ paddingBottom: controlsBottomPadding }}>
11191127 {!voiceInput.isBusy && composerMenu.trigger && composerMenu.items.length > 0 ? (
11201128 <View className="mb-2">
11211129 <ComposerCommandPopover
......@@ -1134,12 +1142,11 @@ export function NewTaskDraftScreen(props: {
11341142 minHeight: 140,
11351143 overflow: "hidden",
11361144 paddingBottom: 6,
1137 paddingHorizontal: 14,
11381145 paddingTop: 14,
11391146 }}
11401147 >
11411148 {flow.attachments.length > 0 ? (
1142 <View className="pb-2.5">
1149 <View className="px-[14px] pb-2.5">
11431150 <ComposerAttachmentStrip
11441151 attachments={flow.attachments}
11451152 imageBorderRadius={16}
......@@ -1153,12 +1160,17 @@ export function NewTaskDraftScreen(props: {
11531160 </View>
11541161 ) : null}
11551162
1156 {promptEditor}
1163 <View className="px-[14px]">{promptEditor}</View>
11571164 <View className="h-1" />
11581165
11591166 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} collapsable={false}>
11601167 <ComposerDictationToolbar showsDictation={isVoiceInputPresented}>
1161 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={0}>
1168 <ComposerToolbarRow
1169 paddingBottom={0}
1170 paddingHorizontal={0}
1171 paddingTop={0}
1172 style={{ gap: 0 }}
1173 >
11621174 <ComposerDictationCancelAction
11631175 presentation={voicePresentation}
11641176 onCancel={voiceInput.cancel}
......@@ -1172,58 +1184,52 @@ export function NewTaskDraftScreen(props: {
11721184 onDismissError={voiceInput.cancel}
11731185 />
11741186 ) : (
1175 <ComposerToolbarScroller contentPaddingRight={8} fadeSurface="sheet">
1176 <ComposerToolbarButton
1177 accessibilityLabel="Add attachment"
1178 disabled={isComposerInteractionLocked}
1179 icon="plus"
1180 onPress={() => {
1181 if (
1182 selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments
1183 ) {
1184 Alert.alert("Add attachment", undefined, [
1185 { text: "Photos", onPress: () => void handlePickImages() },
1186 { text: "Files", onPress: () => void handlePickFiles() },
1187 { text: "Cancel", style: "cancel" },
1188 ]);
1189 return;
1190 }
1191 void handlePickImages();
1192 }}
1193 showChevron={false}
1194 />
1195 <ComposerInlineControl
1196 accessibilityLabel="Model and reasoning settings"
1187 <>
1188 <ComposerAttachmentButton
11971189 disabled={isComposerInteractionLocked}
1198 emphasized
1199 iconNode={
1200 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
1201 }
1202 label={flow.selectedModelOption?.label ?? "Choose model"}
1203 maxWidth={152}
1204 onPress={settingsSheetPresentation.open}
1190 supportsFiles={Boolean(
1191 selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments,
1192 )}
1193 onPickMedia={handlePickMedia}
1194 onPickFiles={handlePickFiles}
12051195 />
1206 {flow.planModeEnabled ? (
1196 <ComposerToolbarScroller align="end" contentPaddingRight={0} fadeSurface="sheet">
12071197 <ComposerInlineControl
1208 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}
1209 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
1198 accessibilityLabel="Model and reasoning settings"
12101199 disabled={isComposerInteractionLocked}
12111200 emphasized
1212 icon={
1213 flow.interactionMode === "plan"
1214 ? { ios: "list.bullet.clipboard", android: "auto_awesome" }
1215 : { ios: "hammer", android: "construction" }
1216 }
1217 label={flow.interactionMode === "plan" ? "Plan" : "Build"}
1218 onPress={() =>
1219 flow.setInteractionMode(
1220 flow.interactionMode === "plan" ? "default" : "plan",
1221 )
1201 iconNode={
1202 <ProviderIcon
1203 provider={flow.selectedModelOption?.providerDriver}
1204 size={16}
1205 />
12221206 }
1223 showChevron={false}
1207 label={flow.selectedModelOption?.label ?? "Choose model"}
1208 maxWidth={152}
1209 onPress={settingsSheetPresentation.open}
12241210 />
1225 ) : null}
1226 </ComposerToolbarScroller>
1211 {flow.planModeEnabled ? (
1212 <ComposerInlineControl
1213 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}
1214 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
1215 disabled={isComposerInteractionLocked}
1216 emphasized
1217 icon={
1218 flow.interactionMode === "plan"
1219 ? { ios: "list.bullet.clipboard", android: "auto_awesome" }
1220 : { ios: "hammer", android: "construction" }
1221 }
1222 label={flow.interactionMode === "plan" ? "Plan" : "Build"}
1223 onPress={() =>
1224 flow.setInteractionMode(
1225 flow.interactionMode === "plan" ? "default" : "plan",
1226 )
1227 }
1228 showChevron={false}
1229 />
1230 ) : null}
1231 </ComposerToolbarScroller>
1232 </>
12271233 )}
12281234 <ComposerDictationPrimaryAction
12291235 state={voiceInput.state}
......@@ -1235,7 +1241,7 @@ export function NewTaskDraftScreen(props: {
12351241 onCancel={voiceInput.cancel}
12361242 />
12371243 {voicePresentation.showsSend ? (
1238 <ComposerToolbarButton
1244 <ComposerActionButton
12391245 accessibilityLabel={
12401246 flow.submitting
12411247 ? "Starting task"
......@@ -1246,7 +1252,6 @@ export function NewTaskDraftScreen(props: {
12461252 disabled={!canStart}
12471253 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
12481254 onPress={() => void handleStart()}
1249 showChevron={false}
12501255 variant="primary"
12511256 />
12521257 ) : null}
apps/mobile/src/features/threads/ThreadComposer.tsx+71-79
......@@ -19,15 +19,7 @@ import {
1919 useState,
2020 type RefObject,
2121} from "react";
22import {
23 ActivityIndicator,
24 Alert,
25 Image,
26 Platform,
27 Pressable,
28 View,
29 type ViewStyle,
30} from "react-native";
22import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";
3123import ImageViewing from "react-native-image-viewing";
3224import Animated, {
3325 FadeIn,
......@@ -46,15 +38,15 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
4638
4739import { AppText as Text } from "../../components/AppText";
4840import { SymbolView } from "../../components/AppSymbol";
41import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
4942import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
5043import { GlassSurface } from "../../components/GlassSurface";
5144import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
5245import {
46 ComposerActionButton,
5347 ComposerInlineControl,
54 ComposerToolbarButton,
5548 ComposerToolbarRow,
5649} from "../../components/ComposerToolbar";
57import { ControlPill } from "../../components/ControlPill";
5850import { ProviderIcon } from "../../components/ProviderIcon";
5951import type { DraftComposerAttachment } from "../../lib/composerImages";
6052import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
......@@ -67,6 +59,7 @@ import {
6759 ComposerDictationCancelAction,
6860 ComposerDictationDraftContent,
6961 ComposerDictationPrimaryAction,
62 ComposerDictationStartAction,
7063 ComposerDictationStatus,
7164 ComposerDictationToolbar,
7265} from "../voice-input/ComposerDictationControl";
......@@ -115,7 +108,7 @@ export interface ThreadComposerProps {
115108 readonly projectCwd: string | null;
116109 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
117110 readonly onChangeDraftMessage: (value: string) => void;
118 readonly onPickDraftImages: () => Promise<void>;
111 readonly onPickDraftMedia: () => Promise<void>;
119112 readonly onPickDraftFiles: () => Promise<void>;
120113 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
121114 readonly onRemoveDraftImage: (imageId: string) => void;
......@@ -315,8 +308,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
315308 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
316309 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
317310 const showStopAction =
318 props.selectedThread.session?.status === "running" ||
319 props.selectedThread.session?.status === "starting";
311 !hasContent &&
312 (props.selectedThread.session?.status === "running" ||
313 props.selectedThread.session?.status === "starting");
320314
321315 const sendLabel =
322316 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
......@@ -528,7 +522,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
528522
529523 return (
530524 <Animated.View
531 className="px-4"
525 className="px-[12px]"
532526 style={{
533527 paddingTop: isExpanded ? 8 : 6,
534528 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
......@@ -571,7 +565,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
571565 minHeight: 140,
572566 overflow: "hidden" as const,
573567 paddingBottom: 6,
574 paddingHorizontal: 14,
575568 paddingTop: 14,
576569 }
577570 : {
......@@ -579,18 +572,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
579572 // shape morph stays bounded while rendering as a capsule.
580573 borderRadius: 27,
581574 overflow: "hidden" as const,
582 paddingHorizontal: 14,
583 paddingVertical: showsCompactDictation ? 2 : 5,
575 paddingVertical: 2,
584576 }
585577 }
586578 >
587579 <ComposerDictationDraftContent
588580 className={isExpanded ? undefined : "flex-row items-center"}
589 collapsed={showsCompactDictation}
581 compact={!isExpanded}
582 hidden={showsCompactDictation}
590583 >
584 {!isExpanded ? (
585 <ComposerAttachmentButton
586 supportsFiles={Boolean(
587 props.serverConfig?.environment.capabilities.fileAttachments,
588 )}
589 onPickMedia={props.onPickDraftMedia}
590 onPickFiles={props.onPickDraftFiles}
591 />
592 ) : null}
591593 {isExpanded ? (
592594 <Animated.View
593 className={props.draftAttachments.length > 0 ? "pb-2.5" : undefined}
595 className={props.draftAttachments.length > 0 ? "px-[14px] pb-2.5" : undefined}
594596 entering={FadeIn.duration(160)}
595597 exiting={FadeOut.duration(120)}
596598 >
......@@ -602,7 +604,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
602604 </Animated.View>
603605 ) : null}
604606 <Animated.View
605 className={isExpanded ? undefined : "min-w-0 flex-1"}
607 className={isExpanded ? "px-[14px]" : "min-w-0 flex-1 px-[4px]"}
606608 layout={COMPOSER_LAYOUT_TRANSITION}
607609 >
608610 <ComposerEditor
......@@ -629,12 +631,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
629631 ? {
630632 minHeight: 72,
631633 maxHeight: 160,
632 paddingHorizontal: 4,
633634 paddingVertical: 4,
634635 }
635636 : {
636637 height: 36,
637 paddingHorizontal: 4,
638638 }
639639 }
640640 textStyle={{
......@@ -643,7 +643,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
643643 }}
644644 />
645645 </Animated.View>
646 {!isExpanded && !voiceInput.isBusy && props.draftAttachments.length > 0 ? (
646 {!isExpanded && props.draftAttachments.length > 0 ? (
647647 <View className="flex-row gap-1 pl-1">
648648 {props.draftAttachments.slice(0, 3).map((attachment) =>
649649 attachment.type === "image" ? (
......@@ -675,33 +675,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
675675 ) : null}
676676 </View>
677677 ) : null}
678 {!isExpanded && !voiceInput.isBusy ? (
679 <Animated.View
680 className="flex-row items-center gap-1.5"
681 entering={FadeIn.duration(180)}
682 exiting={FadeOut.duration(100)}
683 >
684 {voiceInput.isAvailable ? (
685 <ComposerDictationPrimaryAction
686 state={voiceInput.state}
687 presentation={voicePresentation}
688 isAvailable={voiceInput.isAvailable}
689 onStart={voiceInput.start}
690 onConfirm={voiceInput.stop}
691 onCancel={voiceInput.cancel}
692 />
693 ) : null}
678 {!isExpanded ? (
679 <View className="flex-row items-center">
680 <ComposerDictationStartAction
681 state={voiceInput.state}
682 isAvailable={voiceInput.isAvailable}
683 onStart={voiceInput.start}
684 onCancel={voiceInput.cancel}
685 />
694686 {showStopAction ? (
695 <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
687 <ComposerActionButton
688 accessibilityLabel="Stop agent"
689 icon="stop.fill"
690 variant="danger"
691 onPress={props.onStopThread}
692 />
696693 ) : (
697 <ControlPill
694 <ComposerActionButton
695 accessibilityLabel={sendLabel}
698696 icon="arrow.up"
699697 variant="primary"
700 disabled={!canSend}
698 disabled={!hasContent}
701699 onPress={handleSend}
702700 />
703701 )}
704 </Animated.View>
702 </View>
705703 ) : null}
706704 {isExpanded ? <View className="h-1" /> : null}
707705 </ComposerDictationDraftContent>
......@@ -712,12 +710,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
712710 layout={COMPOSER_LAYOUT_TRANSITION}
713711 pointerEvents={isToolbarVisible ? "auto" : "none"}
714712 style={
715 isToolbarVisible
713 isExpanded
716714 ? undefined
717715 : {
718 height: 0,
719 opacity: 0,
720 overflow: "hidden",
716 position: "absolute",
717 bottom: 2,
718 left: 0,
719 right: 0,
721720 }
722721 }
723722 >
......@@ -725,7 +724,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
725724 showsDictation={isVoiceInputPresented}
726725 visible={isToolbarVisible}
727726 >
728 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={0}>
727 <ComposerToolbarRow
728 paddingBottom={0}
729 paddingHorizontal={0}
730 paddingTop={0}
731 style={{ gap: 0 }}
732 >
729733 <ComposerDictationCancelAction
730734 presentation={voicePresentation}
731735 onCancel={voiceInput.cancel}
......@@ -739,24 +743,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
739743 onDismissError={voiceInput.cancel}
740744 />
741745 ) : (
742 <View className="min-w-0 flex-1 flex-row items-center gap-2">
743 <ComposerToolbarButton
744 accessibilityLabel="Add attachment"
745 icon="plus"
746 onPress={() => {
747 if (props.serverConfig?.environment.capabilities.fileAttachments) {
748 Alert.alert("Add attachment", undefined, [
749 { text: "Photos", onPress: () => void props.onPickDraftImages() },
750 { text: "Files", onPress: () => void props.onPickDraftFiles() },
751 { text: "Cancel", style: "cancel" },
752 ]);
753 return;
754 }
755 void props.onPickDraftImages();
756 }}
757 showChevron={false}
746 <View className="min-w-0 flex-1 flex-row items-center justify-between">
747 <ComposerAttachmentButton
748 supportsFiles={Boolean(
749 props.serverConfig?.environment.capabilities.fileAttachments,
750 )}
751 onPickMedia={props.onPickDraftMedia}
752 onPickFiles={props.onPickDraftFiles}
758753 />
759 <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}>
754 <View className="min-w-0 shrink" style={{ maxWidth: 152 }}>
760755 <ComposerInlineControl
761756 accessibilityLabel="Model and reasoning settings"
762757 emphasized
......@@ -770,7 +765,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
770765 </View>
771766 </View>
772767 )}
773 <View className="shrink-0 flex-row items-center gap-2">
768 <View className="shrink-0 flex-row items-center">
774769 <ComposerDictationPrimaryAction
775770 state={voiceInput.state}
776771 presentation={voicePresentation}
......@@ -779,23 +774,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
779774 onConfirm={voiceInput.stop}
780775 onCancel={voiceInput.cancel}
781776 />
782 {voicePresentation.showsSend ? (
783 <ComposerToolbarButton
784 accessibilityLabel={sendLabel}
785 icon="arrow.up"
786 variant="primary"
787 disabled={!canSend}
788 onPress={handleSend}
789 showChevron={false}
790 />
791 ) : null}
792777 {showStopAction ? (
793 <ComposerToolbarButton
778 <ComposerActionButton
794779 accessibilityLabel="Stop agent"
795780 icon="stop.fill"
796781 variant="danger"
797782 onPress={props.onStopThread}
798 showChevron={false}
783 />
784 ) : voicePresentation.showsSend ? (
785 <ComposerActionButton
786 accessibilityLabel={sendLabel}
787 icon="arrow.up"
788 variant="primary"
789 disabled={!canSend}
790 onPress={handleSend}
799791 />
800792 ) : null}
801793 </View>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2-2
......@@ -121,7 +121,7 @@ export interface ThreadDetailScreenProps {
121121 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
122122 readonly onOpenConnectionEditor: () => void;
123123 readonly onChangeDraftMessage: (value: string) => void;
124 readonly onPickDraftImages: () => Promise<void>;
124 readonly onPickDraftMedia: () => Promise<void>;
125125 readonly onPickDraftFiles: () => Promise<void>;
126126 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
127127 readonly onRemoveDraftImage: (imageId: string) => void;
......@@ -812,7 +812,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
812812 projectCwd={props.projectWorkspaceRoot}
813813 bottomInset={composerBottomInset}
814814 onChangeDraftMessage={props.onChangeDraftMessage}
815 onPickDraftImages={props.onPickDraftImages}
815 onPickDraftMedia={props.onPickDraftMedia}
816816 onPickDraftFiles={props.onPickDraftFiles}
817817 onNativePasteImages={props.onNativePasteImages}
818818 onRemoveDraftImage={props.onRemoveDraftImage}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1-1
......@@ -793,7 +793,7 @@ function ThreadRouteContent(
793793 usesAutomaticContentInsets={usesNativeHeaderGlass}
794794 onOpenConnectionEditor={handleOpenConnectionEditor}
795795 onChangeDraftMessage={composer.onChangeDraftMessage}
796 onPickDraftImages={composer.onPickDraftImages}
796 onPickDraftMedia={composer.onPickDraftMedia}
797797 onPickDraftFiles={composer.onPickDraftFiles}
798798 onNativePasteImages={composer.onNativePasteImages}
799799 onRemoveDraftImage={composer.onRemoveDraftImage}
apps/mobile/src/features/voice-input/ComposerDictationControl.tsx+99-40
......@@ -10,13 +10,12 @@ import {
1010} from "react-native";
1111import Animated, {
1212 Easing,
13 FadeIn,
14 FadeOut,
1513 LinearTransition,
1614 ReduceMotion,
1715 useAnimatedStyle,
1816 useSharedValue,
1917 withTiming,
18 type EntryExitAnimationFunction,
2019 type SharedValue,
2120} from "react-native-reanimated";
2221
......@@ -35,8 +34,56 @@ const DICTATION_LAYOUT =
3534 Platform.OS === "android"
3635 ? undefined
3736 : LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System);
38const DICTATION_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
39const DICTATION_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
37const TOOLBAR_FLIP_TIMING = {
38 duration: 260,
39 easing: Easing.inOut(Easing.cubic),
40 reduceMotion: ReduceMotion.System,
41} as const;
42const TOOLBAR_HALF_HEIGHT = 22;
43const TOOLBAR_PERSPECTIVE = 600;
44
45/** Moves each face around the same horizontal axis, keeping their edges together. */
46function toolbarFlip(fromDegrees: number, toDegrees: number): EntryExitAnimationFunction {
47 return () => {
48 "worklet";
49 const fromRadians = (fromDegrees * Math.PI) / 180;
50 const toRadians = (toDegrees * Math.PI) / 180;
51 const fromSine = Math.sin(fromRadians);
52 const toSine = Math.sin(toRadians);
53 return {
54 initialValues: {
55 opacity: fromDegrees === 0 ? 1 : 0,
56 transform: [
57 { perspective: TOOLBAR_PERSPECTIVE },
58 { translateY: -TOOLBAR_HALF_HEIGHT * fromSine },
59 { rotateX: `${fromDegrees}deg` },
60 ],
61 },
62 animations: {
63 opacity: withTiming(toDegrees === 0 ? 1 : 0, TOOLBAR_FLIP_TIMING),
64 transform: [
65 { perspective: TOOLBAR_PERSPECTIVE },
66 {
67 translateY: withTiming(-TOOLBAR_HALF_HEIGHT * toSine, {
68 ...TOOLBAR_FLIP_TIMING,
69 easing: (time) => {
70 const angle =
71 fromRadians + (toRadians - fromRadians) * TOOLBAR_FLIP_TIMING.easing(time);
72 return (Math.sin(angle) - fromSine) / (toSine - fromSine);
73 },
74 }),
75 },
76 { rotateX: withTiming(`${toDegrees}deg`, TOOLBAR_FLIP_TIMING) },
77 ],
78 },
79 };
80 };
81}
82
83const DRAFT_TOOLBAR_ENTERING = toolbarFlip(90, 0);
84const DRAFT_TOOLBAR_EXITING = toolbarFlip(0, 90);
85const DICTATION_TOOLBAR_ENTERING = toolbarFlip(-90, 0);
86const DICTATION_TOOLBAR_EXITING = toolbarFlip(0, -90);
4087const WAVEFORM_BAR_HEIGHT = 32;
4188const WAVEFORM_MIN_BAR_HEIGHT = 2;
4289const WAVEFORM_BAR_SPACING = 5;
......@@ -46,51 +93,63 @@ const WAVEFORM_TIMING = {
4693 reduceMotion: ReduceMotion.System,
4794} as const;
4895
49/** Keeps the native editor mounted when compact dictation replaces the draft area. */
96/** Rotates the compact draft away without unmounting or resizing its native editor. */
5097export function ComposerDictationDraftContent(props: {
5198 readonly children: ReactNode;
5299 readonly className?: string;
53 readonly collapsed: boolean;
100 readonly compact: boolean;
101 readonly hidden: boolean;
54102}) {
55 const visibility = useSharedValue(props.collapsed ? 0 : 1);
103 const rotation = useSharedValue(props.hidden ? 1 : 0);
56104 useLayoutEffect(() => {
57 visibility.value = withTiming(props.collapsed ? 0 : 1, DICTATION_TIMING);
58 }, [props.collapsed, visibility]);
105 rotation.value = withTiming(props.hidden ? 1 : 0, TOOLBAR_FLIP_TIMING);
106 }, [props.hidden, rotation]);
107 const compact = props.compact;
59108 const animatedStyle = useAnimatedStyle(() => ({
60 opacity: visibility.value,
61 transform: [{ translateY: -4 * (1 - visibility.value) }],
109 opacity: compact ? 1 - rotation.value : 1,
110 transform: compact
111 ? [
112 { perspective: TOOLBAR_PERSPECTIVE },
113 { translateY: -TOOLBAR_HALF_HEIGHT * Math.sin((rotation.value * Math.PI) / 2) },
114 { rotateX: `${rotation.value * 90}deg` },
115 ]
116 : [],
62117 }));
63118
64119 return (
65120 <Animated.View
66 accessibilityElementsHidden={props.collapsed}
67 importantForAccessibility={props.collapsed ? "no-hide-descendants" : "auto"}
121 accessibilityElementsHidden={props.hidden}
122 importantForAccessibility={props.hidden ? "no-hide-descendants" : "auto"}
68123 collapsable={false}
69124 className={props.className}
70125 layout={DICTATION_LAYOUT}
71 style={[animatedStyle, { overflow: "hidden" }, props.collapsed ? { height: 0 } : undefined]}
126 pointerEvents={props.hidden ? "none" : "auto"}
127 style={[animatedStyle, { backfaceVisibility: "hidden" }]}
72128 >
73129 {props.children}
74130 </Animated.View>
75131 );
76132}
77133
78/** Crossfades controls within one toolbar row while the draft keeps its position. */
134/** Flips the entire row while keeping the outgoing controls intact until it leaves. */
79135export function ComposerDictationToolbar(props: {
80136 readonly children: ReactNode;
81137 readonly showsDictation: boolean;
82138 readonly visible?: boolean;
83139}) {
84140 return (
85 <View className="relative h-11">
86 <Animated.View
87 key={props.showsDictation ? "dictation" : "draft"}
88 className="absolute inset-0"
89 entering={DICTATION_ENTERING}
90 exiting={props.visible === false ? undefined : DICTATION_EXITING}
91 >
92 {props.children}
93 </Animated.View>
141 <View className="relative h-[44px] overflow-hidden">
142 {props.visible !== false ? (
143 <Animated.View
144 key={props.showsDictation ? "dictation" : "draft"}
145 className="absolute inset-0"
146 entering={props.showsDictation ? DICTATION_TOOLBAR_ENTERING : DRAFT_TOOLBAR_ENTERING}
147 exiting={props.showsDictation ? DICTATION_TOOLBAR_EXITING : DRAFT_TOOLBAR_EXITING}
148 style={{ backfaceVisibility: "hidden" }}
149 >
150 {props.children}
151 </Animated.View>
152 ) : null}
94153 </View>
95154 );
96155}
......@@ -179,19 +238,15 @@ function VoiceActionButton(props: {
179238 accessibilityLabel={props.accessibilityLabel}
180239 accessibilityRole="button"
181240 accessibilityState={{ busy: props.loading, disabled: props.disabled }}
182 className={cn(
183 "items-center justify-center active:opacity-70",
184 variant === "primary" ? "size-11" : "size-9",
185 )}
241 className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
186242 disabled={props.disabled}
187 hitSlop={variant === "plain" ? 4 : undefined}
188243 onPress={props.onPress}
189244 style={{ opacity: props.disabled && !props.loading ? 0.4 : 1 }}
190245 >
191246 <View
192247 className={cn(
193248 "items-center justify-center",
194 variant === "primary" ? "size-11 rounded-full bg-subtle" : "size-9",
249 variant === "primary" ? "size-[30px] rounded-full bg-subtle" : "size-[44px]",
195250 )}
196251 >
197252 {variant === "primary" ? (
......@@ -200,25 +255,21 @@ function VoiceActionButton(props: {
200255 style={primaryStyle}
201256 />
202257 ) : null}
203 <Animated.View
204 key={props.loading ? "loading" : "icon"}
205 className="absolute inset-0 items-center justify-center"
206 entering={DICTATION_ENTERING}
207 exiting={DICTATION_EXITING}
208 >
258 <View className="absolute inset-0 items-center justify-center">
209259 {props.loading ? (
210260 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
211261 ) : (
212262 <SymbolView
213263 name={props.icon}
214 size={17}
264 size={variant === "primary" ? 16 : 20}
265 weight={variant === "primary" ? "semibold" : "regular"}
215266 tintColorClassName={
216267 variant === "primary" ? "accent-primary-foreground" : "accent-icon"
217268 }
218269 type="monochrome"
219270 />
220271 )}
221 </Animated.View>
272 </View>
222273 </View>
223274 </Pressable>
224275 );
......@@ -237,11 +288,9 @@ export function ComposerDictationStatus(props: {
237288 }, [props.phase, recordingVisibility]);
238289 const waveformStyle = useAnimatedStyle(() => ({
239290 opacity: recordingVisibility.value,
240 transform: [{ translateY: -4 * (1 - recordingVisibility.value) }],
241291 }));
242292 const labelStyle = useAnimatedStyle(() => ({
243293 opacity: 1 - recordingVisibility.value,
244 transform: [{ translateY: 4 * recordingVisibility.value }],
245294 }));
246295
247296 if (!props.presentation.statusLabel) return null;
......@@ -340,6 +389,16 @@ export function ComposerDictationPrimaryAction(props: {
340389 );
341390 }
342391
392 return <ComposerDictationStartAction {...props} />;
393}
394
395export function ComposerDictationStartAction(props: {
396 readonly state: VoiceInputState;
397 readonly isAvailable: boolean;
398 readonly disabled?: boolean;
399 readonly onStart: () => void;
400 readonly onCancel: () => void;
401}) {
343402 if (!props.isAvailable) return null;
344403 const openSettings = props.state.phase === "error" && props.state.errorAction === "settings";
345404 return (
apps/mobile/src/lib/composerFiles.test.ts+189-1
......@@ -1,8 +1,10 @@
11import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import type { ImagePickerAsset } from "expo-image-picker";
23
34const mocks = vi.hoisted(() => ({
45 documentUri: "file:///documents",
56 pickFile: vi.fn(),
7 pickMedia: vi.fn(),
68 copy: vi.fn(),
79 delete: vi.fn(),
810 open: vi.fn(),
......@@ -37,6 +39,14 @@ vi.mock("expo-file-system", () => {
3739 return mocks.size(this.uri) ?? null;
3840 }
3941
42 get name(): string {
43 return this.uri.split("/").at(-1) ?? "";
44 }
45
46 get type(): string {
47 return "video/quicktime";
48 }
49
4050 create(): void {}
4151
4252 open(mode: string) {
......@@ -64,18 +74,23 @@ vi.mock("expo-file-system", () => {
6474 };
6575});
6676
77vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia }));
6778vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" }));
6879
6980import {
7081 persistComposerAttachmentFile,
7182 pickComposerFiles,
83 pickComposerImages,
84 pickComposerMedia,
7285 removePersistedComposerAttachmentFile,
7386} from "./composerImages";
87import { isForegroundHandoffActive } from "./foreground-handoff";
7488
75describe("pickComposerFiles", () => {
89describe("composer file attachments", () => {
7690 beforeEach(() => {
7791 mocks.documentUri = "file:///documents";
7892 mocks.pickFile.mockReset();
93 mocks.pickMedia.mockReset();
7994 mocks.copy.mockReset();
8095 mocks.delete.mockReset();
8196 mocks.open.mockReset();
......@@ -83,6 +98,179 @@ describe("pickComposerFiles", () => {
8398 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
8499 });
85100
101 describe("photo library videos", () => {
102 const image: ImagePickerAsset = {
103 uri: "file:///picker/photo.png",
104 type: "image",
105 fileName: "photo.png",
106 mimeType: "image/png",
107 fileSize: 3,
108 base64: "YWJj",
109 width: 1,
110 height: 1,
111 };
112 const video: ImagePickerAsset = {
113 uri: "file:///picker/clip.mov",
114 type: "video",
115 fileName: "clip.mov",
116 mimeType: "video/quicktime",
117 fileSize: 20 * 1024 * 1024,
118 base64: null,
119 width: 1920,
120 height: 1080,
121 };
122
123 it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => {
124 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
125 mocks.size.mockReturnValue(video.fileSize);
126
127 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 });
128
129 expect(mocks.pickMedia).toHaveBeenCalledWith(
130 expect.objectContaining({
131 mediaTypes: ["images", "videos"],
132 shouldDownloadFromNetwork: true,
133 }),
134 );
135 expect(result).toEqual({
136 attachments: [
137 expect.objectContaining({ type: "image", dataUrl: "data:image/png;base64,YWJj" }),
138 {
139 id: "attachment-id",
140 type: "file",
141 name: "clip.mov",
142 mimeType: "video/quicktime",
143 sizeBytes: video.fileSize,
144 fileUri: "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
145 },
146 ],
147 error: null,
148 });
149 expect(mocks.copy).toHaveBeenCalledWith(
150 video.uri,
151 "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
152 );
153 expect(mocks.delete).not.toHaveBeenCalled();
154 });
155
156 it("keeps image-only destinations on the image picker path", async () => {
157 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image] });
158
159 const result = await pickComposerImages({ existingCount: 0 });
160
161 expect(mocks.pickMedia).toHaveBeenCalledWith(
162 expect.objectContaining({ mediaTypes: ["images"] }),
163 );
164 expect(result.images).toEqual([
165 expect.objectContaining({ type: "image", name: "photo.png" }),
166 ]);
167 expect(result.error).toBeNull();
168 expect(mocks.copy).not.toHaveBeenCalled();
169 });
170
171 it("does not persist videos when the destination lacks file support", async () => {
172 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [video, image] });
173
174 const result = await pickComposerMedia({ existingCount: 0 });
175
176 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
177 expect(result.error).toBe("Video attachments are unavailable here.");
178 expect(mocks.copy).not.toHaveBeenCalled();
179 });
180
181 it("uses local video metadata when the picker omits its name, MIME type, or size", async () => {
182 mocks.pickMedia.mockResolvedValue({
183 canceled: false,
184 assets: [{ ...video, fileName: null, mimeType: undefined, fileSize: undefined }],
185 });
186
187 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 });
188
189 expect(result.error).toBeNull();
190 expect(result.attachments).toEqual([
191 expect.objectContaining({
192 type: "file",
193 name: "clip.mov",
194 mimeType: "video/quicktime",
195 sizeBytes: 42,
196 }),
197 ]);
198 });
199
200 it.each([
201 {
202 reason: "picker size exceeds the server limit",
203 reported: 2 * 1024 * 1024,
204 stored: 42,
205 limit: 1024 * 1024,
206 error: "'clip.mov' exceeds the 1 MB attachment limit.",
207 },
208 {
209 reason: "actual size exceeds the server limit",
210 reported: 42,
211 stored: 2 * 1024 * 1024,
212 limit: 1024 * 1024,
213 error: "'clip.mov' exceeds the 1 MB attachment limit.",
214 },
215 {
216 reason: "stored copy is empty",
217 reported: 42,
218 stored: 0,
219 limit: 1024 * 1024,
220 error: "'clip.mov' is empty or could not be read.",
221 },
222 {
223 reason: "server advertises more than the contract limit",
224 reported: 51 * 1024 * 1024,
225 stored: 42,
226 limit: 80 * 1024 * 1024,
227 error: "'clip.mov' exceeds the 50 MB attachment limit.",
228 },
229 ])(
230 "rejects a video when $reason while retaining the selected photo",
231 async ({ reported, stored, limit, error }) => {
232 mocks.pickMedia.mockResolvedValue({
233 canceled: false,
234 assets: [{ ...video, fileSize: reported }, image],
235 });
236 mocks.size.mockReturnValue(stored);
237
238 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit });
239
240 expect(result).toEqual({
241 attachments: [expect.objectContaining({ type: "image" })],
242 error,
243 });
244 if (stored === 0) {
245 expect(mocks.delete).toHaveBeenCalledWith(
246 "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
247 );
248 }
249 },
250 );
251
252 it("applies the remaining attachment slots to photos and videos together", async () => {
253 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
254
255 const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 });
256
257 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
258 expect(result.error).toBe("You can attach up to 8 attachments per message.");
259 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));
260 expect(mocks.copy).not.toHaveBeenCalled();
261 });
262
263 it("reports a native video retrieval error and ends the foreground handoff", async () => {
264 mocks.pickMedia.mockRejectedValue(new Error("Could not download video from iCloud."));
265
266 await expect(pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 })).resolves.toEqual({
267 attachments: [],
268 error: "Could not download video from iCloud.",
269 });
270 expect(isForegroundHandoffActive()).toBe(false);
271 });
272 });
273
86274 it("copies picked files into app-owned storage without loading their contents", async () => {
87275 mocks.pickFile.mockResolvedValue({
88276 canceled: false,
apps/mobile/src/lib/composerImages.ts+101-36
......@@ -157,6 +157,40 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis
157157 }
158158}
159159
160async function createComposerFileAttachment(input: {
161 readonly uri: string;
162 readonly name: string;
163 readonly mimeType: string;
164 readonly sizeBytes: number | null;
165 readonly maxBytes: number;
166}): Promise<DraftComposerFileAttachment> {
167 if (input.sizeBytes !== null && input.sizeBytes > input.maxBytes) {
168 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
169 }
170 const { File } = await import("expo-file-system");
171 const fileUri = await persistComposerAttachmentFile(input.uri, input.name, input.maxBytes);
172 try {
173 const sizeBytes = new File(fileUri).size ?? input.sizeBytes ?? 0;
174 if (sizeBytes <= 0) {
175 throw new Error(`'${input.name}' is empty or could not be read.`);
176 }
177 if (sizeBytes > input.maxBytes) {
178 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
179 }
180 return {
181 id: uuidv4(),
182 type: "file",
183 name: input.name,
184 mimeType: input.mimeType,
185 sizeBytes,
186 fileUri,
187 };
188 } catch (error) {
189 await removePersistedComposerAttachmentFile(fileUri);
190 throw error;
191 }
192}
193
160194export async function pickComposerFiles(input: {
161195 readonly existingCount: number;
162196 readonly maxBytes?: number;
......@@ -199,32 +233,16 @@ export async function pickComposerFiles(input: {
199233 // contract rejects empty names at send time, so fall back before the name
200234 // reaches storage, errors, or the attachment itself.
201235 const name = file.name.trim().length > 0 ? file.name : "file";
202 const sizeBytes = file.size ?? null;
203 if (sizeBytes !== null && sizeBytes > maxBytes) {
204 error = fileAttachmentTooLargeMessage(name, maxBytes);
205 continue;
206 }
207236 try {
208 const fileUri = await persistComposerAttachmentFile(file.uri, name, maxBytes);
209 const storedSizeBytes = new File(fileUri).size ?? sizeBytes ?? 0;
210 if (storedSizeBytes <= 0) {
211 await removePersistedComposerAttachmentFile(fileUri);
212 error = `'${name}' is empty or could not be read.`;
213 continue;
214 }
215 if (storedSizeBytes > maxBytes) {
216 await removePersistedComposerAttachmentFile(fileUri);
217 error = fileAttachmentTooLargeMessage(name, maxBytes);
218 continue;
219 }
220 attachments.push({
221 id: uuidv4(),
222 type: "file",
223 name,
224 mimeType: file.type || "application/octet-stream",
225 sizeBytes: storedSizeBytes,
226 fileUri,
227 });
237 attachments.push(
238 await createComposerFileAttachment({
239 uri: file.uri,
240 name,
241 mimeType: file.type || "application/octet-stream",
242 sizeBytes: file.size ?? null,
243 maxBytes,
244 }),
245 );
228246 } catch (cause) {
229247 error = cause instanceof Error ? cause.message : `Could not read '${name}'.`;
230248 }
......@@ -239,7 +257,7 @@ async function loadImagePicker() {
239257 try {
240258 return await import("expo-image-picker");
241259 } catch (error) {
242 throw new Error("Image attachments are unavailable right now.", { cause: error });
260 throw new Error("The photo library is unavailable right now.", { cause: error });
243261 }
244262}
245263
......@@ -254,12 +272,27 @@ async function loadClipboard() {
254272export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{
255273 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
256274 readonly error: string | null;
275}> {
276 const result = await pickComposerMedia(input);
277 return {
278 images: result.attachments.filter((attachment) => attachment.type === "image"),
279 error: result.error,
280 };
281}
282
283/** Videos use file uploads; omit maxVideoBytes for image-only destinations. */
284export async function pickComposerMedia(input: {
285 readonly existingCount: number;
286 readonly maxVideoBytes?: number;
287}): Promise<{
288 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
289 readonly error: string | null;
257290}> {
258291 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
259292 if (remainingSlots <= 0) {
260293 return {
261 images: [],
262 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`,
294 attachments: [],
295 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`,
263296 };
264297 }
265298
......@@ -268,9 +301,8 @@ export async function pickComposerImages(input: { readonly existingCount: number
268301 imagePicker = await loadImagePicker();
269302 } catch (error) {
270303 return {
271 images: [],
272 error:
273 error instanceof Error ? error.message : "Image attachments are unavailable right now.",
304 attachments: [],
305 error: error instanceof Error ? error.message : "The photo library is unavailable right now.",
274306 };
275307 }
276308
......@@ -280,28 +312,61 @@ export async function pickComposerImages(input: { readonly existingCount: number
280312 let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>;
281313 try {
282314 result = await imagePicker.launchImageLibraryAsync({
283 mediaTypes: ["images"],
315 mediaTypes: input.maxVideoBytes === undefined ? ["images"] : ["images", "videos"],
284316 allowsMultipleSelection: true,
285317 selectionLimit: remainingSlots,
286318 base64: true,
287319 quality: 1,
320 shouldDownloadFromNetwork: true,
288321 });
322 } catch (error) {
323 return {
324 attachments: [],
325 error: error instanceof Error ? error.message : "Could not open the photo library.",
326 };
289327 } finally {
290328 endHandoff();
291329 }
292330
293331 if (result.canceled) {
294332 return {
295 images: [],
333 attachments: [],
296334 error: null,
297335 };
298336 }
299337
300 const nextImages: DraftComposerImageAttachment[] = [];
338 const attachments: DraftComposerAttachment[] = [];
301339 let error: string | null = null;
302340
303341 for (const asset of result.assets) {
342 if (attachments.length >= remainingSlots) {
343 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
344 break;
345 }
304346 const mimeType = asset.mimeType?.toLowerCase();
347 if (asset.type === "video" || mimeType?.startsWith("video/")) {
348 if (input.maxVideoBytes === undefined) {
349 error = "Video attachments are unavailable here.";
350 continue;
351 }
352 try {
353 const { File } = await import("expo-file-system");
354 const file = new File(asset.uri);
355 attachments.push(
356 await createComposerFileAttachment({
357 uri: asset.uri,
358 name: asset.fileName?.trim() || file.name || "video",
359 mimeType: mimeType || file.type || "application/octet-stream",
360 sizeBytes: asset.fileSize ?? null,
361 maxBytes: clampFileAttachmentUploadBytes(input.maxVideoBytes),
362 }),
363 );
364 } catch (cause) {
365 error =
366 cause instanceof Error ? cause.message : `Could not read '${asset.fileName ?? "video"}'.`;
367 }
368 continue;
369 }
305370 if (!mimeType?.startsWith("image/")) {
306371 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
307372 continue;
......@@ -323,7 +388,7 @@ export async function pickComposerImages(input: { readonly existingCount: number
323388 continue;
324389 }
325390
326 nextImages.push({
391 attachments.push({
327392 id: uuidv4(),
328393 type: "image",
329394 name: asset.fileName ?? "image",
......@@ -335,7 +400,7 @@ export async function pickComposerImages(input: { readonly existingCount: number
335400 }
336401
337402 return {
338 images: nextImages,
403 attachments,
339404 error,
340405 };
341406}
apps/mobile/src/lib/menu-action-colors.test.ts created+67
......@@ -0,0 +1,67 @@
1import type { MenuAction } from "@react-native-menu/menu";
2import { describe, expect, it } from "vite-plus/test";
3
4import { withMenuActionIconColors } from "./menu-action-colors";
5
6describe("withMenuActionIconColors", () => {
7 it.each(["#111111", "#eeeeee"])(
8 "gives icons a visible color at every menu depth for the %s theme",
9 (icon) => {
10 const actions: MenuAction[] = [
11 { id: "photos", title: "Photos", image: "photo" },
12 {
13 title: "Thread",
14 subactions: [
15 {
16 title: "Pinned thread",
17 image: "pin",
18 subactions: [{ title: "Move up", image: "arrow.up" }],
19 },
20 ],
21 },
22 ];
23
24 const result = withMenuActionIconColors(actions, { icon, destructiveIcon: "#ff0000" });
25
26 expect(result[0]?.imageColor).toBe(icon);
27 expect(result[1]).not.toHaveProperty("imageColor");
28 expect(result[1]?.subactions?.[0]?.imageColor).toBe(icon);
29 expect(result[1]?.subactions?.[0]?.subactions?.[0]?.imageColor).toBe(icon);
30 expect(actions[0]).not.toHaveProperty("imageColor");
31 expect(actions[1]?.subactions?.[0]).not.toHaveProperty("imageColor");
32 },
33 );
34
35 it("uses the destructive color while retaining action state and attributes", () => {
36 const action: MenuAction = {
37 id: "delete",
38 title: "Delete",
39 image: "trash",
40 state: "off",
41 attributes: { destructive: true, disabled: true },
42 };
43
44 expect(
45 withMenuActionIconColors([action], {
46 icon: "#111111",
47 destructiveIcon: "#cc0000",
48 }),
49 ).toEqual([{ ...action, imageColor: "#cc0000" }]);
50 });
51
52 it.each(["#123456", "transparent", 0])("preserves explicit icon color %s", (imageColor) => {
53 const action: MenuAction = {
54 title: "Delete",
55 image: "trash",
56 imageColor,
57 attributes: { destructive: true },
58 };
59
60 expect(
61 withMenuActionIconColors([action], {
62 icon: "#111111",
63 destructiveIcon: "#cc0000",
64 }),
65 ).toEqual([action]);
66 });
67});
apps/mobile/src/lib/menu-action-colors.ts created+24
......@@ -0,0 +1,24 @@
1import type { MenuAction } from "@react-native-menu/menu";
2
3// MenuView's iOS bridge treats an omitted imageColor as transparent.
4export function withMenuActionIconColors(
5 actions: readonly MenuAction[],
6 colors: {
7 readonly icon: MenuAction["imageColor"];
8 readonly destructiveIcon: MenuAction["imageColor"];
9 },
10): MenuAction[] {
11 return actions.map((action) => ({
12 ...action,
13 ...(action.image
14 ? {
15 imageColor:
16 action.imageColor ??
17 (action.attributes?.destructive ? colors.destructiveIcon : colors.icon),
18 }
19 : {}),
20 ...(action.subactions
21 ? { subactions: withMenuActionIconColors(action.subactions, colors) }
22 : {}),
23 }));
24}
apps/mobile/src/state/use-composer-drafts.test.ts+20-11
......@@ -221,40 +221,49 @@ describe("mobile composer drafts", () => {
221221 });
222222 });
223223
224 it("caps appended attachments at the send limit against the live draft", () => {
224 it("releases videos rejected by the live draft limit and keeps accepted files", async () => {
225 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
226 onTestFinished(() => outboxLoad.mockRestore());
227 const cleanup = Promise.withResolvers<void>();
228 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
229 cleanup.resolve();
230 });
225231 const makeAttachment = (id: string) => ({
226232 id,
227233 type: "file" as const,
228 name: `${id}.pdf`,
229 mimeType: "application/pdf",
234 name: `${id}.mov`,
235 mimeType: "video/quicktime",
230236 sizeBytes: 42,
231 fileUri: `file:///documents/t3-composer-attachments/${id}.pdf`,
237 fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,
232238 });
239 const draftKey = "new-task:environment-1:project-cap";
233240 const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`));
234241 appAtomRegistry.set(composerDraftsAtom, {
235 "environment-1:thread-cap": { text: "send this", attachments: existing },
242 [draftKey]: { text: "send this", attachments: existing },
236243 });
237244
238 const rejected = appendComposerDraftAttachments("environment-1:thread-cap", [
245 const rejected = appendComposerDraftAttachments(draftKey, [
239246 makeAttachment("incoming-1"),
240247 makeAttachment("incoming-2"),
241248 ]);
242249
243250 expect(rejected).toBe(1);
244 const draft = appAtomRegistry.get(composerDraftsAtom)["environment-1:thread-cap"];
251 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
245252 expect(draft?.attachments).toHaveLength(8);
246253 expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");
254 await cleanup.promise;
255 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(
256 makeAttachment("incoming-2").fileUri,
257 );
247258
248259 // Restore paths bypass the cap so a failed send never drops its files.
249260 const overflowRejected = appendComposerDraftAttachments(
250 "environment-1:thread-cap",
261 draftKey,
251262 [makeAttachment("restored-1")],
252263 { allowOverflow: true },
253264 );
254265 expect(overflowRejected).toBe(0);
255 expect(
256 appAtomRegistry.get(composerDraftsAtom)["environment-1:thread-cap"]?.attachments,
257 ).toHaveLength(9);
266 expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9);
258267 });
259268
260269 it("keeps shared attachment files until every draft releases them", async () => {
apps/mobile/src/state/use-thread-composer-state.ts+13-8
......@@ -28,7 +28,7 @@ import {
2828 convertPastedImagesToAttachments,
2929 pasteComposerClipboard,
3030 pickComposerFiles,
31 pickComposerImages,
31 pickComposerMedia,
3232} from "../lib/composerImages";
3333import type { DraftComposerImageAttachment } from "../lib/composerImages";
3434import { scopedThreadKey } from "../lib/scopedEntities";
......@@ -316,26 +316,31 @@ export function useThreadComposerState() {
316316 [selectedThreadShell],
317317 );
318318
319 const onPickDraftImages = useCallback(async () => {
319 const onPickDraftMedia = useCallback(async () => {
320320 if (!selectedThreadShell) {
321321 return;
322322 }
323323
324324 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
325 const result = await pickComposerImages({
325 const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities;
326 const result = await pickComposerMedia({
326327 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
328 maxVideoBytes:
329 capabilities?.attachmentUploads === true
330 ? capabilities.fileAttachments?.maxUploadBytes
331 : undefined,
327332 });
328 const rejectedImageCount = appendComposerDraftAttachments(threadKey, result.images);
333 const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments);
329334 const problems = [
330335 ...(result.error ? [result.error] : []),
331 ...(rejectedImageCount > 0
336 ...(rejectedCount > 0
332337 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
333338 : []),
334339 ];
335340 if (problems.length > 0) {
336 Alert.alert("Could not attach image", problems.join("\n\n"));
341 Alert.alert("Could not attach photo or video", problems.join("\n\n"));
337342 }
338 }, [composerDrafts, selectedThreadShell]);
343 }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]);
339344
340345 const onPickDraftFiles = useCallback(async () => {
341346 if (!selectedThreadShell) {
......@@ -470,7 +475,7 @@ export function useThreadComposerState() {
470475 runtimeMode,
471476 interactionMode,
472477 onChangeDraftMessage,
473 onPickDraftImages,
478 onPickDraftMedia,
474479 onPickDraftFiles,
475480 onPasteIntoDraft,
476481 onNativePasteImages,
docs/internals/mobile-navigation.md+7
......@@ -41,3 +41,10 @@ After changing a dependency patch, refresh CocoaPods before rebuilding an
4141existing iOS project. pnpm installs each patch hash in a different directory;
4242an old Pods project can keep compiling the previous directory even though Metro
4343and `apps/mobile/node_modules` resolve to the new patch.
44
45`ControlPillMenu` resolves semantic icon colors through `withUniwind` and supplies them to every
46iOS `MenuView` action, including nested actions. The menu library's Fabric bridge converts a missing
47`imageColor` to transparent, so callers should use this wrapper instead of
48rendering `MenuView` directly. Explicit colors are preserved, and destructive
49actions default to the theme's danger foreground color. Native stack header menus
50use a separate implementation and do not need this workaround.
docs/user/composer.md+14-7
......@@ -4,15 +4,21 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee
44composer and shows how many characters need to be removed. Shorten the draft or split it into
55multiple messages, then send again in the same thread.
66
7On mobile, an empty composer shows an interrupt button while the agent is working. Adding text
8or an attachment replaces it with the send button. This applies to both compact and expanded
9composers.
10
711You can attach images up to 10 MB. On servers that support file uploads, you can also
8attach text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised
12attach videos, text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised
913by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
1014upload directly to the environment, where your agent can read, copy, or edit them by their file path.
1115
1216On web and desktop, attachments upload as soon as you add them. The send button becomes available
13after every upload finishes. Failed uploads can be retried or removed. On mobile, the **+** control
14offers Photos, and adds Files when the connected server supports file uploads. You can share a file
15into T3 Code from any app through the system share sheet. Mobile uploads happen when the message
17after every upload finishes. Failed uploads can be retried or removed. On mobile, tap **+** to open
18the photo library from either the compact or expanded composer. When the connected server supports
19file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**.
20Videos use the server's file upload limit. You can also share photos, videos, and files into
21T3 Code from other apps through the system share sheet. Mobile uploads happen when the message
1622sends, so queued messages keep their files until they deliver. Select a received file on mobile
1723to save it or open it in another app through the system share sheet.
1824
......@@ -41,9 +47,10 @@ send.
4147## Voice input on iPhone
4248
4349On supported iPhones with iOS 26 or later, tap the microphone in the composer to record a message.
44An expanded composer keeps your draft visible and replaces its toolbar with waves that respond to
45your voice. A collapsed composer shows a compact recording strip instead. Tap the checkmark to
46finish and transcribe on your device. The waves fade into a transcription status, then the usual
50An expanded composer keeps your draft visible and flips its bottom toolbar into recording controls
51with waves that respond to your voice. A collapsed composer flips into a compact recording strip
52without changing height. Tap the checkmark to finish and transcribe on your device. The waves fade
53into a transcription status, then the usual
4754controls return with the text inserted at the selection where recording started. If the keyboard
4855is open when you start, it stays open during voice input. You can review and edit the text before
4956you send it.