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] = [...@@ -148,6 +148,7 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
148 supportsText: true,148 supportsText: true,
149 supportsWebUrlWithMaxCount: 1,149 supportsWebUrlWithMaxCount: 1,
150 supportsImageWithMaxCount: 8,150 supportsImageWithMaxCount: 8,
151 supportsMovieWithMaxCount: 8,
151 supportsFileWithMaxCount: 8,152 supportsFileWithMaxCount: 8,
152 },153 },
153 },154 },
apps/mobile/src/components/AppSymbol.tsx+2
...@@ -59,6 +59,7 @@ import IconMinus from "@tabler/icons-react-native/IconMinus";...@@ -59,6 +59,7 @@ import IconMinus from "@tabler/icons-react-native/IconMinus";
59import IconMoon from "@tabler/icons-react-native/IconMoon";59import IconMoon from "@tabler/icons-react-native/IconMoon";
60import IconNetwork from "@tabler/icons-react-native/IconNetwork";60import IconNetwork from "@tabler/icons-react-native/IconNetwork";
61import IconPalette from "@tabler/icons-react-native/IconPalette";61import IconPalette from "@tabler/icons-react-native/IconPalette";
62import IconPhoto from "@tabler/icons-react-native/IconPhoto";
62import IconPin from "@tabler/icons-react-native/IconPin";63import IconPin from "@tabler/icons-react-native/IconPin";
63import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff";64import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff";
64import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay";65import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay";
...@@ -133,6 +134,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -133,6 +134,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
133 magnifyingglass: IconSearch,134 magnifyingglass: IconSearch,
134 paintbrush: IconPalette,135 paintbrush: IconPalette,
135 "person.crop.circle": IconUserCircle,136 "person.crop.circle": IconUserCircle,
137 photo: IconPhoto,
136 pin: IconPin,138 pin: IconPin,
137 "pin.slash": IconPinnedOff,139 "pin.slash": IconPinnedOff,
138 play: IconPlayerPlay,140 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: {...@@ -113,6 +113,7 @@ export function ComposerToolbarRow(props: {
113113
114export function ComposerToolbarScroller(props: {114export function ComposerToolbarScroller(props: {
115 readonly children: ReactNode;115 readonly children: ReactNode;
116 readonly align?: "start" | "end";
116 /** Only for non-Uniwind surfaces such as the native terminal palette. */117 /** Only for non-Uniwind surfaces such as the native terminal palette. */
117 readonly fadeOpaque?: string;118 readonly fadeOpaque?: string;
118 /** Only for non-Uniwind surfaces such as the native terminal palette. */119 /** Only for non-Uniwind surfaces such as the native terminal palette. */
...@@ -167,6 +168,8 @@ export function ComposerToolbarScroller(props: {...@@ -167,6 +168,8 @@ export function ComposerToolbarScroller(props: {
167 showsHorizontalScrollIndicator={false}168 showsHorizontalScrollIndicator={false}
168 contentContainerStyle={{169 contentContainerStyle={{
169 alignItems: "center",170 alignItems: "center",
171 flexGrow: props.align === "end" ? 1 : undefined,
172 justifyContent: props.align === "end" ? "flex-end" : undefined,
170 gap: COMPOSER_TOOLBAR_GAP,173 gap: COMPOSER_TOOLBAR_GAP,
171 paddingLeft: 0,174 paddingLeft: 0,
172 paddingRight: props.contentPaddingRight ?? 1,175 paddingRight: props.contentPaddingRight ?? 1,
...@@ -214,6 +217,46 @@ export function ComposerToolbarScroller(props: {...@@ -214,6 +217,46 @@ export function ComposerToolbarScroller(props: {
214 );217 );
215}218}
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
217export function ComposerToolbarButton(props: {260export function ComposerToolbarButton(props: {
218 readonly icon?: ComponentProps<typeof SymbolView>["name"];261 readonly icon?: ComponentProps<typeof SymbolView>["name"];
219 readonly iconNode?: ReactNode;262 readonly iconNode?: ReactNode;
apps/mobile/src/components/ControlPill.tsx+30-3
...@@ -6,16 +6,38 @@ import {...@@ -6,16 +6,38 @@ import {
6 type ComponentProps,6 type ComponentProps,
7 type ReactElement,7 type ReactElement,
8 type ReactNode,8 type ReactNode,
9 useMemo,
9 useRef,10 useRef,
10} from "react";11} 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";
12import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";14import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1315
14import { cn } from "../lib/cn";16import { cn } from "../lib/cn";
17import { withMenuActionIconColors } from "../lib/menu-action-colors";
15import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";18import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
16import { SymbolView } from "./AppSymbol";19import { SymbolView } from "./AppSymbol";
17import { AppText as Text } from "./AppText";20import { 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
19export function ControlPill(props: {41export function ControlPill(props: {
20 readonly icon?: ComponentProps<typeof SymbolView>["name"];42 readonly icon?: ComponentProps<typeof SymbolView>["name"];
21 readonly iconNode?: ReactNode;43 readonly iconNode?: ReactNode;
...@@ -217,8 +239,13 @@ export function ControlPillMenu(...@@ -217,8 +239,13 @@ export function ControlPillMenu(
217 };239 };
218 }240 }
219 return (241 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 >
221 {children}248 {children}
222 </MenuView>249 </ThemedMenuView>
223 );250 );
224}251}
apps/mobile/src/features/sharing/incoming-share-model.test.ts+48
...@@ -181,6 +181,54 @@ describe("incoming native shares", () => {...@@ -181,6 +181,54 @@ describe("incoming native shares", () => {
181 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);181 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
182 });182 });
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
184 it("reports an unreadable shared file without calling it oversized", async () => {232 it("reports an unreadable shared file without calling it oversized", async () => {
185 const file: SharePayload = {233 const file: SharePayload = {
186 shareType: "file",234 shareType: "file",
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+64-59
...@@ -25,12 +25,13 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";...@@ -25,12 +25,13 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
2525
26import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";26import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
27import {27import {
28 ComposerActionButton,
28 ComposerInlineControl,29 ComposerInlineControl,
29 ComposerToolbarButton,
30 ComposerToolbarRow,30 ComposerToolbarRow,
31 ComposerToolbarScroller,31 ComposerToolbarScroller,
32} from "../../components/ComposerToolbar";32} from "../../components/ComposerToolbar";
33import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";33import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
34import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
34import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";35import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
35import { ProviderIcon } from "../../components/ProviderIcon";36import { ProviderIcon } from "../../components/ProviderIcon";
36import { SymbolView } from "../../components/AppSymbol";37import { SymbolView } from "../../components/AppSymbol";
...@@ -55,7 +56,7 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata";...@@ -55,7 +56,7 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
55import {56import {
56 convertPastedImagesToAttachments,57 convertPastedImagesToAttachments,
57 pickComposerFiles,58 pickComposerFiles,
58 pickComposerImages,59 pickComposerMedia,
59} from "../../lib/composerImages";60} from "../../lib/composerImages";
60import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";61import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
61import {62import {
...@@ -715,12 +716,20 @@ export function NewTaskDraftScreen(props: {...@@ -715,12 +716,20 @@ export function NewTaskDraftScreen(props: {
715 });716 });
716 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;717 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
717718
718 async function handlePickImages(): Promise<void> {719 async function handlePickMedia(): Promise<void> {
719 if (isComposerInteractionLocked || voiceInput.isBusy) {720 if (isComposerInteractionLocked || voiceInput.isBusy) {
720 return;721 return;
721 }722 }
722 const result = await pickComposerImages({ existingCount: flow.attachments.length });723 const capabilities = selectedEnvironmentServerConfig?.environment.capabilities;
723 const rejectedCount = result.images.length > 0 ? flow.appendAttachments(result.images) : 0;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;
724 const problems = [733 const problems = [
725 ...(result.error ? [result.error] : []),734 ...(result.error ? [result.error] : []),
726 ...(rejectedCount > 0735 ...(rejectedCount > 0
...@@ -728,7 +737,7 @@ export function NewTaskDraftScreen(props: {...@@ -728,7 +737,7 @@ export function NewTaskDraftScreen(props: {
728 : []),737 : []),
729 ];738 ];
730 if (problems.length > 0) {739 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"));
732 }741 }
733 }742 }
734743
...@@ -991,7 +1000,6 @@ export function NewTaskDraftScreen(props: {...@@ -991,7 +1000,6 @@ export function NewTaskDraftScreen(props: {
991 style={{1000 style={{
992 minHeight: 72,1001 minHeight: 72,
993 maxHeight: 160,1002 maxHeight: 160,
994 paddingHorizontal: 4,
995 paddingVertical: 4,1003 paddingVertical: 4,
996 }}1004 }}
997 textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}1005 textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}
...@@ -1115,7 +1123,7 @@ export function NewTaskDraftScreen(props: {...@@ -1115,7 +1123,7 @@ export function NewTaskDraftScreen(props: {
1115 );1123 );
11161124
1117 const composerDock = (1125 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 }}>
1119 {!voiceInput.isBusy && composerMenu.trigger && composerMenu.items.length > 0 ? (1127 {!voiceInput.isBusy && composerMenu.trigger && composerMenu.items.length > 0 ? (
1120 <View className="mb-2">1128 <View className="mb-2">
1121 <ComposerCommandPopover1129 <ComposerCommandPopover
...@@ -1134,12 +1142,11 @@ export function NewTaskDraftScreen(props: {...@@ -1134,12 +1142,11 @@ export function NewTaskDraftScreen(props: {
1134 minHeight: 140,1142 minHeight: 140,
1135 overflow: "hidden",1143 overflow: "hidden",
1136 paddingBottom: 6,1144 paddingBottom: 6,
1137 paddingHorizontal: 14,
1138 paddingTop: 14,1145 paddingTop: 14,
1139 }}1146 }}
1140 >1147 >
1141 {flow.attachments.length > 0 ? (1148 {flow.attachments.length > 0 ? (
1142 <View className="pb-2.5">1149 <View className="px-[14px] pb-2.5">
1143 <ComposerAttachmentStrip1150 <ComposerAttachmentStrip
1144 attachments={flow.attachments}1151 attachments={flow.attachments}
1145 imageBorderRadius={16}1152 imageBorderRadius={16}
...@@ -1153,12 +1160,17 @@ export function NewTaskDraftScreen(props: {...@@ -1153,12 +1160,17 @@ export function NewTaskDraftScreen(props: {
1153 </View>1160 </View>
1154 ) : null}1161 ) : null}
11551162
1156 {promptEditor}1163 <View className="px-[14px]">{promptEditor}</View>
1157 <View className="h-1" />1164 <View className="h-1" />
11581165
1159 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} collapsable={false}>1166 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} collapsable={false}>
1160 <ComposerDictationToolbar showsDictation={isVoiceInputPresented}>1167 <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 >
1162 <ComposerDictationCancelAction1174 <ComposerDictationCancelAction
1163 presentation={voicePresentation}1175 presentation={voicePresentation}
1164 onCancel={voiceInput.cancel}1176 onCancel={voiceInput.cancel}
...@@ -1172,58 +1184,52 @@ export function NewTaskDraftScreen(props: {...@@ -1172,58 +1184,52 @@ export function NewTaskDraftScreen(props: {
1172 onDismissError={voiceInput.cancel}1184 onDismissError={voiceInput.cancel}
1173 />1185 />
1174 ) : (1186 ) : (
1175 <ComposerToolbarScroller contentPaddingRight={8} fadeSurface="sheet">1187 <>
1176 <ComposerToolbarButton1188 <ComposerAttachmentButton
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"
1197 disabled={isComposerInteractionLocked}1189 disabled={isComposerInteractionLocked}
1198 emphasized1190 supportsFiles={Boolean(
1199 iconNode={1191 selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments,
1200 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />1192 )}
1201 }1193 onPickMedia={handlePickMedia}
1202 label={flow.selectedModelOption?.label ?? "Choose model"}1194 onPickFiles={handlePickFiles}
1203 maxWidth={152}
1204 onPress={settingsSheetPresentation.open}
1205 />1195 />
1206 {flow.planModeEnabled ? (1196 <ComposerToolbarScroller align="end" contentPaddingRight={0} fadeSurface="sheet">
1207 <ComposerInlineControl1197 <ComposerInlineControl
1208 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}1198 accessibilityLabel="Model and reasoning settings"
1209 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
1210 disabled={isComposerInteractionLocked}1199 disabled={isComposerInteractionLocked}
1211 emphasized1200 emphasized
1212 icon={1201 iconNode={
1213 flow.interactionMode === "plan"1202 <ProviderIcon
1214 ? { ios: "list.bullet.clipboard", android: "auto_awesome" }1203 provider={flow.selectedModelOption?.providerDriver}
1215 : { ios: "hammer", android: "construction" }1204 size={16}
1216 }1205 />
1217 label={flow.interactionMode === "plan" ? "Plan" : "Build"}
1218 onPress={() =>
1219 flow.setInteractionMode(
1220 flow.interactionMode === "plan" ? "default" : "plan",
1221 )
1222 }1206 }
1223 showChevron={false}1207 label={flow.selectedModelOption?.label ?? "Choose model"}
1208 maxWidth={152}
1209 onPress={settingsSheetPresentation.open}
1224 />1210 />
1225 ) : null}1211 {flow.planModeEnabled ? (
1226 </ComposerToolbarScroller>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 </>
1227 )}1233 )}
1228 <ComposerDictationPrimaryAction1234 <ComposerDictationPrimaryAction
1229 state={voiceInput.state}1235 state={voiceInput.state}
...@@ -1235,7 +1241,7 @@ export function NewTaskDraftScreen(props: {...@@ -1235,7 +1241,7 @@ export function NewTaskDraftScreen(props: {
1235 onCancel={voiceInput.cancel}1241 onCancel={voiceInput.cancel}
1236 />1242 />
1237 {voicePresentation.showsSend ? (1243 {voicePresentation.showsSend ? (
1238 <ComposerToolbarButton1244 <ComposerActionButton
1239 accessibilityLabel={1245 accessibilityLabel={
1240 flow.submitting1246 flow.submitting
1241 ? "Starting task"1247 ? "Starting task"
...@@ -1246,7 +1252,6 @@ export function NewTaskDraftScreen(props: {...@@ -1246,7 +1252,6 @@ export function NewTaskDraftScreen(props: {
1246 disabled={!canStart}1252 disabled={!canStart}
1247 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}1253 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
1248 onPress={() => void handleStart()}1254 onPress={() => void handleStart()}
1249 showChevron={false}
1250 variant="primary"1255 variant="primary"
1251 />1256 />
1252 ) : null}1257 ) : null}
apps/mobile/src/features/threads/ThreadComposer.tsx+71-79
...@@ -19,15 +19,7 @@ import {...@@ -19,15 +19,7 @@ import {
19 useState,19 useState,
20 type RefObject,20 type RefObject,
21} from "react";21} from "react";
22import {22import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";
23 ActivityIndicator,
24 Alert,
25 Image,
26 Platform,
27 Pressable,
28 View,
29 type ViewStyle,
30} from "react-native";
31import ImageViewing from "react-native-image-viewing";23import ImageViewing from "react-native-image-viewing";
32import Animated, {24import Animated, {
33 FadeIn,25 FadeIn,
...@@ -46,15 +38,15 @@ import { scopedThreadKey } from "../../lib/scopedEntities";...@@ -46,15 +38,15 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
4638
47import { AppText as Text } from "../../components/AppText";39import { AppText as Text } from "../../components/AppText";
48import { SymbolView } from "../../components/AppSymbol";40import { SymbolView } from "../../components/AppSymbol";
41import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
49import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";42import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
50import { GlassSurface } from "../../components/GlassSurface";43import { GlassSurface } from "../../components/GlassSurface";
51import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";44import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
52import {45import {
46 ComposerActionButton,
53 ComposerInlineControl,47 ComposerInlineControl,
54 ComposerToolbarButton,
55 ComposerToolbarRow,48 ComposerToolbarRow,
56} from "../../components/ComposerToolbar";49} from "../../components/ComposerToolbar";
57import { ControlPill } from "../../components/ControlPill";
58import { ProviderIcon } from "../../components/ProviderIcon";50import { ProviderIcon } from "../../components/ProviderIcon";
59import type { DraftComposerAttachment } from "../../lib/composerImages";51import type { DraftComposerAttachment } from "../../lib/composerImages";
60import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";52import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
...@@ -67,6 +59,7 @@ import {...@@ -67,6 +59,7 @@ import {
67 ComposerDictationCancelAction,59 ComposerDictationCancelAction,
68 ComposerDictationDraftContent,60 ComposerDictationDraftContent,
69 ComposerDictationPrimaryAction,61 ComposerDictationPrimaryAction,
62 ComposerDictationStartAction,
70 ComposerDictationStatus,63 ComposerDictationStatus,
71 ComposerDictationToolbar,64 ComposerDictationToolbar,
72} from "../voice-input/ComposerDictationControl";65} from "../voice-input/ComposerDictationControl";
...@@ -115,7 +108,7 @@ export interface ThreadComposerProps {...@@ -115,7 +108,7 @@ export interface ThreadComposerProps {
115 readonly projectCwd: string | null;108 readonly projectCwd: string | null;
116 readonly editorRef?: RefObject<ComposerEditorHandle | null>;109 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
117 readonly onChangeDraftMessage: (value: string) => void;110 readonly onChangeDraftMessage: (value: string) => void;
118 readonly onPickDraftImages: () => Promise<void>;111 readonly onPickDraftMedia: () => Promise<void>;
119 readonly onPickDraftFiles: () => Promise<void>;112 readonly onPickDraftFiles: () => Promise<void>;
120 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;113 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
121 readonly onRemoveDraftImage: (imageId: string) => void;114 readonly onRemoveDraftImage: (imageId: string) => void;
...@@ -315,8 +308,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -315,8 +308,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
315 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);308 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;309 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
317 const showStopAction =310 const showStopAction =
318 props.selectedThread.session?.status === "running" ||311 !hasContent &&
319 props.selectedThread.session?.status === "starting";312 (props.selectedThread.session?.status === "running" ||
313 props.selectedThread.session?.status === "starting");
320314
321 const sendLabel =315 const sendLabel =
322 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";316 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
...@@ -528,7 +522,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -528,7 +522,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
528522
529 return (523 return (
530 <Animated.View524 <Animated.View
531 className="px-4"525 className="px-[12px]"
532 style={{526 style={{
533 paddingTop: isExpanded ? 8 : 6,527 paddingTop: isExpanded ? 8 : 6,
534 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),528 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
...@@ -571,7 +565,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -571,7 +565,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
571 minHeight: 140,565 minHeight: 140,
572 overflow: "hidden" as const,566 overflow: "hidden" as const,
573 paddingBottom: 6,567 paddingBottom: 6,
574 paddingHorizontal: 14,
575 paddingTop: 14,568 paddingTop: 14,
576 }569 }
577 : {570 : {
...@@ -579,18 +572,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -579,18 +572,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
579 // shape morph stays bounded while rendering as a capsule.572 // shape morph stays bounded while rendering as a capsule.
580 borderRadius: 27,573 borderRadius: 27,
581 overflow: "hidden" as const,574 overflow: "hidden" as const,
582 paddingHorizontal: 14,575 paddingVertical: 2,
583 paddingVertical: showsCompactDictation ? 2 : 5,
584 }576 }
585 }577 }
586 >578 >
587 <ComposerDictationDraftContent579 <ComposerDictationDraftContent
588 className={isExpanded ? undefined : "flex-row items-center"}580 className={isExpanded ? undefined : "flex-row items-center"}
589 collapsed={showsCompactDictation}581 compact={!isExpanded}
582 hidden={showsCompactDictation}
590 >583 >
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}
591 {isExpanded ? (593 {isExpanded ? (
592 <Animated.View594 <Animated.View
593 className={props.draftAttachments.length > 0 ? "pb-2.5" : undefined}595 className={props.draftAttachments.length > 0 ? "px-[14px] pb-2.5" : undefined}
594 entering={FadeIn.duration(160)}596 entering={FadeIn.duration(160)}
595 exiting={FadeOut.duration(120)}597 exiting={FadeOut.duration(120)}
596 >598 >
...@@ -602,7 +604,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -602,7 +604,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
602 </Animated.View>604 </Animated.View>
603 ) : null}605 ) : null}
604 <Animated.View606 <Animated.View
605 className={isExpanded ? undefined : "min-w-0 flex-1"}607 className={isExpanded ? "px-[14px]" : "min-w-0 flex-1 px-[4px]"}
606 layout={COMPOSER_LAYOUT_TRANSITION}608 layout={COMPOSER_LAYOUT_TRANSITION}
607 >609 >
608 <ComposerEditor610 <ComposerEditor
...@@ -629,12 +631,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -629,12 +631,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
629 ? {631 ? {
630 minHeight: 72,632 minHeight: 72,
631 maxHeight: 160,633 maxHeight: 160,
632 paddingHorizontal: 4,
633 paddingVertical: 4,634 paddingVertical: 4,
634 }635 }
635 : {636 : {
636 height: 36,637 height: 36,
637 paddingHorizontal: 4,
638 }638 }
639 }639 }
640 textStyle={{640 textStyle={{
...@@ -643,7 +643,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -643,7 +643,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
643 }}643 }}
644 />644 />
645 </Animated.View>645 </Animated.View>
646 {!isExpanded && !voiceInput.isBusy && props.draftAttachments.length > 0 ? (646 {!isExpanded && props.draftAttachments.length > 0 ? (
647 <View className="flex-row gap-1 pl-1">647 <View className="flex-row gap-1 pl-1">
648 {props.draftAttachments.slice(0, 3).map((attachment) =>648 {props.draftAttachments.slice(0, 3).map((attachment) =>
649 attachment.type === "image" ? (649 attachment.type === "image" ? (
...@@ -675,33 +675,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -675,33 +675,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
675 ) : null}675 ) : null}
676 </View>676 </View>
677 ) : null}677 ) : null}
678 {!isExpanded && !voiceInput.isBusy ? (678 {!isExpanded ? (
679 <Animated.View679 <View className="flex-row items-center">
680 className="flex-row items-center gap-1.5"680 <ComposerDictationStartAction
681 entering={FadeIn.duration(180)}681 state={voiceInput.state}
682 exiting={FadeOut.duration(100)}682 isAvailable={voiceInput.isAvailable}
683 >683 onStart={voiceInput.start}
684 {voiceInput.isAvailable ? (684 onCancel={voiceInput.cancel}
685 <ComposerDictationPrimaryAction685 />
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}
694 {showStopAction ? (686 {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 />
696 ) : (693 ) : (
697 <ControlPill694 <ComposerActionButton
695 accessibilityLabel={sendLabel}
698 icon="arrow.up"696 icon="arrow.up"
699 variant="primary"697 variant="primary"
700 disabled={!canSend}698 disabled={!hasContent}
701 onPress={handleSend}699 onPress={handleSend}
702 />700 />
703 )}701 )}
704 </Animated.View>702 </View>
705 ) : null}703 ) : null}
706 {isExpanded ? <View className="h-1" /> : null}704 {isExpanded ? <View className="h-1" /> : null}
707 </ComposerDictationDraftContent>705 </ComposerDictationDraftContent>
...@@ -712,12 +710,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -712,12 +710,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
712 layout={COMPOSER_LAYOUT_TRANSITION}710 layout={COMPOSER_LAYOUT_TRANSITION}
713 pointerEvents={isToolbarVisible ? "auto" : "none"}711 pointerEvents={isToolbarVisible ? "auto" : "none"}
714 style={712 style={
715 isToolbarVisible713 isExpanded
716 ? undefined714 ? undefined
717 : {715 : {
718 height: 0,716 position: "absolute",
719 opacity: 0,717 bottom: 2,
720 overflow: "hidden",718 left: 0,
719 right: 0,
721 }720 }
722 }721 }
723 >722 >
...@@ -725,7 +724,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -725,7 +724,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
725 showsDictation={isVoiceInputPresented}724 showsDictation={isVoiceInputPresented}
726 visible={isToolbarVisible}725 visible={isToolbarVisible}
727 >726 >
728 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={0}>727 <ComposerToolbarRow
728 paddingBottom={0}
729 paddingHorizontal={0}
730 paddingTop={0}
731 style={{ gap: 0 }}
732 >
729 <ComposerDictationCancelAction733 <ComposerDictationCancelAction
730 presentation={voicePresentation}734 presentation={voicePresentation}
731 onCancel={voiceInput.cancel}735 onCancel={voiceInput.cancel}
...@@ -739,24 +743,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -739,24 +743,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
739 onDismissError={voiceInput.cancel}743 onDismissError={voiceInput.cancel}
740 />744 />
741 ) : (745 ) : (
742 <View className="min-w-0 flex-1 flex-row items-center gap-2">746 <View className="min-w-0 flex-1 flex-row items-center justify-between">
743 <ComposerToolbarButton747 <ComposerAttachmentButton
744 accessibilityLabel="Add attachment"748 supportsFiles={Boolean(
745 icon="plus"749 props.serverConfig?.environment.capabilities.fileAttachments,
746 onPress={() => {750 )}
747 if (props.serverConfig?.environment.capabilities.fileAttachments) {751 onPickMedia={props.onPickDraftMedia}
748 Alert.alert("Add attachment", undefined, [752 onPickFiles={props.onPickDraftFiles}
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}
758 />753 />
759 <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}>754 <View className="min-w-0 shrink" style={{ maxWidth: 152 }}>
760 <ComposerInlineControl755 <ComposerInlineControl
761 accessibilityLabel="Model and reasoning settings"756 accessibilityLabel="Model and reasoning settings"
762 emphasized757 emphasized
...@@ -770,7 +765,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -770,7 +765,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
770 </View>765 </View>
771 </View>766 </View>
772 )}767 )}
773 <View className="shrink-0 flex-row items-center gap-2">768 <View className="shrink-0 flex-row items-center">
774 <ComposerDictationPrimaryAction769 <ComposerDictationPrimaryAction
775 state={voiceInput.state}770 state={voiceInput.state}
776 presentation={voicePresentation}771 presentation={voicePresentation}
...@@ -779,23 +774,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -779,23 +774,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
779 onConfirm={voiceInput.stop}774 onConfirm={voiceInput.stop}
780 onCancel={voiceInput.cancel}775 onCancel={voiceInput.cancel}
781 />776 />
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}
792 {showStopAction ? (777 {showStopAction ? (
793 <ComposerToolbarButton778 <ComposerActionButton
794 accessibilityLabel="Stop agent"779 accessibilityLabel="Stop agent"
795 icon="stop.fill"780 icon="stop.fill"
796 variant="danger"781 variant="danger"
797 onPress={props.onStopThread}782 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}
799 />791 />
800 ) : null}792 ) : null}
801 </View>793 </View>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2-2
...@@ -121,7 +121,7 @@ export interface ThreadDetailScreenProps {...@@ -121,7 +121,7 @@ export interface ThreadDetailScreenProps {
121 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;121 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
122 readonly onOpenConnectionEditor: () => void;122 readonly onOpenConnectionEditor: () => void;
123 readonly onChangeDraftMessage: (value: string) => void;123 readonly onChangeDraftMessage: (value: string) => void;
124 readonly onPickDraftImages: () => Promise<void>;124 readonly onPickDraftMedia: () => Promise<void>;
125 readonly onPickDraftFiles: () => Promise<void>;125 readonly onPickDraftFiles: () => Promise<void>;
126 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;126 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
127 readonly onRemoveDraftImage: (imageId: string) => void;127 readonly onRemoveDraftImage: (imageId: string) => void;
...@@ -812,7 +812,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -812,7 +812,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
812 projectCwd={props.projectWorkspaceRoot}812 projectCwd={props.projectWorkspaceRoot}
813 bottomInset={composerBottomInset}813 bottomInset={composerBottomInset}
814 onChangeDraftMessage={props.onChangeDraftMessage}814 onChangeDraftMessage={props.onChangeDraftMessage}
815 onPickDraftImages={props.onPickDraftImages}815 onPickDraftMedia={props.onPickDraftMedia}
816 onPickDraftFiles={props.onPickDraftFiles}816 onPickDraftFiles={props.onPickDraftFiles}
817 onNativePasteImages={props.onNativePasteImages}817 onNativePasteImages={props.onNativePasteImages}
818 onRemoveDraftImage={props.onRemoveDraftImage}818 onRemoveDraftImage={props.onRemoveDraftImage}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1-1
...@@ -793,7 +793,7 @@ function ThreadRouteContent(...@@ -793,7 +793,7 @@ function ThreadRouteContent(
793 usesAutomaticContentInsets={usesNativeHeaderGlass}793 usesAutomaticContentInsets={usesNativeHeaderGlass}
794 onOpenConnectionEditor={handleOpenConnectionEditor}794 onOpenConnectionEditor={handleOpenConnectionEditor}
795 onChangeDraftMessage={composer.onChangeDraftMessage}795 onChangeDraftMessage={composer.onChangeDraftMessage}
796 onPickDraftImages={composer.onPickDraftImages}796 onPickDraftMedia={composer.onPickDraftMedia}
797 onPickDraftFiles={composer.onPickDraftFiles}797 onPickDraftFiles={composer.onPickDraftFiles}
798 onNativePasteImages={composer.onNativePasteImages}798 onNativePasteImages={composer.onNativePasteImages}
799 onRemoveDraftImage={composer.onRemoveDraftImage}799 onRemoveDraftImage={composer.onRemoveDraftImage}
apps/mobile/src/features/voice-input/ComposerDictationControl.tsx+99-40
...@@ -10,13 +10,12 @@ import {...@@ -10,13 +10,12 @@ import {
10} from "react-native";10} from "react-native";
11import Animated, {11import Animated, {
12 Easing,12 Easing,
13 FadeIn,
14 FadeOut,
15 LinearTransition,13 LinearTransition,
16 ReduceMotion,14 ReduceMotion,
17 useAnimatedStyle,15 useAnimatedStyle,
18 useSharedValue,16 useSharedValue,
19 withTiming,17 withTiming,
18 type EntryExitAnimationFunction,
20 type SharedValue,19 type SharedValue,
21} from "react-native-reanimated";20} from "react-native-reanimated";
2221
...@@ -35,8 +34,56 @@ const DICTATION_LAYOUT =...@@ -35,8 +34,56 @@ const DICTATION_LAYOUT =
35 Platform.OS === "android"34 Platform.OS === "android"
36 ? undefined35 ? undefined
37 : LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System);36 : LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System);
38const DICTATION_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);37const TOOLBAR_FLIP_TIMING = {
39const DICTATION_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);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);
40const WAVEFORM_BAR_HEIGHT = 32;87const WAVEFORM_BAR_HEIGHT = 32;
41const WAVEFORM_MIN_BAR_HEIGHT = 2;88const WAVEFORM_MIN_BAR_HEIGHT = 2;
42const WAVEFORM_BAR_SPACING = 5;89const WAVEFORM_BAR_SPACING = 5;
...@@ -46,51 +93,63 @@ const WAVEFORM_TIMING = {...@@ -46,51 +93,63 @@ const WAVEFORM_TIMING = {
46 reduceMotion: ReduceMotion.System,93 reduceMotion: ReduceMotion.System,
47} as const;94} 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. */
50export function ComposerDictationDraftContent(props: {97export function ComposerDictationDraftContent(props: {
51 readonly children: ReactNode;98 readonly children: ReactNode;
52 readonly className?: string;99 readonly className?: string;
53 readonly collapsed: boolean;100 readonly compact: boolean;
101 readonly hidden: boolean;
54}) {102}) {
55 const visibility = useSharedValue(props.collapsed ? 0 : 1);103 const rotation = useSharedValue(props.hidden ? 1 : 0);
56 useLayoutEffect(() => {104 useLayoutEffect(() => {
57 visibility.value = withTiming(props.collapsed ? 0 : 1, DICTATION_TIMING);105 rotation.value = withTiming(props.hidden ? 1 : 0, TOOLBAR_FLIP_TIMING);
58 }, [props.collapsed, visibility]);106 }, [props.hidden, rotation]);
107 const compact = props.compact;
59 const animatedStyle = useAnimatedStyle(() => ({108 const animatedStyle = useAnimatedStyle(() => ({
60 opacity: visibility.value,109 opacity: compact ? 1 - rotation.value : 1,
61 transform: [{ translateY: -4 * (1 - visibility.value) }],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 : [],
62 }));117 }));
63118
64 return (119 return (
65 <Animated.View120 <Animated.View
66 accessibilityElementsHidden={props.collapsed}121 accessibilityElementsHidden={props.hidden}
67 importantForAccessibility={props.collapsed ? "no-hide-descendants" : "auto"}122 importantForAccessibility={props.hidden ? "no-hide-descendants" : "auto"}
68 collapsable={false}123 collapsable={false}
69 className={props.className}124 className={props.className}
70 layout={DICTATION_LAYOUT}125 layout={DICTATION_LAYOUT}
71 style={[animatedStyle, { overflow: "hidden" }, props.collapsed ? { height: 0 } : undefined]}126 pointerEvents={props.hidden ? "none" : "auto"}
127 style={[animatedStyle, { backfaceVisibility: "hidden" }]}
72 >128 >
73 {props.children}129 {props.children}
74 </Animated.View>130 </Animated.View>
75 );131 );
76}132}
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. */
79export function ComposerDictationToolbar(props: {135export function ComposerDictationToolbar(props: {
80 readonly children: ReactNode;136 readonly children: ReactNode;
81 readonly showsDictation: boolean;137 readonly showsDictation: boolean;
82 readonly visible?: boolean;138 readonly visible?: boolean;
83}) {139}) {
84 return (140 return (
85 <View className="relative h-11">141 <View className="relative h-[44px] overflow-hidden">
86 <Animated.View142 {props.visible !== false ? (
87 key={props.showsDictation ? "dictation" : "draft"}143 <Animated.View
88 className="absolute inset-0"144 key={props.showsDictation ? "dictation" : "draft"}
89 entering={DICTATION_ENTERING}145 className="absolute inset-0"
90 exiting={props.visible === false ? undefined : DICTATION_EXITING}146 entering={props.showsDictation ? DICTATION_TOOLBAR_ENTERING : DRAFT_TOOLBAR_ENTERING}
91 >147 exiting={props.showsDictation ? DICTATION_TOOLBAR_EXITING : DRAFT_TOOLBAR_EXITING}
92 {props.children}148 style={{ backfaceVisibility: "hidden" }}
93 </Animated.View>149 >
150 {props.children}
151 </Animated.View>
152 ) : null}
94 </View>153 </View>
95 );154 );
96}155}
...@@ -179,19 +238,15 @@ function VoiceActionButton(props: {...@@ -179,19 +238,15 @@ function VoiceActionButton(props: {
179 accessibilityLabel={props.accessibilityLabel}238 accessibilityLabel={props.accessibilityLabel}
180 accessibilityRole="button"239 accessibilityRole="button"
181 accessibilityState={{ busy: props.loading, disabled: props.disabled }}240 accessibilityState={{ busy: props.loading, disabled: props.disabled }}
182 className={cn(241 className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
183 "items-center justify-center active:opacity-70",
184 variant === "primary" ? "size-11" : "size-9",
185 )}
186 disabled={props.disabled}242 disabled={props.disabled}
187 hitSlop={variant === "plain" ? 4 : undefined}
188 onPress={props.onPress}243 onPress={props.onPress}
189 style={{ opacity: props.disabled && !props.loading ? 0.4 : 1 }}244 style={{ opacity: props.disabled && !props.loading ? 0.4 : 1 }}
190 >245 >
191 <View246 <View
192 className={cn(247 className={cn(
193 "items-center justify-center",248 "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]",
195 )}250 )}
196 >251 >
197 {variant === "primary" ? (252 {variant === "primary" ? (
...@@ -200,25 +255,21 @@ function VoiceActionButton(props: {...@@ -200,25 +255,21 @@ function VoiceActionButton(props: {
200 style={primaryStyle}255 style={primaryStyle}
201 />256 />
202 ) : null}257 ) : null}
203 <Animated.View258 <View className="absolute inset-0 items-center justify-center">
204 key={props.loading ? "loading" : "icon"}
205 className="absolute inset-0 items-center justify-center"
206 entering={DICTATION_ENTERING}
207 exiting={DICTATION_EXITING}
208 >
209 {props.loading ? (259 {props.loading ? (
210 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />260 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
211 ) : (261 ) : (
212 <SymbolView262 <SymbolView
213 name={props.icon}263 name={props.icon}
214 size={17}264 size={variant === "primary" ? 16 : 20}
265 weight={variant === "primary" ? "semibold" : "regular"}
215 tintColorClassName={266 tintColorClassName={
216 variant === "primary" ? "accent-primary-foreground" : "accent-icon"267 variant === "primary" ? "accent-primary-foreground" : "accent-icon"
217 }268 }
218 type="monochrome"269 type="monochrome"
219 />270 />
220 )}271 )}
221 </Animated.View>272 </View>
222 </View>273 </View>
223 </Pressable>274 </Pressable>
224 );275 );
...@@ -237,11 +288,9 @@ export function ComposerDictationStatus(props: {...@@ -237,11 +288,9 @@ export function ComposerDictationStatus(props: {
237 }, [props.phase, recordingVisibility]);288 }, [props.phase, recordingVisibility]);
238 const waveformStyle = useAnimatedStyle(() => ({289 const waveformStyle = useAnimatedStyle(() => ({
239 opacity: recordingVisibility.value,290 opacity: recordingVisibility.value,
240 transform: [{ translateY: -4 * (1 - recordingVisibility.value) }],
241 }));291 }));
242 const labelStyle = useAnimatedStyle(() => ({292 const labelStyle = useAnimatedStyle(() => ({
243 opacity: 1 - recordingVisibility.value,293 opacity: 1 - recordingVisibility.value,
244 transform: [{ translateY: 4 * recordingVisibility.value }],
245 }));294 }));
246295
247 if (!props.presentation.statusLabel) return null;296 if (!props.presentation.statusLabel) return null;
...@@ -340,6 +389,16 @@ export function ComposerDictationPrimaryAction(props: {...@@ -340,6 +389,16 @@ export function ComposerDictationPrimaryAction(props: {
340 );389 );
341 }390 }
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}) {
343 if (!props.isAvailable) return null;402 if (!props.isAvailable) return null;
344 const openSettings = props.state.phase === "error" && props.state.errorAction === "settings";403 const openSettings = props.state.phase === "error" && props.state.errorAction === "settings";
345 return (404 return (
apps/mobile/src/lib/composerFiles.test.ts+189-1
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import type { ImagePickerAsset } from "expo-image-picker";
23
3const mocks = vi.hoisted(() => ({4const mocks = vi.hoisted(() => ({
4 documentUri: "file:///documents",5 documentUri: "file:///documents",
5 pickFile: vi.fn(),6 pickFile: vi.fn(),
7 pickMedia: vi.fn(),
6 copy: vi.fn(),8 copy: vi.fn(),
7 delete: vi.fn(),9 delete: vi.fn(),
8 open: vi.fn(),10 open: vi.fn(),
...@@ -37,6 +39,14 @@ vi.mock("expo-file-system", () => {...@@ -37,6 +39,14 @@ vi.mock("expo-file-system", () => {
37 return mocks.size(this.uri) ?? null;39 return mocks.size(this.uri) ?? null;
38 }40 }
3941
42 get name(): string {
43 return this.uri.split("/").at(-1) ?? "";
44 }
45
46 get type(): string {
47 return "video/quicktime";
48 }
49
40 create(): void {}50 create(): void {}
4151
42 open(mode: string) {52 open(mode: string) {
...@@ -64,18 +74,23 @@ vi.mock("expo-file-system", () => {...@@ -64,18 +74,23 @@ vi.mock("expo-file-system", () => {
64 };74 };
65});75});
6676
77vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia }));
67vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" }));78vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" }));
6879
69import {80import {
70 persistComposerAttachmentFile,81 persistComposerAttachmentFile,
71 pickComposerFiles,82 pickComposerFiles,
83 pickComposerImages,
84 pickComposerMedia,
72 removePersistedComposerAttachmentFile,85 removePersistedComposerAttachmentFile,
73} from "./composerImages";86} from "./composerImages";
87import { isForegroundHandoffActive } from "./foreground-handoff";
7488
75describe("pickComposerFiles", () => {89describe("composer file attachments", () => {
76 beforeEach(() => {90 beforeEach(() => {
77 mocks.documentUri = "file:///documents";91 mocks.documentUri = "file:///documents";
78 mocks.pickFile.mockReset();92 mocks.pickFile.mockReset();
93 mocks.pickMedia.mockReset();
79 mocks.copy.mockReset();94 mocks.copy.mockReset();
80 mocks.delete.mockReset();95 mocks.delete.mockReset();
81 mocks.open.mockReset();96 mocks.open.mockReset();
...@@ -83,6 +98,179 @@ describe("pickComposerFiles", () => {...@@ -83,6 +98,179 @@ describe("pickComposerFiles", () => {
83 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));98 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
84 });99 });
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
86 it("copies picked files into app-owned storage without loading their contents", async () => {274 it("copies picked files into app-owned storage without loading their contents", async () => {
87 mocks.pickFile.mockResolvedValue({275 mocks.pickFile.mockResolvedValue({
88 canceled: false,276 canceled: false,
apps/mobile/src/lib/composerImages.ts+101-36
...@@ -157,6 +157,40 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis...@@ -157,6 +157,40 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis
157 }157 }
158}158}
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
160export async function pickComposerFiles(input: {194export async function pickComposerFiles(input: {
161 readonly existingCount: number;195 readonly existingCount: number;
162 readonly maxBytes?: number;196 readonly maxBytes?: number;
...@@ -199,32 +233,16 @@ export async function pickComposerFiles(input: {...@@ -199,32 +233,16 @@ export async function pickComposerFiles(input: {
199 // contract rejects empty names at send time, so fall back before the name233 // contract rejects empty names at send time, so fall back before the name
200 // reaches storage, errors, or the attachment itself.234 // reaches storage, errors, or the attachment itself.
201 const name = file.name.trim().length > 0 ? file.name : "file";235 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 }
207 try {236 try {
208 const fileUri = await persistComposerAttachmentFile(file.uri, name, maxBytes);237 attachments.push(
209 const storedSizeBytes = new File(fileUri).size ?? sizeBytes ?? 0;238 await createComposerFileAttachment({
210 if (storedSizeBytes <= 0) {239 uri: file.uri,
211 await removePersistedComposerAttachmentFile(fileUri);240 name,
212 error = `'${name}' is empty or could not be read.`;241 mimeType: file.type || "application/octet-stream",
213 continue;242 sizeBytes: file.size ?? null,
214 }243 maxBytes,
215 if (storedSizeBytes > maxBytes) {244 }),
216 await removePersistedComposerAttachmentFile(fileUri);245 );
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 });
228 } catch (cause) {246 } catch (cause) {
229 error = cause instanceof Error ? cause.message : `Could not read '${name}'.`;247 error = cause instanceof Error ? cause.message : `Could not read '${name}'.`;
230 }248 }
...@@ -239,7 +257,7 @@ async function loadImagePicker() {...@@ -239,7 +257,7 @@ async function loadImagePicker() {
239 try {257 try {
240 return await import("expo-image-picker");258 return await import("expo-image-picker");
241 } catch (error) {259 } 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 });
243 }261 }
244}262}
245263
...@@ -254,12 +272,27 @@ async function loadClipboard() {...@@ -254,12 +272,27 @@ async function loadClipboard() {
254export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{272export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{
255 readonly images: ReadonlyArray<DraftComposerImageAttachment>;273 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
256 readonly error: string | null;274 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;
257}> {290}> {
258 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;291 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
259 if (remainingSlots <= 0) {292 if (remainingSlots <= 0) {
260 return {293 return {
261 images: [],294 attachments: [],
262 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`,295 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`,
263 };296 };
264 }297 }
265298
...@@ -268,9 +301,8 @@ export async function pickComposerImages(input: { readonly existingCount: number...@@ -268,9 +301,8 @@ export async function pickComposerImages(input: { readonly existingCount: number
268 imagePicker = await loadImagePicker();301 imagePicker = await loadImagePicker();
269 } catch (error) {302 } catch (error) {
270 return {303 return {
271 images: [],304 attachments: [],
272 error:305 error: error instanceof Error ? error.message : "The photo library is unavailable right now.",
273 error instanceof Error ? error.message : "Image attachments are unavailable right now.",
274 };306 };
275 }307 }
276308
...@@ -280,28 +312,61 @@ export async function pickComposerImages(input: { readonly existingCount: number...@@ -280,28 +312,61 @@ export async function pickComposerImages(input: { readonly existingCount: number
280 let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>;312 let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>;
281 try {313 try {
282 result = await imagePicker.launchImageLibraryAsync({314 result = await imagePicker.launchImageLibraryAsync({
283 mediaTypes: ["images"],315 mediaTypes: input.maxVideoBytes === undefined ? ["images"] : ["images", "videos"],
284 allowsMultipleSelection: true,316 allowsMultipleSelection: true,
285 selectionLimit: remainingSlots,317 selectionLimit: remainingSlots,
286 base64: true,318 base64: true,
287 quality: 1,319 quality: 1,
320 shouldDownloadFromNetwork: true,
288 });321 });
322 } catch (error) {
323 return {
324 attachments: [],
325 error: error instanceof Error ? error.message : "Could not open the photo library.",
326 };
289 } finally {327 } finally {
290 endHandoff();328 endHandoff();
291 }329 }
292330
293 if (result.canceled) {331 if (result.canceled) {
294 return {332 return {
295 images: [],333 attachments: [],
296 error: null,334 error: null,
297 };335 };
298 }336 }
299337
300 const nextImages: DraftComposerImageAttachment[] = [];338 const attachments: DraftComposerAttachment[] = [];
301 let error: string | null = null;339 let error: string | null = null;
302340
303 for (const asset of result.assets) {341 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 }
304 const mimeType = asset.mimeType?.toLowerCase();346 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 }
305 if (!mimeType?.startsWith("image/")) {370 if (!mimeType?.startsWith("image/")) {
306 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;371 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
307 continue;372 continue;
...@@ -323,7 +388,7 @@ export async function pickComposerImages(input: { readonly existingCount: number...@@ -323,7 +388,7 @@ export async function pickComposerImages(input: { readonly existingCount: number
323 continue;388 continue;
324 }389 }
325390
326 nextImages.push({391 attachments.push({
327 id: uuidv4(),392 id: uuidv4(),
328 type: "image",393 type: "image",
329 name: asset.fileName ?? "image",394 name: asset.fileName ?? "image",
...@@ -335,7 +400,7 @@ export async function pickComposerImages(input: { readonly existingCount: number...@@ -335,7 +400,7 @@ export async function pickComposerImages(input: { readonly existingCount: number
335 }400 }
336401
337 return {402 return {
338 images: nextImages,403 attachments,
339 error,404 error,
340 };405 };
341}406}
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", () => {...@@ -221,40 +221,49 @@ describe("mobile composer drafts", () => {
221 });221 });
222 });222 });
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 });
225 const makeAttachment = (id: string) => ({231 const makeAttachment = (id: string) => ({
226 id,232 id,
227 type: "file" as const,233 type: "file" as const,
228 name: `${id}.pdf`,234 name: `${id}.mov`,
229 mimeType: "application/pdf",235 mimeType: "video/quicktime",
230 sizeBytes: 42,236 sizeBytes: 42,
231 fileUri: `file:///documents/t3-composer-attachments/${id}.pdf`,237 fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,
232 });238 });
239 const draftKey = "new-task:environment-1:project-cap";
233 const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`));240 const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`));
234 appAtomRegistry.set(composerDraftsAtom, {241 appAtomRegistry.set(composerDraftsAtom, {
235 "environment-1:thread-cap": { text: "send this", attachments: existing },242 [draftKey]: { text: "send this", attachments: existing },
236 });243 });
237244
238 const rejected = appendComposerDraftAttachments("environment-1:thread-cap", [245 const rejected = appendComposerDraftAttachments(draftKey, [
239 makeAttachment("incoming-1"),246 makeAttachment("incoming-1"),
240 makeAttachment("incoming-2"),247 makeAttachment("incoming-2"),
241 ]);248 ]);
242249
243 expect(rejected).toBe(1);250 expect(rejected).toBe(1);
244 const draft = appAtomRegistry.get(composerDraftsAtom)["environment-1:thread-cap"];251 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
245 expect(draft?.attachments).toHaveLength(8);252 expect(draft?.attachments).toHaveLength(8);
246 expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");253 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
248 // Restore paths bypass the cap so a failed send never drops its files.259 // Restore paths bypass the cap so a failed send never drops its files.
249 const overflowRejected = appendComposerDraftAttachments(260 const overflowRejected = appendComposerDraftAttachments(
250 "environment-1:thread-cap",261 draftKey,
251 [makeAttachment("restored-1")],262 [makeAttachment("restored-1")],
252 { allowOverflow: true },263 { allowOverflow: true },
253 );264 );
254 expect(overflowRejected).toBe(0);265 expect(overflowRejected).toBe(0);
255 expect(266 expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9);
256 appAtomRegistry.get(composerDraftsAtom)["environment-1:thread-cap"]?.attachments,
257 ).toHaveLength(9);
258 });267 });
259268
260 it("keeps shared attachment files until every draft releases them", async () => {269 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 {...@@ -28,7 +28,7 @@ import {
28 convertPastedImagesToAttachments,28 convertPastedImagesToAttachments,
29 pasteComposerClipboard,29 pasteComposerClipboard,
30 pickComposerFiles,30 pickComposerFiles,
31 pickComposerImages,31 pickComposerMedia,
32} from "../lib/composerImages";32} from "../lib/composerImages";
33import type { DraftComposerImageAttachment } from "../lib/composerImages";33import type { DraftComposerImageAttachment } from "../lib/composerImages";
34import { scopedThreadKey } from "../lib/scopedEntities";34import { scopedThreadKey } from "../lib/scopedEntities";
...@@ -316,26 +316,31 @@ export function useThreadComposerState() {...@@ -316,26 +316,31 @@ export function useThreadComposerState() {
316 [selectedThreadShell],316 [selectedThreadShell],
317 );317 );
318318
319 const onPickDraftImages = useCallback(async () => {319 const onPickDraftMedia = useCallback(async () => {
320 if (!selectedThreadShell) {320 if (!selectedThreadShell) {
321 return;321 return;
322 }322 }
323323
324 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);324 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
325 const result = await pickComposerImages({325 const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities;
326 const result = await pickComposerMedia({
326 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,327 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
328 maxVideoBytes:
329 capabilities?.attachmentUploads === true
330 ? capabilities.fileAttachments?.maxUploadBytes
331 : undefined,
327 });332 });
328 const rejectedImageCount = appendComposerDraftAttachments(threadKey, result.images);333 const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments);
329 const problems = [334 const problems = [
330 ...(result.error ? [result.error] : []),335 ...(result.error ? [result.error] : []),
331 ...(rejectedImageCount > 0336 ...(rejectedCount > 0
332 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]337 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
333 : []),338 : []),
334 ];339 ];
335 if (problems.length > 0) {340 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"));
337 }342 }
338 }, [composerDrafts, selectedThreadShell]);343 }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]);
339344
340 const onPickDraftFiles = useCallback(async () => {345 const onPickDraftFiles = useCallback(async () => {
341 if (!selectedThreadShell) {346 if (!selectedThreadShell) {
...@@ -470,7 +475,7 @@ export function useThreadComposerState() {...@@ -470,7 +475,7 @@ export function useThreadComposerState() {
470 runtimeMode,475 runtimeMode,
471 interactionMode,476 interactionMode,
472 onChangeDraftMessage,477 onChangeDraftMessage,
473 onPickDraftImages,478 onPickDraftMedia,
474 onPickDraftFiles,479 onPickDraftFiles,
475 onPasteIntoDraft,480 onPasteIntoDraft,
476 onNativePasteImages,481 onNativePasteImages,
docs/internals/mobile-navigation.md+7
...@@ -41,3 +41,10 @@ After changing a dependency patch, refresh CocoaPods before rebuilding an...@@ -41,3 +41,10 @@ After changing a dependency patch, refresh CocoaPods before rebuilding an
41existing iOS project. pnpm installs each patch hash in a different directory;41existing iOS project. pnpm installs each patch hash in a different directory;
42an old Pods project can keep compiling the previous directory even though Metro42an old Pods project can keep compiling the previous directory even though Metro
43and `apps/mobile/node_modules` resolve to the new patch.43and `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...@@ -4,15 +4,21 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee
4composer and shows how many characters need to be removed. Shorten the draft or split it into4composer and shows how many characters need to be removed. Shorten the draft or split it into
5multiple messages, then send again in the same thread.5multiple 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
7You can attach images up to 10 MB. On servers that support file uploads, you can also11You 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 advertised12attach videos, text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised
9by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files13by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files
10upload directly to the environment, where your agent can read, copy, or edit them by their file path.14upload directly to the environment, where your agent can read, copy, or edit them by their file path.
1115
12On web and desktop, attachments upload as soon as you add them. The send button becomes available16On 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 **+** control17after every upload finishes. Failed uploads can be retried or removed. On mobile, tap **+** to open
14offers Photos, and adds Files when the connected server supports file uploads. You can share a file18the photo library from either the compact or expanded composer. When the connected server supports
15into T3 Code from any app through the system share sheet. Mobile uploads happen when the message19file 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
16sends, so queued messages keep their files until they deliver. Select a received file on mobile22sends, so queued messages keep their files until they deliver. Select a received file on mobile
17to save it or open it in another app through the system share sheet.23to save it or open it in another app through the system share sheet.
1824
...@@ -41,9 +47,10 @@ send....@@ -41,9 +47,10 @@ send.
41## Voice input on iPhone47## Voice input on iPhone
4248
43On supported iPhones with iOS 26 or later, tap the microphone in the composer to record a message.49On 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 to50An expanded composer keeps your draft visible and flips its bottom toolbar into recording controls
45your voice. A collapsed composer shows a compact recording strip instead. Tap the checkmark to51with waves that respond to your voice. A collapsed composer flips into a compact recording strip
46finish and transcribe on your device. The waves fade into a transcription status, then the usual52without changing height. Tap the checkmark to finish and transcribe on your device. The waves fade
53into a transcription status, then the usual
47controls return with the text inserted at the selection where recording started. If the keyboard54controls return with the text inserted at the selection where recording started. If the keyboard
48is open when you start, it stays open during voice input. You can review and edit the text before55is open when you start, it stays open during voice input. You can review and edit the text before
49you send it.56you send it.