Skip to content

Commit e5ea25d

Browse files
fix(mobile): queued edits end when their run starts, wherever you are (#25468)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7d3208c commit e5ea25d

27 files changed

Lines changed: 768 additions & 461 deletions

‎apps/mobile/src/Stack.tsx‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ import {
8282
NewTaskEnvironmentPickerRouteScreen,
8383
} from "./features/threads/NewTaskContextPickerScreens";
8484
import {
85-
ExistingThreadSettingsRouteProvider,
8685
ExistingThreadSettingsRouteScreen,
8786
NewTaskThreadSettingsRouteScreen,
8887
} from "./features/threads/ThreadSettingsSheet";
@@ -550,20 +549,18 @@ function RootStackLayout(props: {
550549
<ThreadOutboxDrainWorker />
551550
<ShowcaseCaptureCoordinator pathname={pathname} />
552551
<SettingsEnvironmentFilterProvider open={settingsOpen}>
553-
<ExistingThreadSettingsRouteProvider>
554-
<WorkspaceConnectionStatusProvider>
555-
<AndroidWorkspaceTabsProvider state={props.state}>
556-
<AdaptiveWorkspaceLayout
557-
pathname={workspaceLocation.pathname}
558-
workspaceRouteKey={workspaceLocation.routeKey}
559-
selectedPullRequestKey={selectedPullRequestKey(props.state)}
560-
>
561-
{props.children}
562-
<HardwareKeyboardCommandOverlay />
563-
</AdaptiveWorkspaceLayout>
564-
</AndroidWorkspaceTabsProvider>
565-
</WorkspaceConnectionStatusProvider>
566-
</ExistingThreadSettingsRouteProvider>
552+
<WorkspaceConnectionStatusProvider>
553+
<AndroidWorkspaceTabsProvider state={props.state}>
554+
<AdaptiveWorkspaceLayout
555+
pathname={workspaceLocation.pathname}
556+
workspaceRouteKey={workspaceLocation.routeKey}
557+
selectedPullRequestKey={selectedPullRequestKey(props.state)}
558+
>
559+
{props.children}
560+
<HardwareKeyboardCommandOverlay />
561+
</AdaptiveWorkspaceLayout>
562+
</AndroidWorkspaceTabsProvider>
563+
</WorkspaceConnectionStatusProvider>
567564
</SettingsEnvironmentFilterProvider>
568565
</HardwareKeyboardCommandProvider>
569566
</NewTaskFlowProvider>

‎apps/mobile/src/components/ComposerAttachmentStrip.tsx‎

Lines changed: 28 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import { composerImageGallery } from "../lib/composerImageGallery";
33
import { SymbolView } from "./ui/AppSymbol";
44
import { imageMimeType } from "@t3tools/shared/image";
55
import { videoMimeType } from "@t3tools/shared/video";
6-
import { useEffect, useMemo, useState } from "react";
6+
import { useAtomValue } from "@effect/atom-react";
7+
import { AsyncResult, Atom } from "effect/reactivity";
8+
import { Paths } from "expo-file-system";
79
import { Image, Pressable, ScrollView, View } from "react-native";
810

911
import { AppText as Text } from "./ui/AppText";
12+
import { MaterializedPreviewKey, materializedPreviewAtom } from "../lib/composerImagePreview";
1013
import { PierreEntryIcon } from "./PierreEntryIcon";
1114
import {
1215
isFileBackedComposerAttachment,
@@ -93,65 +96,31 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr
9396
);
9497
}
9598

96-
const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews";
97-
9899
/** Fabric re-parses URLs each layout; large data URLs can stall and abort commits. */
99100
const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000;
100101

101-
async function materializeDataUrlPreview(id: string, dataUrl: string): Promise<string | null> {
102-
const comma = dataUrl.indexOf(",");
103-
if (comma < 0) return null;
104-
const { Directory, File, Paths } = await import("expo-file-system");
105-
const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg";
106-
const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg");
107-
const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY);
108-
directory.create({ idempotent: true, intermediates: true });
109-
const file = new File(directory, `${id}.${extension}`);
110-
if (!file.exists) {
111-
file.create();
112-
await file.write(dataUrl.slice(comma + 1), { encoding: "base64" });
113-
}
114-
return file.uri;
115-
}
102+
const noMaterializedPreview = Atom.make(AsyncResult.initial<string | null>()).pipe(
103+
Atom.withLabel("composer-image-preview:none"),
104+
);
116105

117106
function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string | null {
118-
const { id, fileUri, previewUri } = attachment;
119-
const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null);
120-
const [materialized, setMaterialized] = useState<{ id: string; uri: string | null } | null>(null);
107+
const { fileUri, previewUri } = attachment;
121108
const inlinePreview = fileUri === undefined && previewUri.startsWith("data:");
122-
useEffect(() => {
123-
if (fileUri === undefined) return;
124-
let cancelled = false;
125-
void (async () => {
126-
const { Paths } = await import("expo-file-system");
127-
const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
128-
if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned });
129-
})();
130-
return () => {
131-
cancelled = true;
132-
};
133-
}, [fileUri, previewUri]);
134-
useEffect(() => {
135-
if (!inlinePreview) return;
136-
let cancelled = false;
137-
void materializeDataUrlPreview(id, previewUri)
138-
.then((uri) => {
139-
if (!cancelled && uri !== null) setMaterialized({ id, uri });
140-
})
141-
.catch((error: unknown) => {
142-
console.warn("[composer-attachments] could not cache an image preview", error);
143-
if (!cancelled) setMaterialized({ id, uri: null });
144-
});
145-
return () => {
146-
cancelled = true;
147-
};
148-
}, [id, inlinePreview, previewUri]);
149-
if (fileUri !== undefined && rebased?.fileUri === fileUri) return rebased.uri;
150-
if (fileUri !== undefined) return previewUri.startsWith("data:") ? fileUri : previewUri;
109+
const materialized = useAtomValue(
110+
inlinePreview
111+
? materializedPreviewAtom(new MaterializedPreviewKey(attachment))
112+
: noMaterializedPreview,
113+
);
114+
if (fileUri !== undefined) {
115+
// Reinstalls move the app container, so a stored file URI may point at the old one.
116+
const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
117+
if (owned !== null) return owned;
118+
return previewUri.startsWith("data:") ? fileUri : previewUri;
119+
}
151120
if (inlinePreview) {
152-
if (materialized?.id !== id) return null;
121+
if (!AsyncResult.isSuccess(materialized)) return null;
153122
return (
154-
materialized.uri ??
123+
materialized.value ??
155124
(previewUri.length <= INLINE_PREVIEW_FALLBACK_MAX_CHARS ? previewUri : null)
156125
);
157126
}
@@ -280,15 +249,12 @@ function ComposerVideoAttachment(props: {
280249
const { attachment } = props;
281250
const sourceIdentifier = `draft:${attachment.id}`;
282251
const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
283-
const actionsSource = useMemo<MediaActionsSource>(
284-
() => ({
285-
name: attachment.name,
286-
mimeType: videoMimeType(attachment) ?? attachment.mimeType,
287-
sourceIdentifier,
288-
attachment,
289-
}),
290-
[attachment, sourceIdentifier],
291-
);
252+
const actionsSource: MediaActionsSource = {
253+
name: attachment.name,
254+
mimeType: videoMimeType(attachment) ?? attachment.mimeType,
255+
sourceIdentifier,
256+
attachment,
257+
};
292258

293259
return (
294260
<VideoAttachmentTile
@@ -304,7 +270,7 @@ function ComposerVideoAttachment(props: {
304270
}
305271

306272
export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
307-
const gallery = useMemo(() => composerImageGallery(props.attachments), [props.attachments]);
273+
const gallery = composerImageGallery(props.attachments);
308274
const size = props.imageSize ?? 72;
309275
const radius = props.imageBorderRadius ?? 16;
310276
const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";

‎apps/mobile/src/features/threads/NewTaskDraftScreen.tsx‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,10 +1246,6 @@ export function NewTaskDraftScreen(props: {
12461246
onSendMessage={handleStart}
12471247
onReconnectEnvironment={() => onReconnectEnvironment(selectedProject.environmentId)}
12481248
canSwitchThreadProvider
1249-
onUpdateThreadModelSelection={(selection) =>
1250-
flow.setSelectedModelKey(`${selection.instanceId}:${selection.model}`, selection.options)
1251-
}
1252-
onUpdateThreadRuntimeMode={flow.setRuntimeMode}
12531249
onUpdateThreadInteractionMode={flow.planModeEnabled ? flow.setInteractionMode : undefined}
12541250
onRespondToApproval={async () => {}}
12551251
onSelectUserInputOption={() => {}}

‎apps/mobile/src/features/threads/PendingUserInputCard.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
269269
</View>
270270
{question.allowCustomAnswer !== false ? (
271271
<QuestionAttachments
272+
key={`${props.pendingUserInput.requestId}:${question.id}`}
272273
requestId={props.pendingUserInput.requestId}
273274
question={question}
274275
questions={props.pendingUserInput.questions}

‎apps/mobile/src/features/threads/QuestionAttachments.tsx‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,27 +45,23 @@ export function QuestionAttachments(props: {
4545
const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
4646
const configs = useServerConfigs();
4747
const drafts = useAtomValue(composerDraftsAtom);
48-
const scopeKey = JSON.stringify([
49-
selectedThread?.environmentId,
50-
selectedThread?.id,
51-
props.requestId,
52-
props.question.id,
53-
]);
54-
// Unmounted inputs never emit onBlur; explicitly release keyboard ownership.
5548
const focusedRef = useRef(false);
5649
const onInputFocusChange = props.onInputFocusChange;
57-
const releaseFocus = useEffectEvent(() => {
58-
if (focusedRef.current) onInputFocusChange?.(false);
59-
});
60-
useEffect(() => () => releaseFocus(), []);
61-
const pickerScope = useRef<{ key: string; active: boolean } | null>(null);
50+
// The caller keys this component by request and question, so unmounting ends any open
51+
// picker's scope.
52+
const pickerScope = useRef<{ active: boolean }>({ active: false });
6253
useEffect(() => {
63-
const scope = { key: scopeKey, active: true };
54+
const scope = { active: true };
6455
pickerScope.current = scope;
6556
return () => {
6657
scope.active = false;
6758
};
68-
}, [scopeKey]);
59+
}, []);
60+
// Unmounted inputs never emit onBlur; explicitly release keyboard ownership.
61+
const releaseFocus = useEffectEvent(() => {
62+
if (focusedRef.current) onInputFocusChange?.(false);
63+
});
64+
useEffect(() => () => releaseFocus(), []);
6965
const append = (
7066
key: string,
7167
attachments: Parameters<typeof appendComposerDraftAttachments>[1],
@@ -106,7 +102,7 @@ export function QuestionAttachments(props: {
106102
})
107103
.then(async (images) => {
108104
if (
109-
scope?.active &&
105+
scope.active &&
110106
(appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0
111107
) {
112108
if (append(key, images) > 0)
@@ -150,7 +146,7 @@ export function QuestionAttachments(props: {
150146
const picked = "files" in result ? result.files : result.attachments;
151147
// Resolution on another client clears the reservation while the picker is open.
152148
if (
153-
!scope?.active ||
149+
!scope.active ||
154150
(appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) === 0
155151
) {
156152
await releaseUnusedComposerAttachmentFiles(picked);

0 commit comments

Comments
 (0)