Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,15 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
const handleNativePaste = useNativePaste((uris) => {
void (async () => {
try {
const images = await convertPastedImagesToAttachments({
const pasted = await convertPastedImagesToAttachments({
uris,
existingCount: attachments.length,
});
if (images.length > 0) {
setAttachments((current) => [...current, ...images]);
if (pasted.images.length > 0) {
setAttachments((current) => [...current, ...pasted.images]);
}
if (pasted.error) {
setPendingConnectionError(pasted.error);
}
} catch (error) {
console.error("[review comment] error converting pasted images", error);
Expand Down
17 changes: 13 additions & 4 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import { resolveSelectableModelSelection } from "../../lib/modelOptions";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
import {
setPendingConnectionError,
useRemoteConnectionStatus,
} from "../../state/use-remote-environment-registry";
import { useNewTaskFlow } from "./new-task-flow-provider";
import { useCreateProjectThread } from "./use-project-actions";
import { resolveDraftProjectSelection } from "./new-task-project-selection";
Expand Down Expand Up @@ -607,17 +610,23 @@ export function NewTaskDraftScreen(props: {
if (result.images.length > 0) {
flow.appendAttachments(result.images);
}
if (result.error) {
setPendingConnectionError(result.error);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

const handleNativePasteImages = useCallback(
async (uris: ReadonlyArray<string>) => {
try {
const images = await convertPastedImagesToAttachments({
const pasted = await convertPastedImagesToAttachments({
uris,
existingCount: flow.attachments.length,
});
if (images.length > 0) {
flow.appendAttachments(images);
if (pasted.images.length > 0) {
flow.appendAttachments(pasted.images);
}
if (pasted.error) {
setPendingConnectionError(pasted.error);
}
} catch (error) {
console.error("[native paste] error converting images", error);
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/features/threads/use-project-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";

import { threadEnvironment } from "../../state/threads";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import {
droppedAttachmentsWarning,
type DraftComposerImageAttachment,
} from "../../lib/composerImages";
import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
import { randomHex } from "../../lib/uuid";
Expand Down Expand Up @@ -84,7 +87,11 @@ export function useCreateProjectThread() {
);
return AsyncResult.failure(result.cause);
}
setPendingConnectionError(null);
// Legacy drafts can still carry data-url images. `thread.turn.start` now
// only accepts uploaded attachment ids, which mobile cannot mint yet, so
// those images are dropped and the user is told rather than left to
// assume they were sent. Null clears the banner when there are none.
setPendingConnectionError(droppedAttachmentsWarning(input.initialAttachments.length));

return mapAtomCommandResult(result, () =>
scopeThreadRef(input.project.environmentId, threadId),
Expand Down
80 changes: 25 additions & 55 deletions apps/mobile/src/lib/composerImages.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";

const files = new Map<string, { base64: string; deleted: boolean }>();

Expand Down Expand Up @@ -38,33 +37,22 @@ vi.mock("./uuid", () => ({

import {
convertPastedImagesToAttachments,
droppedAttachmentsWarning,
isOwnedPastedImageUri,
toUploadChatImageAttachments,
} from "./composerImages";

describe("toUploadChatImageAttachments", () => {
it("strips client draft id and previewUri for the startTurn wire shape", () => {
expect(
toUploadChatImageAttachments([
{
id: "client-draft-id",
type: "image",
name: "pasted-image.png",
mimeType: "image/png",
sizeBytes: 12,
dataUrl: "data:image/png;base64,AA==",
previewUri: "file:///tmp/preview.png",
},
]),
).toEqual([
{
type: "image",
name: "pasted-image.png",
mimeType: "image/png",
sizeBytes: 12,
dataUrl: "data:image/png;base64,AA==",
},
]);
describe("droppedAttachmentsWarning", () => {
it("stays quiet when a message carries no legacy images", () => {
expect(droppedAttachmentsWarning(0)).toBeNull();
});

it("names how many legacy images were left behind", () => {
expect(droppedAttachmentsWarning(1)).toBe(
"1 image was not sent. Image attach needs an app update.",
);
expect(droppedAttachmentsWarning(3)).toBe(
"3 images were not sent. Image attach needs an app update.",
);
});
});

Expand All @@ -83,42 +71,24 @@ describe("native pasted image cleanup", () => {
expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false);
});

it("converts owned files to data-backed previews and deletes the source", async () => {
const uri =
// Image attach is off until mobile implements upload-on-attach, so pasted
// images produce no attachment. Temp-file cleanup must still happen.
it("attaches nothing and deletes owned temp files, leaving user files alone", async () => {
const owned =
"file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png";
files.set(uri, { base64: "aGVsbG8=", deleted: false });

const attachments = await convertPastedImagesToAttachments({
uris: [uri],
existingCount: 0,
});

expect(attachments).toEqual([
expect.objectContaining({
dataUrl: "data:image/png;base64,aGVsbG8=",
previewUri: "data:image/png;base64,aGVsbG8=",
}),
]);
expect(files.get(uri)?.deleted).toBe(true);
});

it("deletes rejected and overflow owned files without deleting user-owned files", async () => {
const rejected =
"file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png";
const overflow =
"file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png";
const userOwned = "file:///private/var/mobile/photos/library.png";
files.set(rejected, { base64: "", deleted: false });
files.set(overflow, { base64: "aGVsbG8=", deleted: false });
files.set(owned, { base64: "aGVsbG8=", deleted: false });
files.set(userOwned, { base64: "aGVsbG8=", deleted: false });

await convertPastedImagesToAttachments({
uris: [rejected, overflow, userOwned],
existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1,
const pasted = await convertPastedImagesToAttachments({
uris: [owned, userOwned],
existingCount: 0,
});

expect(files.get(rejected)?.deleted).toBe(true);
expect(files.get(overflow)?.deleted).toBe(true);
expect(pasted.images).toEqual([]);
// The drop is surfaced to callers, not just logged.
expect(pasted.error).toContain("app update");
expect(files.get(owned)?.deleted).toBe(true);
expect(files.get(userOwned)?.deleted).toBe(false);
});
});
79 changes: 61 additions & 18 deletions apps/mobile/src/lib/composerImages.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
import {
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
type UploadChatImageAttachment,
} from "@t3tools/contracts";
import { estimateBase64ByteSize } from "./base64";
import { uuidv4 } from "./uuid";

export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
/**
* Local-only draft shape. It used to extend the contracts upload type, but
* `thread.turn.start` now carries id references to already-uploaded blobs, so
* `dataUrl` never reaches the wire and lives purely in mobile draft storage.
*/
export interface DraftComposerImageAttachment {
readonly id: string;
readonly previewUri: string;
readonly type: "image";
readonly name: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly dataUrl: string;
}

/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */
export function toUploadChatImageAttachments(
attachments: ReadonlyArray<DraftComposerImageAttachment>,
): ReadonlyArray<UploadChatImageAttachment> {
return attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
dataUrl: attachment.dataUrl,
}));
export const IMAGE_ATTACH_UNAVAILABLE_MESSAGE = "Image attach needs an app update.";

/**
* Copy for legacy draft/outbox images that predate the contract change and so
* cannot be sent. Returns null when there is nothing to warn about.
*/
export function droppedAttachmentsWarning(count: number): string | null {
if (count <= 0) {
return null;
}
const subject = count === 1 ? "1 image was" : `${count} images were`;
return `${subject} not sent. ${IMAGE_ATTACH_UNAVAILABLE_MESSAGE}`;
}

/**
* Mobile has no upload-on-attach implementation yet (web shipped first), and
* the old data-url path is gone from the contract. Capture stays disabled
* until the mobile port lands; flip this back on with that change.
*/
const IMAGE_ATTACH_ENABLED: boolean = false;

const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";

async function loadImagePicker() {
Expand All @@ -46,6 +63,10 @@ export async function pickComposerImages(input: { readonly existingCount: number
readonly images: ReadonlyArray<DraftComposerImageAttachment>;
readonly error: string | null;
}> {
if (!IMAGE_ATTACH_ENABLED) {
return { images: [], error: IMAGE_ATTACH_UNAVAILABLE_MESSAGE };
}

const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
if (remainingSlots <= 0) {
return {
Expand Down Expand Up @@ -137,7 +158,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu

const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;

if (await clipboard.hasImageAsync()) {
if ((await clipboard.hasImageAsync()) && IMAGE_ATTACH_ENABLED) {
if (remainingSlots <= 0) {
return {
images: [],
Expand Down Expand Up @@ -181,14 +202,26 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu
};
}

// Reached with attach disabled even when the clipboard holds an image:
// mixed copy payloads (common on iOS) must still paste their text, with
// the image drop surfaced rather than silently swallowed.
if (await clipboard.hasStringAsync()) {
const text = await clipboard.getStringAsync();
const droppedImage = !IMAGE_ATTACH_ENABLED && (await clipboard.hasImageAsync());
return {
images: [],
text: text.length > 0 ? text : null,
error: text.length > 0 ? null : "Clipboard is empty.",
error: droppedImage
? IMAGE_ATTACH_UNAVAILABLE_MESSAGE
: text.length > 0
? null
: "Clipboard is empty.",
};
}
if (!IMAGE_ATTACH_ENABLED && (await clipboard.hasImageAsync())) {
// Image-only clipboard while attach is disabled.
return { images: [], text: null, error: IMAGE_ATTACH_UNAVAILABLE_MESSAGE };
}

return {
images: [],
Expand Down Expand Up @@ -234,9 +267,19 @@ export function isOwnedPastedImageUri(uri: string): boolean {
export async function convertPastedImagesToAttachments(input: {
readonly uris: ReadonlyArray<string>;
readonly existingCount: number;
}): Promise<ReadonlyArray<DraftComposerImageAttachment>> {
}): Promise<{
readonly images: ReadonlyArray<DraftComposerImageAttachment>;
/** Set when pasted images were dropped; callers surface it like a pick error. */
readonly error: string | null;
}> {
const { File } = await import("expo-file-system");
const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
// Zero slots while attach is disabled: the loop below still runs so owned
// temporary paste files are deleted, but nothing is decoded or attached.
const remainingSlots = IMAGE_ATTACH_ENABLED
? PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount
: 0;
const error =
!IMAGE_ATTACH_ENABLED && input.uris.length > 0 ? IMAGE_ATTACH_UNAVAILABLE_MESSAGE : null;
const results: DraftComposerImageAttachment[] = [];

for (const [index, uri] of input.uris.entries()) {
Expand Down Expand Up @@ -277,5 +320,5 @@ export async function convertPastedImagesToAttachments(input: {
}
}

return results;
return { images: results, error };
}
9 changes: 7 additions & 2 deletions apps/mobile/src/lib/projectThreadStartTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type RuntimeMode,
} from "@t3tools/contracts";

import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages";
import type { DraftComposerImageAttachment } from "./composerImages";

export function deriveThreadTitleFromPrompt(value: string): string {
const trimmed = value.trim();
Expand All @@ -28,6 +28,11 @@ export interface ProjectThreadStartTurnSpec {
readonly messageId: string;
readonly createdAt: string;
readonly text: string;
/**
* Legacy data-url drafts only. `thread.turn.start` now takes id references
* to uploaded blobs, which mobile cannot mint yet, so these are dropped
* rather than sent. Callers surface `droppedAttachmentsWarning` to the user.
*/
readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly modelSelection: ModelSelection;
readonly runtimeMode: RuntimeMode;
Expand Down Expand Up @@ -55,7 +60,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
messageId: MessageId.make(spec.messageId),
role: "user" as const,
text: spec.text,
attachments: toUploadChatImageAttachments(spec.attachments),
attachments: [],
},
modelSelection: spec.modelSelection,
titleSeed: title,
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,15 @@ export function useThreadComposerState() {

const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
try {
const images = await convertPastedImagesToAttachments({
const pasted = await convertPastedImagesToAttachments({
uris,
existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
});
if (images.length > 0) {
appendComposerDraftAttachments(threadKey, images);
if (pasted.images.length > 0) {
appendComposerDraftAttachments(threadKey, pasted.images);
}
if (pasted.error) {
setPendingConnectionError(pasted.error);
}
} catch (error) {
console.error("[native paste] error converting images", {
Expand Down
Loading
Loading