Skip to content
Open
5 changes: 2 additions & 3 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
} from "@t3tools/contracts";

import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
import { composerContextImportsAtom } from "../../state/use-composer-drafts";
import { composerContextImportsAtom, composerDraftBusy } from "../../state/use-composer-drafts";
import { abortComposerDraftOperations } from "../../state/composerDraftOperations";
import { composerContextSendBlockReason } from "../../lib/composerContext";
import { ComposerInlineControl } from "../../components/ComposerToolbar";
Expand Down Expand Up @@ -94,7 +94,6 @@ import { idleShareFlagAtom, idleShareImportPhaseAtom } from "./incomingShareImpo
import { newTaskFlowKey } from "./new-task-flow-state";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
import { appAtomRegistry } from "../../state/atom-registry";

import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";

Expand Down Expand Up @@ -585,7 +584,7 @@ export function NewTaskDraftScreen(props: {
return null;
}
const draft = getComposerDraftSnapshot(draftKey);
if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return null;
if (composerDraftBusy(draftKey)) return null;
const modelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
Expand Down
14 changes: 12 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
getComposerDraftSnapshot,
setComposerDraftText,
composerContextImportsAtom,
composerPendingMediaAtom,
countComposerDraftAttachmentsAfterSelection,
useComposerDraft,
} from "../../state/use-composer-drafts";
Expand Down Expand Up @@ -454,11 +455,19 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
const composerDraftKey = props.draftKey ?? composerOwnerKey;
const { text: draftMessage, attachments: draftAttachments } = useComposerDraft(composerDraftKey);
const queuedEdit = props.queuedEdit ?? null;
const pendingMedia = useAtomValue(composerPendingMediaAtom)[composerDraftKey];
const hasContent =
draftMessage.trim().length > 0 ||
draftAttachments.length > 0 ||
pendingMedia !== undefined ||
(queuedEdit?.existingAttachments.length ?? 0) > 0;
const stripAttachments = composerStripAttachments(draftAttachments);
// A finished attachment keeps its placeholder's id; skip the placeholder once it lands.
const stripAttachments = [
...composerStripAttachments(draftAttachments),
...(pendingMedia ?? []).filter(
(placeholder) => !draftAttachments.some(({ id }) => id === placeholder.id),
),
];
const showStopAction = !hasContent && props.canStopThread && queuedEdit === null;
const onResumeQueue = props.onResumeQueue;
const [resumingQueue, setResumingQueue] = useState(false);
Expand Down Expand Up @@ -620,6 +629,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
(props.canOperateThread || props.connectionState !== "connected") &&
hasContent &&
!contextImports[composerDraftKey] &&
pendingMedia === undefined &&
!isSubmissionLocked &&
sendBlockedReason === null &&
!modelUnavailable;
Expand All @@ -635,7 +645,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
(current) =>
current ?? {
...source,
gallery: filePreviewGallery(source, composerImageGallery(draftAttachments)),
gallery: filePreviewGallery(source, composerImageGallery(stripAttachments)),
},
);
};
Expand Down
21 changes: 16 additions & 5 deletions apps/mobile/src/lib/composerFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe("composer file attachments", () => {
});

it.each(["image/heic", "image/heif", undefined])(
"renders a %s photo to JPEG natively and previews the rendered file",
"renders a %s photo to JPEG natively and previews the picker copy",
async (mimeType) => {
mocks.pickMedia.mockResolvedValue({
canceled: false,
Expand All @@ -181,7 +181,7 @@ describe("composer file attachments", () => {
mimeType: "image/jpeg",
sizeBytes: 4,
dataUrl: `data:image/jpeg;base64,${rendered.base64}`,
previewUri: rendered.uri,
previewUri: photo.uri,
},
],
error: null,
Expand Down Expand Up @@ -248,12 +248,14 @@ describe("composer file attachments", () => {
name: "IMG_9280.png",
mimeType: "image/png",
sizeBytes: last.width * last.height * 4,
previewUri: `file:///cache/fitted-${last.width}x${last.height}.png`,
previewUri: photo.uri,
}),
);
expect(image!.sizeBytes).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES);
expect(mocks.delete).toHaveBeenCalledWith("file:///cache/fitted-1397x2000.png");
expect(mocks.delete).not.toHaveBeenCalledWith(image!.previewUri);
expect(mocks.delete).toHaveBeenCalledWith(
`file:///cache/fitted-${last.width}x${last.height}.png`,
);
});

it("measures the picker file instead of trusting the reported size", async () => {
Expand Down Expand Up @@ -362,8 +364,17 @@ describe("composer file attachments", () => {
uri.endsWith("clip.mov") ? video.fileSize : 3,
);

const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 });
const onPicked = vi.fn();
const result = await pickComposerMedia({
existingCount: 0,
maxVideoBytes: 50 * 1024 * 1024,
onPicked,
});

expect(onPicked).toHaveBeenCalledWith([
expect.objectContaining({ id: "attachment-id", type: "image", previewUri: image.uri }),
expect.objectContaining({ id: "attachment-id", type: "file", fileUri: video.uri }),
]);
expect(mocks.pickMedia).toHaveBeenCalledWith(
expect.objectContaining({
mediaTypes: ["images", "videos"],
Expand Down
51 changes: 41 additions & 10 deletions apps/mobile/src/lib/composerImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ export async function pickComposerImages(input: { readonly existingCount: number
export async function pickComposerMedia(input: {
readonly existingCount: number;
readonly maxVideoBytes?: number;
/** Receives placeholders over the picker's local copies before processing; each keeps its attachment's id. */
readonly onPicked?: (placeholders: ReadonlyArray<DraftComposerAttachment>) => void;
}): Promise<{
readonly attachments: ReadonlyArray<DraftComposerAttachment>;
readonly error: string | null;
Expand Down Expand Up @@ -425,11 +427,28 @@ export async function pickComposerMedia(input: {
error: null,
};
}
const ids = result.assets.map(() => uuidv4());
input.onPicked?.(
result.assets.flatMap((asset, index): DraftComposerAttachment[] => {
const common = {
id: ids[index]!,
name: asset.fileName ?? "",
mimeType: asset.mimeType ?? "",
sizeBytes: asset.fileSize ?? 0,
};
if (asset.type === "image" || asset.mimeType?.startsWith("image/")) {
return [{ ...common, type: "image", previewUri: asset.uri }];
}
return input.maxVideoBytes === undefined
? []
: [{ ...common, type: "file", fileUri: asset.uri }];
}),
);

const attachments: DraftComposerAttachment[] = [];
let error: string | null = null;

for (const asset of result.assets) {
for (const [index, asset] of result.assets.entries()) {
if (attachments.length >= remainingSlots) {
error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
break;
Expand All @@ -443,15 +462,16 @@ export async function pickComposerMedia(input: {
try {
const { File } = await import("expo-file-system");
const file = new File(asset.uri);
attachments.push(
await createComposerFileAttachment({
attachments.push({
...(await createComposerFileAttachment({
uri: asset.uri,
name: asset.fileName?.trim() || file.name || "video",
mimeType: mimeType || file.type || "application/octet-stream",
sizeBytes: asset.fileSize ?? null,
maxBytes: clampFileAttachmentUploadBytes(input.maxVideoBytes),
}),
);
})),
id: ids[index]!,
});
} catch (cause) {
error =
cause instanceof Error ? cause.message : `Could not read '${asset.fileName ?? "video"}'.`;
Expand Down Expand Up @@ -482,7 +502,8 @@ export async function pickComposerMedia(input: {
? mimeType
: null;

let image: { base64: string; mimeType: string; name: string; previewUri: string };
let image: { base64: string; mimeType: string; name: string };
let renderedUri: string | undefined;
try {
if (originalMimeType !== null) {
const { File } = await import("expo-file-system");
Expand All @@ -492,25 +513,34 @@ export async function pickComposerMedia(input: {
mimeType: originalMimeType,
base64,
});
renderedUri = fitted?.uri;
image = {
base64: fitted?.base64 ?? base64,
mimeType: originalMimeType,
name,
previewUri: fitted?.uri ?? asset.uri,
};
} else {
const rendered = await renderPhoto(asset.uri);
renderedUri = rendered.uri;
image = {
base64: rendered.base64,
mimeType: "image/jpeg",
name: /\.jpe?g$/i.test(name) ? name : `${name.replace(/\.[^.]+$/, "")}.jpg`,
previewUri: rendered.uri,
};
}
} catch {
error = `Failed to read '${name}'.`;
continue;
}
// The bytes now live in the data URL, and the preview uses the picker copy.
if (renderedUri !== undefined) {
try {
const { File } = await import("expo-file-system");
new File(renderedUri).delete();
} catch (cause) {
console.warn("[composer-images] could not remove rendered photo", cause);
}
}

const sizeBytes = estimateBase64ByteSize(image.base64);
if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
Expand All @@ -519,13 +549,14 @@ export async function pickComposerMedia(input: {
}

attachments.push({
id: uuidv4(),
id: ids[index]!,
type: "image",
name: image.name,
mimeType: image.mimeType,
sizeBytes,
dataUrl: `data:${image.mimeType};base64,${image.base64}`,
previewUri: image.previewUri,
// The picker copy is already on screen as the placeholder; reusing it avoids a reload flash.
previewUri: asset.uri,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
}

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ vi.mock("./use-composer-drafts", async () => {
return {
composerDraftsAtom: Atom.make({}),
composerContextImportsAtom: Atom.make({}),
composerDraftBusy: () => false,
getComposerDraftSnapshot: () => state.draft,
isFreshSessionPending: () => state.freshPending,
setFreshSessionPending: state.setFreshPending,
Expand Down
26 changes: 19 additions & 7 deletions apps/mobile/src/state/use-composer-attachment-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
captureComposerDraftInsertion,
countComposerDraftAttachmentsAfterSelection,
getComposerDraftSnapshot,
addComposerPendingMedia,
keepPendingComposerMedia,
removeComposerPendingMedia,
type ComposerDraftInsertion,
} from "./use-composer-drafts";
import { startComposerDraftOperation } from "./composerDraftOperations";
Expand Down Expand Up @@ -63,15 +66,24 @@ export function useComposerAttachmentActions(props: {
if (!key || props.locked) return;
const insertion = captureComposerDraftInsertion(key);
await withDraftOperation(key, async (signal) => {
const result = await pickComposerMedia({
let placeholderIds: ReadonlyArray<string> = [];
await pickComposerMedia({
existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion),
maxVideoBytes: capabilities?.fileAttachments?.maxUploadBytes,
});
report(
"Could not attach photo or video",
result.error,
await append(key, signal, result.attachments, insertion),
);
onPicked: (placeholders) => {
placeholderIds = placeholders.map(({ id }) => id);
addComposerPendingMedia(key, placeholders);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
})
.then(async (result) => {
// append writes the draft before its first await, so no removal lands in between.
report(
"Could not attach photo or video",
result.error,
await append(key, signal, keepPendingComposerMedia(key, result.attachments), insertion),
);
})
.finally(() => removeComposerPendingMedia(key, placeholderIds));
});
};
const onPickDraftFiles = async () => {
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ import {
findNewTaskDraftKeys,
flushComposerDrafts,
getComposerDraftSnapshot,
addComposerPendingMedia,
keepPendingComposerMedia,
removeComposerDraftAttachment,
mergeComposerDraftContentState,
migrateLegacyNewTaskDraft,
modelOptionMemoryAtom,
Expand Down Expand Up @@ -1063,6 +1066,36 @@ describe("mobile composer drafts", () => {
expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101);
});

it("releases picked media whose placeholder was removed while it processed", async () => {
const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
onTestFinished(() => outboxLoad.mockRestore());
const cleanup = Promise.withResolvers<void>();
composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
cleanup.resolve();
});
const video = (id: string) => ({
id,
type: "file" as const,
name: `${id}.mov`,
mimeType: "video/quicktime",
sizeBytes: 42,
fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,
});
const draftKey = "thread-pending-media";
appAtomRegistry.set(composerDraftsAtom, { [draftKey]: { text: "", attachments: [] } });
addComposerPendingMedia(draftKey, [video("kept"), video("removed")]);

removeComposerDraftAttachment(draftKey, "removed");

expect(keepPendingComposerMedia(draftKey, [video("kept"), video("removed")])).toEqual([
video("kept"),
]);
await cleanup.promise;
expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(
video("removed").fileUri,
);
});

it("drops duplicate attachments, releases their files, and keeps them on restore", async () => {
const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
onTestFinished(() => outboxLoad.mockRestore());
Expand Down
Loading
Loading