diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index c6d678ddca95..1a337f8da15d 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -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); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 87b12ad22f5f..81b8cd585dd2 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -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"; @@ -607,17 +610,23 @@ export function NewTaskDraftScreen(props: { if (result.images.length > 0) { flow.appendAttachments(result.images); } + if (result.error) { + setPendingConnectionError(result.error); + } } const handleNativePasteImages = useCallback( async (uris: ReadonlyArray) => { 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); diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a93..a82e6ac89ca8 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -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"; @@ -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), diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 21f2edaf52f6..6b739b2f3710 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -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(); @@ -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.", + ); }); }); @@ -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); }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index f559545c04ef..8e99e6ea1a6b 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -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, -): ReadonlyArray { - 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() { @@ -46,6 +63,10 @@ export async function pickComposerImages(input: { readonly existingCount: number readonly images: ReadonlyArray; 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 { @@ -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: [], @@ -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: [], @@ -234,9 +267,19 @@ export function isOwnedPastedImageUri(uri: string): boolean { export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; -}): Promise> { +}): Promise<{ + readonly images: ReadonlyArray; + /** 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()) { @@ -277,5 +320,5 @@ export async function convertPastedImagesToAttachments(input: { } } - return results; + return { images: results, error }; } diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 85523175a2f5..9597bf04ade5 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -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(); @@ -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; readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; @@ -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, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b09aadf7e6b7..f30830c10023 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -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", { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index d06a4098aab2..ffd571338c91 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,7 +17,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; -import { toUploadChatImageAttachments } from "../lib/composerImages"; +import { droppedAttachmentsWarning } from "../lib/composerImages"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useThreadShells } from "./entities"; @@ -44,7 +44,10 @@ import { useThreadOutboxMessages, useThreadOutboxShellStatuses, } from "./use-thread-outbox"; -import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; +import { + setPendingConnectionError, + useRemoteConnectionStatus, +} from "./use-remote-environment-registry"; export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -149,6 +152,14 @@ export function useThreadOutboxDrain(): void { return false; } + // Messages queued before the attachment contract change can still hold + // data-url images. They are never put on the wire, so tell the user the + // text went without them instead of dropping them silently. + const droppedWarning = droppedAttachmentsWarning(queuedMessage.attachments.length); + if (droppedWarning !== null) { + setPendingConnectionError(droppedWarning); + } + try { await removeThreadOutboxMessage(queuedMessage); return true; @@ -226,7 +237,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: toUploadChatImageAttachments(queuedMessage.attachments), + attachments: [], }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts new file mode 100644 index 000000000000..4b4cfa3a5254 --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -0,0 +1,106 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; +import { + deletePendingAttachment, + issueAttachmentUploadUrl, + storeAttachmentUpload, + validateAttachmentUploadToken, + ATTACHMENT_UPLOAD_ROUTE_PREFIX, +} from "./AttachmentUpload.ts"; + +const testLayer = ServerSecretStore.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })), + Layer.provideMerge(NodeServices.layer), +); + +const uploadInput = { + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, +} as const; + +describe("AttachmentUpload", () => { + it.effect("mints a pending id and a token that validates round-trip", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending"); + expect(issued.relativeUrl.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`)).toBe(true); + + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + expect(claims).toMatchObject({ + kind: "attachment-upload", + attachmentId: issued.attachmentId, + mimeType: "image/png", + sizeBytes: 6, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a tampered token", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const [payload, signature] = token.split("."); + const tampered = `${payload}x.${signature}`; + expect(yield* validateAttachmentUploadToken(tampered)).toBeNull(); + expect(yield* validateAttachmentUploadToken("garbage")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("stores matching bytes and rejects a size mismatch", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) throw new Error("expected valid claims"); + + const short = yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3])); + expect(short).toMatchObject({ ok: false, status: 400 }); + + const stored = yield* storeAttachmentUpload(claims, new Uint8Array(6)); + expect(stored).toEqual({ ok: true }); + const finalPath = NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`); + expect(NodeFS.existsSync(finalPath)).toBe(true); + // No .part residue after a successful store (suffix is per-request). + const partResidue = NodeFS.readdirSync(config.attachmentsDir).filter((entry) => + entry.endsWith(".part"), + ); + expect(partResidue).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("deletes pending uploads idempotently but never thread-scoped files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.mkdirSync(config.attachmentsDir, { recursive: true }); + const uuid = "00000000-0000-4000-8000-0000000000dd"; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + yield* deletePendingAttachment(`pending-${uuid}`); + expect(NodeFS.existsSync(pendingPath)).toBe(false); + // Second delete is a no-op, not an error. + yield* deletePendingAttachment(`pending-${uuid}`); + + const scopedUuid = "00000000-0000-4000-8000-0000000000ee"; + const scopedPath = NodePath.join(config.attachmentsDir, `thread-1-${scopedUuid}.png`); + NodeFS.writeFileSync(scopedPath, Buffer.from("pixels")); + // A delete aimed at a claimed attachment must not remove it. + yield* deletePendingAttachment(`pending-${scopedUuid}`); + yield* deletePendingAttachment(`thread-1-${scopedUuid}`); + expect(NodeFS.existsSync(scopedPath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts new file mode 100644 index 000000000000..14baab2db55a --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -0,0 +1,221 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + ATTACHMENT_UPLOAD_URL_TTL_MS, + type AttachmentCreateUploadUrlInput, + AttachmentUploadSigningKeyError, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + createPendingAttachmentId, + findAttachmentPathByUuid, + parseAttachmentIdFromRelativePath, + parseAttachmentUuid, + parseThreadSegmentFromAttachmentId, + PENDING_ATTACHMENT_THREAD_SEGMENT, +} from "../attachmentStore.ts"; +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { inferImageExtension } from "../imageMime.ts"; + +export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload"; + +// Shares the asset-access secret: upload and asset claims are distinguished +// by their schema `kind`, so a token minted for one can never validate as +// the other, and one key covers both signing uses. +const SIGNING_SECRET_NAME = "asset-access-signing-key"; + +const UploadClaimsSchema = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + mimeType: Schema.String, + /** Exact byte count the client committed to at mint time. */ + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +export type AttachmentUploadClaims = typeof UploadClaimsSchema.Type; + +const UploadClaimsJson = Schema.fromJsonString(UploadClaimsSchema); +const decodeUploadClaimsOption = Schema.decodeUnknownOption(UploadClaimsJson); +const encodeUploadClaims = Schema.encodeSync(UploadClaimsJson); + +// Plain function (not Effect) so the base64/JSON failure modes stay a simple +// null, mirroring AssetAccess.decodeClaims. +function decodeUploadClaims(encodedPayload: string): AttachmentUploadClaims | null { + try { + return Option.getOrNull(decodeUploadClaimsOption(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +const loadSigningSecret = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); +}); + +/** + * Mints the `pending-` id and a signed, expiring upload URL for it. + * Called over the authenticated ws; the returned URL itself carries + * authorization (mirroring signed asset GET URLs), which is what lets the + * browser POST bytes to any environment without extra credential plumbing. + */ +export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* ( + input: AttachmentCreateUploadUrlInput, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })), + ); + const attachmentId = createPendingAttachmentId(); + const expiresAt = (yield* Clock.currentTimeMillis) + ATTACHMENT_UPLOAD_URL_TTL_MS; + const encodedPayload = base64UrlEncode( + encodeUploadClaims({ + version: 1, + kind: "attachment-upload", + attachmentId, + mimeType: input.mimeType.toLowerCase(), + sizeBytes: input.sizeBytes, + expiresAt, + }), + ); + const token = `${encodedPayload}.${signPayload(encodedPayload, secret)}`; + return { + attachmentId, + relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${token}`, + expiresAt, + }; +}); + +/** Verifies signature and expiry; null means "treat as not found". */ +export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* ( + token: string, +) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) return null; + + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the attachment upload signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) return null; + + const claims = decodeUploadClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + return claims; +}); + +export type StoreUploadResult = + | { readonly ok: true } + | { readonly ok: false; readonly status: number; readonly detail: string }; + +/** + * Persists validated upload bytes. Writes to `..part` first and + * renames on success, so a crashed or aborted upload can never leave a file + * that looks like a complete attachment. Re-running with the same token + * overwrites atomically, which makes client retries safe. + */ +export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( + claims: AttachmentUploadClaims, + bytes: Uint8Array, +) { + if (bytes.byteLength !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreUploadResult; + } + const config = yield* ServerConfig.ServerConfig; + const extension = inferImageExtension({ mimeType: claims.mimeType }); + const relativePath = `${claims.attachmentId}${extension}`; + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath, + }); + // Unique per request: two concurrent POSTs of the same token must not + // interleave writes into one temp file. Both rename onto the final path + // atomically; last one wins with identical claims-validated content. + const partPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`, + }); + if (!finalPath || !partPath) { + return { ok: false, status: 500, detail: "Failed to resolve attachment path." }; + } + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const writeResult = yield* Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); + yield* fileSystem.writeFile(partPath, bytes); + yield* fileSystem.rename(partPath, finalPath); + }).pipe( + Effect.as({ ok: true } satisfies StoreUploadResult), + Effect.tapError((cause) => + Effect.logError("Failed to persist attachment upload.", { + attachmentId: claims.attachmentId, + cause, + }), + ), + Effect.orElseSucceed( + () => + ({ + ok: false, + status: 500, + detail: "Failed to persist upload.", + }) satisfies StoreUploadResult, + ), + ); + return writeResult; +}); + +/** + * Deletes a never-sent upload. Idempotent, and refuses (as a silent no-op) + * anything already claimed by a thread: those files are owned by their + * message and only die with the thread. + */ +export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* ( + attachmentId: string, +) { + if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return; + } + const uuid = parseAttachmentUuid(attachmentId); + if (!uuid) return; + const config = yield* ServerConfig.ServerConfig; + const filePath = findAttachmentPathByUuid({ attachmentsDir: config.attachmentsDir, uuid }); + if (!filePath) return; + const path = yield* Path.Path; + const foundId = parseAttachmentIdFromRelativePath(path.basename(filePath)); + if ( + !foundId || + parseThreadSegmentFromAttachmentId(foundId) !== PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + return; + } + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(filePath).pipe( + Effect.catch( + () => + // Raced with the sweep or a duplicate delete; idempotent by design. + Effect.void, + ), + ); +}); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf5..5846633a6db4 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,10 +7,19 @@ import { describe, expect, it } from "vite-plus/test"; import { createAttachmentId, + createPendingAttachmentId, parseThreadSegmentFromAttachmentId, + planAttachmentClaim, resolveAttachmentPathById, + sweepStalePendingAttachments, + toSafeThreadAttachmentSegment, + PENDING_ATTACHMENT_MAX_AGE_MS, } from "./attachmentStore.ts"; +function makeTempAttachmentsDir(): string { + return NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-")); +} + describe("attachmentStore", () => { it("sanitizes thread ids when creating attachment ids", () => { const attachmentId = createAttachmentId("thread.folder/unsafe space"); @@ -45,11 +54,9 @@ describe("attachmentStore", () => { }); it("resolves attachment path by id using the extension that exists on disk", () => { - const attachmentsDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), - ); + const attachmentsDir = makeTempAttachmentsDir(); try { - const attachmentId = "thread-1-attachment"; + const attachmentId = "thread-1-00000000-0000-4000-8000-00000000000a"; const pngPath = NodePath.join(attachmentsDir, `${attachmentId}.png`); NodeFS.writeFileSync(pngPath, Buffer.from("hello")); @@ -63,6 +70,155 @@ describe("attachmentStore", () => { } }); + it("resolves a pending id to the renamed thread-scoped file after claim", () => { + const attachmentsDir = makeTempAttachmentsDir(); + try { + const uuid = "00000000-0000-4000-8000-00000000000b"; + const scopedPath = NodePath.join(attachmentsDir, `thread-9-${uuid}.png`); + NodeFS.writeFileSync(scopedPath, Buffer.from("pixels")); + + // Old signed asset URLs carry the pending id; they must keep resolving. + const resolved = resolveAttachmentPathById({ + attachmentsDir, + attachmentId: `pending-${uuid}`, + }); + expect(resolved).toBe(scopedPath); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("reserves the pending segment for uploads", () => { + expect(toSafeThreadAttachmentSegment("pending")).toBe("pending_thread"); + const pendingId = createPendingAttachmentId(); + expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending"); + }); + + describe("planAttachmentClaim", () => { + const uuid = "00000000-0000-4000-8000-00000000000c"; + + it("plans a rename for a pending attachment", () => { + const attachmentsDir = makeTempAttachmentsDir(); + try { + const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.webp`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + const claimPlan = planAttachmentClaim({ + attachmentsDir, + threadId: "Thread.Foo", + attachmentId: `pending-${uuid}`, + }); + expect(claimPlan).toEqual({ + ok: true, + finalId: `thread-foo-${uuid}`, + currentPath: pendingPath, + finalPath: NodePath.join(attachmentsDir, `thread-foo-${uuid}.webp`), + alreadyScoped: false, + }); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("is idempotent when a retry references an already-renamed file", () => { + const attachmentsDir = makeTempAttachmentsDir(); + try { + const scopedPath = NodePath.join(attachmentsDir, `thread-foo-${uuid}.webp`); + NodeFS.writeFileSync(scopedPath, Buffer.from("pixels")); + + const claimPlan = planAttachmentClaim({ + attachmentsDir, + threadId: "Thread.Foo", + attachmentId: `pending-${uuid}`, + }); + expect(claimPlan).toMatchObject({ ok: true, alreadyScoped: true }); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("refuses an attachment claimed by another thread", () => { + const attachmentsDir = makeTempAttachmentsDir(); + try { + NodeFS.writeFileSync( + NodePath.join(attachmentsDir, `other-thread-${uuid}.png`), + Buffer.from("pixels"), + ); + + const claimPlan = planAttachmentClaim({ + attachmentsDir, + threadId: "Thread.Foo", + attachmentId: `pending-${uuid}`, + }); + expect(claimPlan).toMatchObject({ ok: false }); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("reports a missing attachment", () => { + const attachmentsDir = makeTempAttachmentsDir(); + try { + const claimPlan = planAttachmentClaim({ + attachmentsDir, + threadId: "Thread.Foo", + attachmentId: `pending-${uuid}`, + }); + expect(claimPlan).toMatchObject({ ok: false }); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + }); + + describe("sweepStalePendingAttachments", () => { + it("deletes stale pending and partial files, keeps fresh and scoped ones", () => { + const attachmentsDir = makeTempAttachmentsDir(); + try { + const stalePending = NodePath.join( + attachmentsDir, + "pending-00000000-0000-4000-8000-000000000001.png", + ); + const freshPending = NodePath.join( + attachmentsDir, + "pending-00000000-0000-4000-8000-000000000002.png", + ); + const scoped = NodePath.join( + attachmentsDir, + "thread-1-00000000-0000-4000-8000-000000000003.png", + ); + const stalePart = NodePath.join( + attachmentsDir, + "pending-00000000-0000-4000-8000-000000000004.png.part", + ); + // Fixed epoch so the test never touches the real clock: files are + // stamped relative to `nowMs` below, not to wall time. + const nowMs = 1_800_000_000_000; + const freshMs = nowMs - 60_000; + const staleMs = nowMs - PENDING_ATTACHMENT_MAX_AGE_MS - 60_000; + for (const filePath of [stalePending, freshPending, scoped, stalePart]) { + NodeFS.writeFileSync(filePath, Buffer.from("x")); + } + NodeFS.utimesSync(freshPending, freshMs / 1000, freshMs / 1000); + NodeFS.utimesSync(stalePending, staleMs / 1000, staleMs / 1000); + NodeFS.utimesSync(scoped, staleMs / 1000, staleMs / 1000); + NodeFS.utimesSync(stalePart, staleMs / 1000, staleMs / 1000); + + const swept = sweepStalePendingAttachments({ + attachmentsDir, + nowMs, + }); + expect(swept.deleted).toBe(2); + expect(NodeFS.existsSync(stalePending)).toBe(false); + expect(NodeFS.existsSync(stalePart)).toBe(false); + expect(NodeFS.existsSync(freshPending)).toBe(true); + expect(NodeFS.existsSync(scoped)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + }); + it("returns null when no attachment file exists for the id", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..fe33e35c291c 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -8,9 +9,8 @@ import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths.ts"; -import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; +import { inferImageExtension } from "./imageMime.ts"; -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -19,6 +19,13 @@ const ATTACHMENT_ID_PATTERN = new RegExp( "i", ); +/** + * Segment used for attachments uploaded before their thread exists + * (upload-on-attach). Files named `pending-.` are re-scoped to the + * thread at turn start and swept when stale. + */ +export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; + export function toSafeThreadAttachmentSegment(threadId: string): string | null { const segment = threadId .trim() @@ -31,9 +38,65 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { if (segment.length === 0) { return null; } + // `pending` is reserved for not-yet-sent uploads; a thread that slugged to + // it would have its attachments swept as orphans. + if (segment === PENDING_ATTACHMENT_THREAD_SEGMENT) { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}_thread`; + } return segment; } +export function createPendingAttachmentId(): string { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; +} + +/** Extracts the trailing uuid from a `-` attachment id. */ +export function parseAttachmentUuid(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + const match = normalizedId.match(ATTACHMENT_ID_PATTERN); + return match?.[2]?.toLowerCase() ?? null; +} + +/** + * Finds the on-disk file for an attachment uuid regardless of its current + * segment (`pending-` before send, thread-scoped after). The uuid is the + * stable identity: turn-start renames the file's segment but never the uuid, + * which is what keeps send retries and signed asset URLs working across the + * rename. + */ +export function findAttachmentPathByUuid(input: { + readonly attachmentsDir: string; + readonly uuid: string; +}): string | null { + const uuid = input.uuid.toLowerCase(); + if (!new RegExp(`^${ATTACHMENT_ID_UUID_PATTERN}$`, "i").test(uuid)) { + return null; + } + let entries: string[]; + try { + entries = NodeFS.readdirSync(input.attachmentsDir); + } catch { + return null; + } + const suffixPattern = new RegExp(`-${uuid}\\.[a-z0-9]{1,8}$`, "i"); + for (const entry of entries) { + if (!suffixPattern.test(entry)) { + continue; + } + const resolved = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: entry, + }); + if (resolved && NodeFS.existsSync(resolved)) { + return resolved; + } + } + return null; +} + export function createAttachmentId(threadId: string): string | null { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { @@ -80,20 +143,122 @@ export function resolveAttachmentPathById(input: { readonly attachmentsDir: string; readonly attachmentId: string; }): string | null { - const normalizedId = normalizeAttachmentRelativePath(input.attachmentId); - if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + // Resolution is by uuid, not by exact id: a signed asset URL minted for a + // `pending-` id must keep working after turn start renames the file to its + // thread segment (draft previews hold such URLs across a send). + const uuid = parseAttachmentUuid(input.attachmentId); + if (!uuid) { return null; } - for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { - const maybePath = resolveAttachmentRelativePath({ + return findAttachmentPathByUuid({ attachmentsDir: input.attachmentsDir, uuid }); +} + +export type AttachmentClaimPlan = + | { + readonly ok: true; + readonly finalId: string; + readonly currentPath: string; + readonly finalPath: string; + /** True when a prior (possibly failed) send already renamed the file. */ + readonly alreadyScoped: boolean; + } + | { readonly ok: false; readonly reason: string }; + +/** + * Plans the pending-to-thread re-scope for one referenced attachment at turn + * start. Pure path logic; the caller performs the rename. The uuid is matched + * against whatever segment the file currently has so retries after a partial + * send are idempotent, and a file already claimed by a different thread is + * refused rather than stolen. + */ +export function planAttachmentClaim(input: { + readonly attachmentsDir: string; + readonly threadId: string; + readonly attachmentId: string; +}): AttachmentClaimPlan { + const uuid = parseAttachmentUuid(input.attachmentId); + if (!uuid) { + return { ok: false, reason: "invalid attachment id" }; + } + const threadSegment = toSafeThreadAttachmentSegment(input.threadId); + if (!threadSegment) { + return { ok: false, reason: "invalid thread id" }; + } + const currentPath = findAttachmentPathByUuid({ attachmentsDir: input.attachmentsDir, uuid }); + if (!currentPath) { + return { ok: false, reason: "attachment not found (removed or expired)" }; + } + const fileName = NodePath.basename(currentPath); + const currentId = parseAttachmentIdFromRelativePath(fileName); + const currentSegment = currentId ? parseThreadSegmentFromAttachmentId(currentId) : null; + if (!currentSegment) { + return { ok: false, reason: "attachment file name is malformed" }; + } + const extension = NodePath.extname(fileName); + const finalId = `${threadSegment}-${uuid}`; + if (currentSegment === threadSegment) { + return { ok: true, finalId, currentPath, finalPath: currentPath, alreadyScoped: true }; + } + if (currentSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return { ok: false, reason: "attachment belongs to another thread" }; + } + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${finalId}${extension}`, + }); + if (!finalPath) { + return { ok: false, reason: "failed to resolve attachment path" }; + } + return { ok: true, finalId, currentPath, finalPath, alreadyScoped: false }; +} + +export const PENDING_ATTACHMENT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +export const PARTIAL_UPLOAD_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +/** + * Deletes never-sent uploads (`pending-*`) past their retention window and + * half-written `*.part` files left by aborted uploads. Runs at server start; + * chip removal deletes eagerly, this is the backstop. + */ +export function sweepStalePendingAttachments(input: { + readonly attachmentsDir: string; + readonly nowMs: number; +}): { readonly deleted: number } { + let entries: string[]; + try { + entries = NodeFS.readdirSync(input.attachmentsDir); + } catch { + return { deleted: 0 }; + } + let deleted = 0; + for (const entry of entries) { + const isPartial = entry.endsWith(".part"); + const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS; + if (!isPartial) { + const id = parseAttachmentIdFromRelativePath(entry); + const segment = id ? parseThreadSegmentFromAttachmentId(id) : null; + if (segment !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + continue; + } + } + const resolved = resolveAttachmentRelativePath({ attachmentsDir: input.attachmentsDir, - relativePath: `${normalizedId}${extension}`, + relativePath: entry, }); - if (maybePath && NodeFS.existsSync(maybePath)) { - return maybePath; + if (!resolved) { + continue; + } + try { + const stats = NodeFS.statSync(resolved); + if (input.nowMs - stats.mtimeMs > maxAgeMs) { + NodeFS.unlinkSync(resolved); + deleted += 1; + } + } catch { + // Raced with a concurrent delete or an unreadable entry; skip it. } } - return null; + return { deleted }; } export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370a..e7699a2dd9fa 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -83,6 +83,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, + [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, + [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e678264dde5f..7f8dc963836a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,7 @@ * * @module ServerConfig */ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -14,6 +15,8 @@ import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { sweepStalePendingAttachments } from "./attachmentStore.ts"; + export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); @@ -152,6 +155,18 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server ], { concurrency: "unbounded" }, ); + + // Backstop GC for upload-on-attach: never-sent `pending-*` uploads and + // aborted `*.part` files. Eager deletes happen at chip removal; this covers + // crashed clients and abandoned drafts. + const nowMs = yield* Clock.currentTimeMillis; + const swept = sweepStalePendingAttachments({ + attachmentsDir: derivedPaths.attachmentsDir, + nowMs, + }); + if (swept.deleted > 0) { + yield* Effect.logInfo("Swept stale pending attachments.", { deleted: swept.deleted }); + } }); const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0da55686b92f..765838916888 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { + ATTACHMENT_UPLOAD_ROUTE_PREFIX, + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "./assets/AttachmentUpload.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -226,6 +231,46 @@ export const assetRouteLayer = HttpRouter.add( }), ); +export const attachmentUploadRouteLayer = HttpRouter.add( + "POST", + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + if (token.length === 0) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + // Invalid, tampered, and expired tokens are indistinguishable from + // missing ones on purpose, matching the signed asset GET route. + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const contentLength = Number(request.headers["content-length"] ?? ""); + if (!Number.isInteger(contentLength) || contentLength !== claims.sizeBytes) { + return HttpServerResponse.text("Content-Length must match the minted upload size.", { + status: 400, + }); + } + + const body = yield* request.arrayBuffer.pipe(Effect.orElseSucceed(() => null)); + if (body === null) { + return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); + } + + const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body)); + return stored.ok + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.text(stored.detail, { status: stored.status }); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index e87e4dcbd0dc..41e22097dad4 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -1,92 +1,20 @@ import { describe, expect, it } from "vite-plus/test"; -import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts"; +import { inferImageExtension } from "./imageMime.ts"; describe("imageMime", () => { - it("parses base64 data URL with mime type", () => { - expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=")).toEqual({ - mimeType: "image/png", - base64: "SGVsbG8=", - }); + it("maps known image mime types to extensions", () => { + expect(inferImageExtension({ mimeType: "image/png" })).toBe(".png"); + expect(inferImageExtension({ mimeType: "image/jpeg" })).toBe(".jpg"); + expect(inferImageExtension({ mimeType: "IMAGE/WEBP" })).toBe(".webp"); }); - it("parses base64 data URL with mime parameters", () => { - expect(parseBase64DataUrl("data:image/png;charset=utf-8;base64,SGVsbG8=")).toEqual({ - mimeType: "image/png", - base64: "SGVsbG8=", - }); + it("falls back to a safe file name extension", () => { + expect(inferImageExtension({ mimeType: "image/unknown", fileName: "shot.PNG" })).toBe(".png"); }); - it("rejects non-base64 data URL", () => { - expect(parseBase64DataUrl("data:image/png;charset=utf-8,hello")).toBeNull(); - }); - - it("rejects missing mime type", () => { - expect(parseBase64DataUrl("data:;base64,SGVsbG8=")).toBeNull(); - }); - - it("parses base64 data URL with spaces in payload", () => { - expect(parseBase64DataUrl("data:image/png;base64,SGVs bG8=\n")).toEqual({ - mimeType: "image/png", - base64: "SGVsbG8=", - }); - }); - - it("rejects payload with characters outside the base64 alphabet", () => { - expect(parseBase64DataUrl("data:image/png;base64,SGVs!bG8=")).toBeNull(); - expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull(); - }); - - it("rejects structurally malformed base64", () => { - // '=' before the trailing padding position - expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull(); - expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull(); - // more than two padding characters - expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=====AAA")).toBeNull(); - // length not a multiple of 4 - expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8")).toBeNull(); - }); - - it("accepts base64 with one or two trailing padding characters", () => { - expect(parseBase64DataUrl("data:image/png;base64,SGVsbA==")).toEqual({ - mimeType: "image/png", - base64: "SGVsbA==", - }); - expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8h")).toEqual({ - mimeType: "image/png", - base64: "SGVsbG8h", - }); - }); - - it("rejects empty and whitespace-only payloads", () => { - expect(parseBase64DataUrl("data:image/png;base64,")).toBeNull(); - expect(parseBase64DataUrl("data:image/png;base64, \r\n")).toBeNull(); - }); - - it("parses a case-insensitive scheme and mime type", () => { - expect(parseBase64DataUrl("DATA:IMAGE/PNG;BASE64,SGVsbG8=")).toEqual({ - mimeType: "image/png", - base64: "SGVsbG8=", - }); - }); - - it("parses a multi-megabyte payload from a deep call stack", () => { - // Regression: matching the payload with a regex borrowed the JS call - // stack, so a ~10 MB image parsed inside fiber execution threw - // "RangeError: Maximum call stack size exceeded". - const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`; - const atDepth = (depth: number): ReturnType => - depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1); - const findMaxDepth = (depth: number): number => { - try { - return findMaxDepth(depth + 1); - } catch { - return depth; - } - }; - const result = atDepth(Math.floor(findMaxDepth(0) * 0.85)); - expect(result?.mimeType).toBe("image/png"); - expect(result?.base64.length).toBe(14_000_000); + it("falls back to .bin when nothing safe matches", () => { + expect(inferImageExtension({ mimeType: "image/unknown", fileName: "shot.exe" })).toBe(".bin"); }); it("does not read inherited keys from mime extension map", () => { diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 66ce6096e853..2400a150ff0c 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,89 +29,6 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); -// Whether `code` is a character the base64 payload may contain, aside from -// the whitespace handled separately below. -function isBase64Char(code: number): boolean { - return ( - (code >= 0x61 && code <= 0x7a) || // a-z - (code >= 0x41 && code <= 0x5a) || // A-Z - (code >= 0x30 && code <= 0x39) || // 0-9 - code === 0x2b || // + - code === 0x2f || // / - code === 0x3d // = - ); -} - -function isBase64Whitespace(code: number): boolean { - return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space -} - -// Data URLs carry the full image payload, so this parser must never run a -// regex across the payload: V8's regex engine borrows the JS call stack, and -// matching a multi-megabyte string from a deep call stack (e.g. inside fiber -// execution) throws "Maximum call stack size exceeded". -export function parseBase64DataUrl( - dataUrl: string, -): { readonly mimeType: string; readonly base64: string } | null { - const trimmed = dataUrl.trim(); - if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null; - - const commaIndex = trimmed.indexOf(","); - if (commaIndex === -1) return null; - const header = trimmed.slice(5, commaIndex); - if (header.length === 0) return null; - - const headerParts: Array = []; - for (const part of header.split(";")) { - const partTrimmed = part.trim(); - if (partTrimmed.length > 0) { - headerParts.push(partTrimmed); - } - } - if (headerParts.length < 2) { - return null; - } - const trailingToken = headerParts.at(-1)?.toLowerCase(); - if (trailingToken !== "base64") { - return null; - } - - const mimeType = headerParts[0]?.toLowerCase(); - if (!mimeType) return null; - - const payload = trimmed.slice(commaIndex + 1); - const runs: Array = []; - let runStart = -1; - for (let index = 0; index < payload.length; index += 1) { - const code = payload.charCodeAt(index); - if (isBase64Char(code)) { - if (runStart === -1) runStart = index; - continue; - } - if (!isBase64Whitespace(code)) return null; - if (runStart !== -1) { - runs.push(payload.slice(runStart, index)); - runStart = -1; - } - } - if (runStart !== -1) { - runs.push(payload.slice(runStart)); - } - const base64 = runs.length === 1 ? runs[0]! : runs.join(""); - if (base64.length === 0 || base64.length % 4 !== 0) return null; - const firstPad = base64.indexOf("="); - if (firstPad !== -1) { - // '=' is only valid as one or two trailing padding characters; Node's - // decoder would otherwise silently truncate at the first '='. - if (base64.length - firstPad > 2) return null; - for (let index = firstPad; index < base64.length; index += 1) { - if (base64.charCodeAt(index) !== 0x3d) return null; - } - } - - return { mimeType, base64 }; -} - export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { const key = input.mimeType.toLowerCase(); const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key) diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts new file mode 100644 index 000000000000..c5e75b1a64ad --- /dev/null +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -0,0 +1,138 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + type ClientOrchestrationCommand, + CommandId, + MessageId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { normalizeDispatchCommand } from "./Normalizer.ts"; + +const testLayer = Layer.mergeAll( + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-normalizer-attachments-" }), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const UUID = "00000000-0000-4000-8000-0000000000aa"; + +function turnStartCommand(attachment: { + readonly id: string; + readonly sizeBytes: number; +}): ClientOrchestrationCommand { + return { + type: "thread.turn.start", + commandId: CommandId.make("command-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "look at this", + attachments: [ + { + type: "image", + id: attachment.id, + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: attachment.sizeBytes, + }, + ], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-01T00:00:00.000Z", + }; +} + +describe("normalizeDispatchCommand attachments", () => { + it.effect("claims a pending upload: renames the file and rewrites the id", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${UUID}.png`); + NodeFS.mkdirSync(config.attachmentsDir, { recursive: true }); + NodeFS.writeFileSync(pendingPath, bytes); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ id: `pending-${UUID}`, sizeBytes: bytes.byteLength }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command"); + } + expect(normalized.message.attachments).toHaveLength(1); + expect(normalized.message.attachments[0]?.id).toBe(`thread-1-${UUID}`); + expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `thread-1-${UUID}.png`))).toBe( + true, + ); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("is idempotent when a retry references an already-claimed file", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + NodeFS.mkdirSync(config.attachmentsDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `thread-1-${UUID}.png`), bytes); + + // The retry still carries the original pending id. + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ id: `pending-${UUID}`, sizeBytes: bytes.byteLength }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command"); + } + expect(normalized.message.attachments[0]?.id).toBe(`thread-1-${UUID}`); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("fails when the referenced upload does not exist", () => + Effect.gen(function* () { + const error = yield* normalizeDispatchCommand( + turnStartCommand({ id: `pending-${UUID}`, sizeBytes: 6 }), + ).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationDispatchCommandError"); + expect(error.message).toContain("not found"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("fails when the stored size does not match the reference", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.mkdirSync(config.attachmentsDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${UUID}.png`), + Buffer.from("pixels"), + ); + + const error = yield* normalizeDispatchCommand( + turnStartCommand({ id: `pending-${UUID}`, sizeBytes: 999 }), + ).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationDispatchCommandError"); + expect(error.message).toContain("size"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("refuses an attachment already claimed by another thread", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + NodeFS.mkdirSync(config.attachmentsDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `other-thread-${UUID}.png`), bytes); + + const error = yield* normalizeDispatchCommand( + turnStartCommand({ id: `pending-${UUID}`, sizeBytes: bytes.byteLength }), + ).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationDispatchCommandError"); + expect(error.message).toContain("another thread"); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..b17d3e9b9cc8 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -1,18 +1,15 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import { type ClientOrchestrationCommand, type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { planAttachmentClaim } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; -import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const canonicalizeClientCommandTimestamps = ( @@ -48,7 +45,6 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => const receivedAt = DateTime.formatIso(yield* DateTime.now); const canonicalCommand = canonicalizeClientCommandTimestamps(command, receivedAt); const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -104,67 +100,57 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => return canonicalCommand as OrchestrationCommand; } + // Attachments arrive as id references to bytes already uploaded via the + // signed upload URL flow. Each `pending-` file is renamed to its + // thread segment here; the uuid never changes, so signed asset URLs and + // send retries (which may reference an already-renamed file) keep working. const normalizedAttachments = yield* Effect.forEach( canonicalCommand.message.attachments, (attachment) => Effect.gen(function* () { - const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { - return yield* new OrchestrationDispatchCommandError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, - }); - } - - const bytes = Buffer.from(parsed.base64, "base64"); - if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - return yield* new OrchestrationDispatchCommandError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, - }); - } - - const attachmentId = createAttachmentId(canonicalCommand.threadId); - if (!attachmentId) { - return yield* new OrchestrationDispatchCommandError({ - message: "Failed to create a safe attachment id.", - }); - } - - const persistedAttachment = { - type: "image" as const, - id: attachmentId, - name: attachment.name, - mimeType: parsed.mimeType.toLowerCase(), - sizeBytes: bytes.byteLength, - }; - - const attachmentPath = resolveAttachmentPath({ + const claimPlan = planAttachmentClaim({ attachmentsDir: serverConfig.attachmentsDir, - attachment: persistedAttachment, + threadId: canonicalCommand.threadId, + attachmentId: attachment.id, }); - if (!attachmentPath) { + if (!claimPlan.ok) { return yield* new OrchestrationDispatchCommandError({ - message: `Failed to resolve persisted path for '${attachment.name}'.`, + message: `Attachment '${attachment.name}' cannot be sent: ${claimPlan.reason}.`, }); } - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( - Effect.mapError( - () => - new OrchestrationDispatchCommandError({ - message: `Failed to create attachment directory for '${attachment.name}'.`, - }), - ), - ); - yield* fileSystem.writeFile(attachmentPath, bytes).pipe( + const stats = yield* fileSystem.stat(claimPlan.currentPath).pipe( Effect.mapError( - () => + (cause) => new OrchestrationDispatchCommandError({ - message: `Failed to persist attachment '${attachment.name}'.`, + message: `Attachment '${attachment.name}' cannot be sent: attachment not found (removed or expired).`, + cause, }), ), ); + if (Number(stats.size) !== attachment.sizeBytes) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: stored size does not match the reference.`, + }); + } - return persistedAttachment; + if (!claimPlan.alreadyScoped) { + yield* fileSystem.rename(claimPlan.currentPath, claimPlan.finalPath).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: `Failed to claim attachment '${attachment.name}' for this thread.`, + cause, + }), + ), + ); + } + + return { + ...attachment, + id: claimPlan.finalId, + mimeType: attachment.mimeType.toLowerCase(), + }; }), { concurrency: 1 }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96b..28fa41c5c1a0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,6 +13,7 @@ import * as ServerConfig from "./config.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -451,6 +452,7 @@ export const makeRoutesLayer = Layer.mergeAll( ), otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 173c89ecabff..c48056ef01b5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -88,6 +88,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -1839,6 +1840,16 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.attachmentsCreateUploadUrl]: (input) => + observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.attachmentsDelete]: (input) => + observeRpcEffect( + WS_METHODS.attachmentsDelete, + deletePendingAttachment(input.attachmentId), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3e..ee9014f0e98f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -233,23 +233,6 @@ export interface PullRequestDialogState { key: number; } -export function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - reject(new Error("Could not read image data.")); - }); - reader.addEventListener("error", () => { - reject(reader.error ?? new Error("Failed to read image.")); - }); - reader.readAsDataURL(file); - }); -} - export function resolveSendEnvMode(input: { requestedEnvMode: DraftThreadEnvMode; isGitRepo: boolean; @@ -260,7 +243,7 @@ export function resolveSendEnvMode(input: { export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { - if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { + if (!image.file || typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } try { @@ -275,6 +258,7 @@ export function cloneComposerImageForRetry( export function deriveComposerSendState(options: { prompt: string; + /** Uploaded attachments only — an image still in flight is not sendable content. */ imageCount: number; terminalContexts: ReadonlyArray; /** @@ -283,11 +267,17 @@ export function deriveComposerSendState(options: { * contexts do: a prompt of just element chips is still a valid send. */ elementContextCount?: number; + /** + * Why attachments are blocking the send (uploading or failed), or null. + * Sending around an unsettled attachment would silently drop it. + */ + attachmentBlockReason?: string | null; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; expiredTerminalContextCount: number; hasSendableContent: boolean; + attachmentBlockReason: string | null; } { const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); @@ -303,6 +293,7 @@ export function deriveComposerSendState(options: { options.imageCount > 0 || sendableTerminalContexts.length > 0 || elementContextCount > 0, + attachmentBlockReason: options.attachmentBlockReason ?? null, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdc7e7dee382..ccd7fc91f650 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -205,6 +205,8 @@ import { useComposerDraftStore, type DraftId, } from "../composerDraftStore"; +import { awaitAttachmentUploads, releaseComposerAttachment } from "../lib/attachmentUploadQueue"; +import { readyAttachmentRefs, summarizeAttachmentUploads } from "../lib/attachmentUploadState"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, @@ -307,7 +309,6 @@ import { PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, - readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, @@ -4961,7 +4962,9 @@ function ChatViewContent(props: ChatViewProps) { hasSendableContent, } = deriveComposerSendState({ prompt: promptForSend, - imageCount: composerImages.length, + // Only uploaded attachments count: an image still in flight (or failed) + // is not sendable content, and the composer blocks the send anyway. + imageCount: summarizeAttachmentUploads(composerImages, environmentId).ready, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + @@ -4974,6 +4977,12 @@ function ChatViewContent(props: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; + // The follow-up sends text only; any attached images are being + // discarded with the rest of the composer, so their uploads and + // server-side bytes are released like a chip removal. + for (const image of composerImages) { + releaseComposerAttachment(image); + } clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); await onSubmitPlanFollowUp({ @@ -5086,15 +5095,6 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); - const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), - ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, @@ -5206,13 +5206,26 @@ function ChatViewContent(props: ChatViewProps) { } } - const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } + // Attachments ride the turn as id references to bytes that uploaded the + // moment they were attached. The composer blocks sending while an upload + // is in flight, but the preview "pick and send" gesture attaches and sends + // in one step, so wait for anything still running here. + const settledUploads = await awaitAttachmentUploads( + composerImagesSnapshot.map((image) => image.id), + ); + const sendableImages = composerImagesSnapshot.map((image) => { + const settledUpload = settledUploads.get(image.id); + return settledUpload ? { ...image, upload: settledUpload } : image; + }); + const turnAttachments = readyAttachmentRefs(sendableImages, environmentId); + const unsentImageNames = sendableImages + .filter( + (image) => image.upload.status !== "ready" || image.upload.environmentId !== environmentId, + ) + .map((image) => image.name); let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { + if (failure === null) { const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { @@ -5252,7 +5265,7 @@ function ChatViewContent(props: ChatViewProps) { messageId: messageIdForSend, role: "user", text: outgoingMessageText, - attachments: turnAttachmentsResult.value, + attachments: turnAttachments, }, modelSelection: ctxSelectedModelSelection, titleSeed: title, @@ -5267,6 +5280,15 @@ function ChatViewContent(props: ChatViewProps) { } else { turnStartSucceeded = true; acknowledgeActiveThreadWoke(); + if (unsentImageNames.length > 0) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Some images were not attached", + description: `${unsentImageNames.join(", ")} did not finish uploading, so the message was sent without them.`, + }), + ); + } } } @@ -5290,7 +5312,10 @@ function ChatViewContent(props: ChatViewProps) { return next.length === existing.length ? existing : next; }); promptRef.current = promptForSend; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); + // `sendableImages` carries the settled upload states; the pre-await + // snapshot can still say `uploading` for a job that finished during + // the await and would restore chips no live job will ever advance. + const retryComposerImages = sendableImages.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; composerElementContextsRef.current = composerElementContextsSnapshot; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6b44479b0cb5..2c171915d7ec 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -176,6 +176,7 @@ import { type ComposerThreadDraftState, type DraftSessionState, } from "../composerDraftStore"; +import { releaseComposerAttachment } from "../lib/attachmentUploadQueue"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. @@ -442,10 +443,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { }) { const { composer, draftId, onDiscard, onNavigate, session } = props; const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? ""; - // images mirrors persistedAttachments once rehydration finishes; before - // that only the persisted list is populated, hence max not sum. const attachmentCount = - Math.max(composer.images.length, composer.persistedAttachments.length) + + composer.images.length + composer.terminalContexts.length + composer.elementContexts.length + composer.previewAnnotations.length + @@ -611,6 +610,14 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { ]); const handleDiscard = useCallback( (draftId: DraftId) => { + // Discarding the draft abandons its attachments: stop in-flight + // uploads and free the server-side bytes before the store forgets + // them. (Server-side, only never-sent `pending-` files can be + // deleted, so this can never touch a sent thread's attachments.) + const draftImages = useComposerDraftStore.getState().getComposerDraft(draftId)?.images ?? []; + for (const image of draftImages) { + releaseComposerAttachment(image); + } // The /draft/$draftId route redirects home on its own when the draft // it renders disappears, so discarding the open draft needs no // special-casing here. diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e918e7758688..7d34177e1de2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -41,7 +41,7 @@ import { replaceTextRange, shouldSubmitComposerOnEnter, } from "../../composer-logic"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { deriveComposerSendState } from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, @@ -57,13 +57,27 @@ import { } from "../../composerDraftStore"; import { MAX_STASH_ENTRIES, - partitionStashAttachments, usePromptStashStore, type PromptStashEntry, } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; -import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; +import { compressImageToByteLimit } from "../../lib/imageCompression"; +import { + cancelAttachmentUpload, + releaseComposerAttachment, + retryAttachmentUpload, + startAttachmentUpload, +} from "../../lib/attachmentUploadQueue"; +import { + ATTACHMENT_WRONG_ENVIRONMENT_REASON, + attachmentUploadBlockReason, + formatAttachmentUploadProgress, + isAttachmentInWrongEnvironment, + resolveAttachmentEnvironmentAction, + summarizeAttachmentUploads, +} from "../../lib/attachmentUploadState"; +import { useAssetUrls } from "~/assets/assetUrls"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import { resolveShortcutCommand } from "../../keybindings"; @@ -666,7 +680,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, } = props; - const isSendDisabled = sendDisabledReason !== null; // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) @@ -678,12 +691,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerElementContexts = composerDraft.elementContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; - const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const setComposerDraftImageUpload = useComposerDraftStore((store) => store.setImageUpload); const insertComposerDraftTerminalContext = useComposerDraftStore( (store) => store.insertTerminalContext, ); @@ -702,16 +715,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const removeComposerDraftReviewComment = useComposerDraftStore( (store) => store.removeReviewComment, ); - const clearComposerDraftPersistedAttachments = useComposerDraftStore( - (store) => store.clearPersistedAttachments, - ); const clearComposerDraftPromptAndImages = useComposerDraftStore( (store) => store.clearComposerPromptAndImages, ); - const syncComposerDraftPersistedAttachments = useComposerDraftStore( - (store) => store.syncPersistedAttachments, - ); - const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); // ------------------------------------------------------------------ // Model state @@ -974,12 +980,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const dragDepthRef = useRef(0); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); - /** - * Snapshots currently being encoded, keyed by target+prompt+image ids. - * Keyed rather than boolean so a genuinely different prompt (or a different - * thread) can still be stashed while an earlier encode is running. - */ - const stashInFlightRef = useRef>(new Set()); /** * Count of pasted images still being compressed, per thread. Reserved * against the attachment limit so concurrent pastes can't overshoot it, @@ -991,26 +991,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ + const attachmentUploadSummary = useMemo( + () => summarizeAttachmentUploads(composerImages, environmentId), + [composerImages, environmentId], + ); const composerSendState = useMemo( () => deriveComposerSendState({ prompt, - imageCount: composerImages.length, + imageCount: attachmentUploadSummary.ready, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length + composerReviewComments.length, + attachmentBlockReason: attachmentUploadBlockReason(attachmentUploadSummary), }), [ + attachmentUploadSummary, composerElementContexts.length, - composerImages.length, composerPreviewAnnotations.length, composerReviewComments.length, composerTerminalContexts, prompt, ], ); + // An unsettled attachment blocks the send just like a loading thread does: + // sending around it would silently drop an image the user can see attached. + const effectiveSendDisabledReason = sendDisabledReason ?? composerSendState.attachmentBlockReason; + const isSendDisabled = effectiveSendDisabledReason !== null; // ------------------------------------------------------------------ // Derived: composer trigger / menu @@ -1128,9 +1137,48 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerMenuItemsRef.current = composerMenuItems; activeComposerMenuItemRef.current = activeComposerMenuItem; - const nonPersistedComposerImageIdSet = useMemo( - () => new Set(nonPersistedComposerImageIds), - [nonPersistedComposerImageIds], + // Attachments restored from a persisted draft have no blob preview: their + // bytes only exist on the server, so the thumbnail comes from a signed + // asset URL — the same flow sent-message attachments already use. + const restoredAttachmentIds = useMemo( + () => + composerImages.flatMap((image) => + image.previewUrl.length === 0 && image.upload.status === "ready" + ? [image.upload.attachmentId] + : [], + ), + [composerImages], + ); + const restoredAttachmentResources = useMemo( + () => + restoredAttachmentIds.map((attachmentId) => ({ + _tag: "attachment" as const, + attachmentId, + })), + [restoredAttachmentIds], + ); + const restoredAttachmentUrls = useAssetUrls(environmentId, restoredAttachmentResources); + const restoredAttachmentUrlById = useMemo( + () => + new Map( + restoredAttachmentIds.flatMap((attachmentId, index) => { + const url = restoredAttachmentUrls[index]; + return url ? [[attachmentId, url] as const] : []; + }), + ), + [restoredAttachmentIds, restoredAttachmentUrls], + ); + /** Chips as rendered: local blob preview when we have one, signed URL otherwise. */ + const displayComposerImages = useMemo( + () => + composerImages.map((image) => { + if (image.previewUrl.length > 0 || image.upload.status !== "ready") { + return image; + } + const previewUrl = restoredAttachmentUrlById.get(image.upload.attachmentId); + return previewUrl ? { ...image, previewUrl } : image; + }), + [composerImages, restoredAttachmentUrlById], ); const isComposerApprovalState = activePendingApproval !== null; @@ -1450,72 +1498,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]); // ------------------------------------------------------------------ - // Image persist effect + // Attachment environment retargeting // ------------------------------------------------------------------ + // The bytes live in exactly one environment. When a draft is pointed at a + // different one, an attachment we still hold the File for is silently + // re-uploaded; the old environment's copy is released only after the new + // upload lands (`supersedes`), so a failed re-upload never destroys the + // only server copy. One restored after a reload has no File to re-send; + // its ready state is left intact and the mismatch is rendered and + // send-gated as a derived condition, so switching back recovers it. useEffect(() => { - let cancelled = false; - void (async () => { - if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(composerDraftTarget); - return; - } - const getPersistedAttachmentsForThread = () => - getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; - try { - const currentPersistedAttachments = getPersistedAttachmentsForThread(); - const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), - ); - const stagedAttachmentById = new Map(); - await Promise.all( - composerImages.map(async (image) => { - try { - const dataUrl = await readFileAsDataUrl(image.file); - stagedAttachmentById.set(image.id, { - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl, - }); - } catch { - const existingPersisted = existingPersistedById.get(image.id); - if (existingPersisted) { - stagedAttachmentById.set(image.id, existingPersisted); + for (const image of composerImages) { + if (resolveAttachmentEnvironmentAction(image, environmentId) === "reupload") { + const previousUpload = image.upload; + cancelAttachmentUpload(image.id); + startAttachmentUpload({ + target: composerDraftTarget, + environmentId, + image, + ...(previousUpload.status === "ready" + ? { + supersedes: { + environmentId: previousUpload.environmentId, + attachmentId: previousUpload.attachmentId, + }, } - } - }), - ); - const serialized = Array.from(stagedAttachmentById.values()); - if (cancelled) return; - syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); - } catch { - const currentImageIds = new Set(composerImages.map((image) => image.id)); - const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); - const fallbackPersistedIds: Array = []; - for (const attachment of fallbackPersistedAttachments) { - if (currentImageIds.has(attachment.id)) { - fallbackPersistedIds.push(attachment.id); - } - } - const fallbackPersistedIdSet = new Set(fallbackPersistedIds); - const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => - fallbackPersistedIdSet.has(attachment.id), - ); - if (cancelled) return; - syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); + : {}), + }); } - })(); - return () => { - cancelled = true; - }; - }, [ - composerDraftTarget, - clearComposerDraftPersistedAttachments, - composerImages, - getComposerDraft, - syncComposerDraftPersistedAttachments, - ]); + } + }, [composerDraftTarget, composerImages, environmentId]); // ------------------------------------------------------------------ // Callbacks: prompt change @@ -1910,7 +1922,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashQueue = usePromptStashStore((state) => state.entries); const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); - const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); useEffect(() => { return () => { @@ -1967,7 +1978,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } let unrestoredImageNames: string[] = []; - if (entry.attachments.length > 0) { + // Attachment bytes live in one environment. Restoring into a different + // one cannot reach them, so those come back as named drops. + const wrongEnvironmentImageNames = entry.attachments + .filter((attachment) => attachment.environmentId !== environmentId) + .map((attachment) => attachment.name); + const restorableAttachments = entry.attachments.filter( + (attachment) => attachment.environmentId === environmentId, + ); + if (restorableAttachments.length > 0) { const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); // The draft store also dedupes by mimeType+sizeBytes+name, so filter // on the same key here. Counting a duplicate against capacity would @@ -1982,7 +2001,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) 0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, ); - const pending = entry.attachments.filter( + const pending = restorableAttachments.filter( (attachment) => !existingIds.has(attachment.id) && !existingDedupKeys.has( @@ -2003,18 +2022,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // prompt across threads and providers, so whatever the composer has // selected right now stays selected. - // Each cause gets its own sentence so "too large" is never blamed for a - // file that actually failed to decode, or for one the composer simply - // had no room to take back. + // Each cause gets its own sentence so "still uploading" is never blamed + // for a file that actually failed, or for one the composer simply had no + // room to take back. const missingImageReasons: string[] = []; if (entry.droppedImageNames.length > 0) { missingImageReasons.push( - `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + `${entry.droppedImageNames.join(", ")} had not finished uploading when this prompt was saved.`, ); } if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { missingImageReasons.push( - `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, + `${entry.unreadableImageNames.join(", ")} failed to upload when this prompt was saved.`, + ); + } + if (wrongEnvironmentImageNames.length > 0) { + missingImageReasons.push( + `${wrongEnvironmentImageNames.join(", ")} is not available in this environment.`, ); } if (unrestoredImageNames.length > 0) { @@ -2042,6 +2066,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftImages, composerDraftTarget, composerImagesRef, + environmentId, promptRef, setComposerDraftPrompt, takeStashEntry, @@ -2064,7 +2089,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [takeStashEntry], ); - const stashCurrentPrompt = useCallback(async () => { + const stashCurrentPrompt = useCallback(() => { // Terminal-context placeholders reference live sessions the stash can't // round-trip, so they are stripped from the stashed prompt. const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); @@ -2073,149 +2098,111 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen((open) => !open); return; } - // A repeat ⌘S on the *same* still-unencoded snapshot would stash it - // twice. Guard on the snapshot itself rather than a bare boolean: once - // the composer has been cleared the user can type something genuinely - // new (or switch threads) while encoding continues, and that deserves its - // own entry. - const snapshotKey = `${String(composerDraftTarget)}${prompt}${images - .map((image) => image.id) - .join(",")}`; - if (stashInFlightRef.current.has(snapshotKey)) return; - stashInFlightRef.current.add(snapshotKey); + // Attachments are already on the server, so the entry is a handful of id + // references and the whole stash is one synchronous write. Anything that + // has not landed yet cannot be referenced and comes back as a named drop. + // Nothing is cancelled yet: if the write fails below, the composer (and + // its in-flight uploads) must be left exactly as they were. const stashTarget = composerDraftTarget; - const entryId = randomUUID(); - try { - // Persist the text-only entry *first*, then clear. Ordering matters in - // both directions: writing before clearing means a crash or closed tab - // mid-encode still leaves the prompt recoverable, while clearing before - // the async image work means edits typed during encoding are not wiped. - // Images are appended to the stored entry as they finish encoding. - const { evicted, written, durable } = stashEntryToQueue({ - id: entryId, - createdAt: new Date().toISOString(), - prompt, - attachments: [], - droppedImageNames: [], - unreadableImageNames: [], - pendingImageCount: images.length, - }); - - // Clearing the composer is only safe once the write actually landed. - // If it was rejected (quota) the store has already rolled itself back, - // so leave the composer untouched rather than making it the second - // casualty of a reload. - if (!written) { - toastManager.add({ - type: "error", - title: "Could not stash this prompt", - description: - "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.", - data: { hideCopyButton: true }, + const attachments: PersistedComposerImageAttachment[] = []; + const stillUploadingImages: ComposerImageAttachment[] = []; + const failedImageNames: string[] = []; + for (const image of images) { + if (image.upload.status === "ready") { + attachments.push({ + id: image.id, + attachmentId: image.upload.attachmentId, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + environmentId: image.upload.environmentId, }); - return; + continue; } - // Written but only into the in-memory fallback (localStorage blocked): - // the entry is visible and restorable this session, so proceed with the - // clear, but say it won't survive a reload. - if (!durable) { - toastManager.add({ - type: "warning", - title: "Stashed prompt will not survive a reload", - description: - "Browser storage is unavailable, so this stash is kept in memory only for this session.", - data: { hideCopyButton: true }, - }); + if (image.upload.status === "failed") { + failedImageNames.push(image.name); + } else { + stillUploadingImages.push(image); } + } + const stillUploadingImageNames = stillUploadingImages.map((image) => image.name); - // Only the prompt and images are cleared — terminal/element contexts, - // preview annotations, and review comments are not stashable, so - // destroying them here would be unrecoverable. - promptRef.current = ""; - clearComposerDraftPromptAndImages(stashTarget); - setComposerCursor(0); - setComposerTrigger(null); - pulseStashBadge(); + const { evicted, written, durable } = stashEntryToQueue({ + id: randomUUID(), + createdAt: new Date().toISOString(), + prompt, + attachments, + droppedImageNames: stillUploadingImageNames, + unreadableImageNames: failedImageNames, + }); - if (evicted) { - toastManager.add({ - type: "warning", - title: "Oldest stashed prompt discarded", - description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`, - data: { hideCopyButton: true }, - }); - } + // Clearing the composer is only safe once the write actually landed. + // If it was rejected (quota) the store has already rolled itself back, + // so leave the composer untouched rather than making it the second + // casualty of a reload. + if (!written) { + toastManager.add({ + type: "error", + title: "Could not stash this prompt", + description: + "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.", + data: { hideCopyButton: true }, + }); + return; + } + // Written but only into the in-memory fallback (localStorage blocked): + // the entry is visible and restorable this session, so proceed with the + // clear, but say it won't survive a reload. + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stashed prompt will not survive a reload", + description: + "Browser storage is unavailable, so this stash is kept in memory only for this session.", + data: { hideCopyButton: true }, + }); + } - // Images are re-encoded for the stash rather than stored verbatim: the - // composer allows up to 10MB per image, but localStorage gives the whole - // origin ~5MB. Only the stashed copy shrinks; the live attachment (and - // anything sent without stashing) keeps the original file. - const candidateAttachments: PersistedComposerImageAttachment[] = []; - const oversizedImageNames: string[] = []; - const unreadableImageNames: string[] = []; - for (const image of images) { - const result = await compressImageForStash(image.file); - if (!result.ok) { - // "too large" and "could not be read" are distinct outcomes; the - // menu and restore toast report them separately. - (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push( - image.name, - ); - continue; - } - candidateAttachments.push({ - id: image.id, - name: image.name, - mimeType: result.image.mimeType, - sizeBytes: result.image.sizeBytes, - dataUrl: result.image.dataUrl, - }); - } - const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); + // The write landed, so the composer is being cleared: uploads that could + // not be stashed (still in flight) are cancelled now, and failed ones lose + // their settled record. Doing this before the write would strand the + // chips in `uploading` forever on a rejected write. + for (const image of stillUploadingImages) { + cancelAttachmentUpload(image.id); + } - const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, { - attachments: kept, - droppedImageNames: [...oversizedImageNames, ...droppedNames], - unreadableImageNames, + // Only the prompt and images are cleared — terminal/element contexts, + // preview annotations, and review comments are not stashable, so + // destroying them here would be unrecoverable. + promptRef.current = ""; + clearComposerDraftPromptAndImages(stashTarget); + setComposerCursor(0); + setComposerTrigger(null); + pulseStashBadge(); + + if (evicted) { + toastManager.add({ + type: "warning", + title: "Oldest stashed prompt discarded", + description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`, + data: { hideCopyButton: true }, + }); + } + + const unstashedImageNames = [...stillUploadingImageNames, ...failedImageNames]; + if (unstashedImageNames.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images were not stashed", + description: `${unstashedImageNames.join(", ")} had not finished uploading, so ${unstashedImageNames.length === 1 ? "it was" : "they were"} not saved with the prompt.`, + data: { hideCopyButton: true }, }); - if (attached) { - // The second phase can be rejected on its own: the text-only entry - // fit, but adding image payloads pushed past the quota. Disk would - // then still hold the phase-one entry with pendingImageCount set, - // which reads as an orphan after reload — so say so now. Gated on the - // entry write having been durable: on the in-memory fallback nothing - // is ever durable, and the session-only warning already covered it. - if (!imagesDurable && durable && images.length > 0) { - toastManager.add({ - type: "warning", - title: "Stashed images were not saved", - description: - "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.", - data: { hideCopyButton: true }, - }); - } - } else if (kept.length > 0) { - // The entry was restored or deleted before its images finished - // encoding, so they have nowhere to land. Say so rather than letting - // them evaporate. - toastManager.add({ - type: "warning", - title: "Stashed images did not attach", - description: `That prompt was restored or deleted before ${kept.length} image${kept.length === 1 ? "" : "s"} finished saving. Re-attach ${kept.length === 1 ? "it" : "them"} if you still need ${kept.length === 1 ? "it" : "them"}.`, - data: { hideCopyButton: true }, - }); - } - } finally { - // Must clear on every path: a throw that left this set would wedge this - // snapshot's ⌘S until the composer remounts. - stashInFlightRef.current.delete(snapshotKey); } }, [ clearComposerDraftPromptAndImages, composerDraftTarget, composerImagesRef, - finalizeStashEntryImages, promptRef, pulseStashBadge, stashEntryToQueue, @@ -2339,6 +2326,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sizeBytes: attachmentFile.size, previewUrl, file: attachmentFile, + upload: { status: "uploading", progress: 0 }, }); } if (nextImages.length === 1 && nextImages[0]) { @@ -2346,6 +2334,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } else if (nextImages.length > 1) { addComposerImagesToDraft(nextImages); } + // Upload starts on attach, not on send: the URL is minted with the exact + // post-compression byte count, so this has to run after the ladder. Only + // images the store actually accepted are uploaded — a duplicate paste is + // deduped away and must not leave orphaned bytes on the server. + const acceptedImageIds = new Set( + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.images ?? []).map( + (image) => image.id, + ), + ); + for (const image of nextImages) { + if (!acceptedImageIds.has(image.id)) continue; + startAttachmentUpload({ target: composerDraftTarget, environmentId, image }); + } // Only failures are reported here. Success must not pass `null`: by // now other work (a failed send, an overlapping paste) may have set a // thread error this call knows nothing about, and clearing it would @@ -2364,8 +2365,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }; - const removeComposerImage = (imageId: string) => { - removeComposerImageFromDraft(imageId); + const removeComposerImage = (image: ComposerImageAttachment) => { + // Abort an in-flight upload, and hand back an already-uploaded one so the + // server does not keep bytes nothing will ever reference. + releaseComposerAttachment(image); + removeComposerImageFromDraft(image.id); + }; + + /** Re-runs a failed upload. The File never left memory, so nothing is re-picked. */ + const retryComposerImageUpload = (image: ComposerImageAttachment) => { + if (!image.file) { + return; + } + retryAttachmentUpload({ target: composerDraftTarget, environmentId, image }); }; // ------------------------------------------------------------------ @@ -2784,7 +2796,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || @@ -2900,12 +2912,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations.length > 0 && ( - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) - } + images={displayComposerImages} + onRemove={(annotationId) => { + // The annotation's screenshot is a composer image with a + // server-side upload behind it; removing the card must + // stop that upload / free those bytes like removing a + // chip does. + const annotationImage = displayComposerImages.find( + (image) => image.id === annotationId, + ); + if (annotationImage) { + releaseComposerAttachment(annotationImage); + } + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); + }} onExpandImage={(imageId) => { - const preview = buildExpandedImagePreview(composerImages, imageId); + const preview = buildExpandedImagePreview(displayComposerImages, imageId); if (preview) onExpandImage(preview); }} className="mb-3" @@ -2941,12 +2963,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && - composerImages.some( + displayComposerImages.some( (image) => !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), ) && (
- {composerImages + {displayComposerImages .filter( (image) => !composerPreviewAnnotations.some( @@ -2956,7 +2978,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) .map((image) => (
{image.previewUrl ? ( ) : ( @@ -2980,16 +3017,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {image.name}
)} - {nonPersistedComposerImageIdSet.has(image.id) && ( + {/* Progress is text, not a spinner: a chip that repaints + every frame is a real cost on a 120Hz display. */} + {image.upload.status === "uploading" && ( + + {formatAttachmentUploadProgress(image.upload.progress)} + + )} + {isAttachmentInWrongEnvironment(image, environmentId) && ( - + + {ATTACHMENT_WRONG_ENVIRONMENT_REASON} } /> @@ -2997,8 +3040,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) side="top" className="max-w-64 whitespace-normal leading-tight" > - Draft attachment could not be saved locally and may be lost on - navigation. + This image was uploaded to a different environment. Switch back to + send it from there, or remove it. + + + )} + {image.upload.status === "failed" && ( + + retryComposerImageUpload(image)} + aria-label={`Retry upload for ${image.name}`} + > + {image.upload.reason} + + } + /> + + {image.file + ? "Upload failed. Click to try again, or remove the image." + : "This image is not available here. Remove it to send."} )} @@ -3006,7 +3073,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) variant="ghost" size="icon-xs" className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" - onClick={() => removeComposerImage(image.id)} + onClick={() => removeComposerImage(image)} aria-label={`Remove ${image.name}`} > @@ -3067,7 +3134,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || @@ -3191,7 +3258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} promptHasText={prompt.trim().length > 0} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 9e9238515332..5ef5e108e27e 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -1,4 +1,4 @@ -import { BookmarkIcon, XIcon } from "lucide-react"; +import { BookmarkIcon, ImageIcon, XIcon } from "lucide-react"; import { memo, useEffect, useState } from "react"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -121,30 +121,18 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { onRestore(entry); }} > + {/* Stashed attachments are server-side ids now, so a + thumbnail would need a signed URL per entry per row. + The count carries the same information. */} {entry.attachments.length > 0 ? ( - - {entry.attachments.slice(0, 3).map((attachment) => ( - - ))} - + ) : ( )} {stashEntrySnippet(entry)} - {entry.pendingImageCount ? ( - - saving {entry.pendingImageCount} image - {entry.pendingImageCount === 1 ? "" : "s"}… - - ) : missingImageCount(entry) > 0 ? ( + {missingImageCount(entry) > 0 ? ( {missingImageCount(entry)} image {missingImageCount(entry) === 1 ? "" : "s"} dropped diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 6979a1a4006d..209befa2dd97 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -19,6 +19,7 @@ import { useThreadRecentHistory, } from "~/browserHistoryStore"; import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; +import { startAttachmentUpload } from "~/lib/attachmentUploadQueue"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { @@ -576,10 +577,18 @@ export function PreviewView({ sizeBytes: screenshotFile.size, previewUrl: annotation.screenshot.dataUrl, file: screenshotFile, + upload: { status: "uploading", progress: 0 }, } satisfies ComposerImageAttachment) : null; if (image) { addImage(threadRef, image); + // Element-pick screenshots upload exactly like a pasted image: the + // chip is live immediately, the bytes follow. + startAttachmentUpload({ + target: threadRef, + environmentId: threadRef.environmentId, + image, + }); } if (submission === "send") { onSendAnnotation?.(annotation, image); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 3e4106c583f1..9c65d2d6aa2f 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -4,7 +4,6 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import * as Schema from "effect/Schema"; import { defaultInstanceIdForDriver, EnvironmentId, @@ -66,10 +65,11 @@ import { markPromotedDraftThreads, markPromotedDraftThreadsByRef, type ComposerImageAttachment, + hydrateImagesFromPersisted, useComposerDraftStore, DraftId, } from "./composerDraftStore"; -import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; +import { removeLocalStorageItem } from "./hooks/useLocalStorage"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, @@ -84,6 +84,7 @@ function makeImage(input: { mimeType?: string; sizeBytes?: number; lastModified?: number; + upload?: ComposerImageAttachment["upload"]; }): ComposerImageAttachment { const name = input.name ?? "image.png"; const mimeType = input.mimeType ?? "image/png"; @@ -101,6 +102,7 @@ function makeImage(input: { sizeBytes: file.size, previewUrl: input.previewUrl, file, + upload: input.upload ?? { status: "uploading", progress: 0 }, }; } @@ -345,9 +347,10 @@ describe("composerDraftStore moveComposerPromptAndImages", () => { }); }); -describe("composerDraftStore syncPersistedAttachments", () => { - const threadId = ThreadId.make("thread-sync-persisted"); +describe("composerDraftStore attachment persistence", () => { + const threadId = ThreadId.make("thread-attachment-persistence"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const threadKey = scopedThreadKey(threadRef); beforeEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); @@ -364,40 +367,110 @@ describe("composerDraftStore syncPersistedAttachments", () => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); }); - it("treats malformed persisted draft storage as empty", async () => { - const image = makeImage({ - id: "img-persisted", - previewUrl: "blob:persisted", - }); - useComposerDraftStore.getState().addImage(threadRef, image); - setLocalStorageItem( - COMPOSER_DRAFT_STORAGE_KEY, - { - version: 2, - state: { - draftsByThreadId: { - [threadId]: { - attachments: "not-an-array", - }, - }, + it("persists only uploaded attachments, as environment-scoped id references", () => { + useComposerDraftStore.getState().addImages(threadRef, [ + makeImage({ + id: "img-ready", + previewUrl: "blob:ready", + name: "ready.png", + upload: { + status: "ready", + attachmentId: "pending-ready", + environmentId: TEST_ENVIRONMENT_ID, }, + }), + makeImage({ id: "img-uploading", previewUrl: "blob:uploading", name: "uploading.png" }), + makeImage({ + id: "img-failed", + previewUrl: "blob:failed", + name: "failed.png", + upload: { status: "failed", reason: "Upload failed" }, + }), + ]); + + const partialize = useComposerDraftStore.persist.getOptions().partialize; + const persisted = partialize?.(useComposerDraftStore.getState()) as + | { draftsByThreadKey: Record }> } + | undefined; + + expect(persisted?.draftsByThreadKey[threadKey]?.attachments).toEqual([ + { + id: "img-ready", + attachmentId: "pending-ready", + name: "ready.png", + mimeType: "image/png", + sizeBytes: 4, + environmentId: TEST_ENVIRONMENT_ID, }, - Schema.Unknown, - ); + ]); + }); + + it("rehydrates persisted attachments as ready chips with no local file", () => { + const images = hydrateImagesFromPersisted([ + { + id: "img-restored", + attachmentId: "pending-restored", + name: "restored.png", + mimeType: "image/png", + sizeBytes: 12, + environmentId: TEST_ENVIRONMENT_ID, + }, + ]); - useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ + expect(images).toEqual([ { - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: image.previewUrl, + type: "image", + id: "img-restored", + name: "restored.png", + mimeType: "image/png", + sizeBytes: 12, + // The bytes only exist on the server: no blob preview, no File. + previewUrl: "", + file: null, + upload: { + status: "ready", + attachmentId: "pending-restored", + environmentId: TEST_ENVIRONMENT_ID, + }, }, ]); - await Promise.resolve(); + }); + + it("drops persisted attachments left over from the inline dataUrl shape", async () => { + const storage = useComposerDraftStore.persist.getOptions().storage; + // Composer writes are debounced, so the seeded payload only reaches the + // storage the rehydrate reads from once the timer fires. + vi.useFakeTimers(); + await storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + // Any pre-v9 payload: its attachments carried bytes inline and were + // never uploaded anywhere, so there is nothing to point at. + version: 8, + state: { + draftsByThreadKey: { + [threadKey]: { + prompt: "with a stale attachment", + attachments: [ + { + id: "img-legacy", + name: "legacy.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + }, + }, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }, + }); + await vi.advanceTimersByTimeAsync(1_000); + vi.useRealTimers(); + await useComposerDraftStore.persist.rehydrate(); - expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]); - expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]); + const draft = draftByKey(threadKey); + expect(draft?.prompt).toBe("with a stale attachment"); + expect(draft?.images).toEqual([]); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09ed..2a0320122a14 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -31,9 +31,9 @@ import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; -import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import type { ComposerAttachmentUpload } from "./lib/attachmentUploadState"; import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, @@ -58,7 +58,11 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 8; +// v9 dropped inline `dataUrl` attachments: bytes now upload the moment an +// image is attached, so a persisted attachment is just a server-side id. +// Old-shape entries are dropped rather than migrated — there is nothing to +// migrate them to. +const COMPOSER_DRAFT_STORAGE_VERSION = 9; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -79,18 +83,36 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio }); } +/** + * A composer attachment that already lives on the server. + * + * `id` is the composer's own chip identity, kept so a restored chip is still + * addressable by whatever created it (a preview annotation links its card to + * an image by this id). `attachmentId` is the server-side id the turn-start + * command references, and `environmentId` records where the bytes are: an + * attachment restored into a different environment cannot be sent from there. + * + * Only `ready` attachments are persisted. In-flight uploads die with the page. + */ export const PersistedComposerImageAttachment = Schema.Struct({ id: Schema.String, + attachmentId: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, - dataUrl: Schema.String, + environmentId: Schema.String, }); export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; export interface ComposerImageAttachment extends Omit { + /** + * Local blob URL while the picked `File` is in memory. Empty for attachments + * rehydrated after a reload — those resolve a signed asset URL instead. + */ previewUrl: string; - file: File; + /** Null once the page reloaded: the bytes only exist on the server. */ + file: File | null; + upload: ComposerAttachmentUpload; } const PersistedTerminalContextDraft = Schema.Struct({ @@ -239,11 +261,6 @@ const PersistedComposerDraftStoreState = Schema.Struct({ }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; -const PersistedComposerDraftStoreStorage = Schema.Struct({ - version: Schema.Number, - state: PersistedComposerDraftStoreState, -}); - /** * Composer content keyed by either a draft session (`DraftId`) or a real server * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. @@ -251,8 +268,6 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; - nonPersistedImageIds: string[]; - persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; /** * Element-pick attachments captured from the in-app preview browser. The @@ -295,7 +310,6 @@ export function composerDraftHasUserContent( return ( draft.prompt.trim().length > 0 || draft.images.length > 0 || - draft.persistedAttachments.length > 0 || draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || draft.previewAnnotations.length > 0 || @@ -341,7 +355,7 @@ interface ProjectDraftSession extends DraftSessionState { * Raw `ThreadId` is intentionally excluded so callers cannot drop environment * identity for real threads. */ -type ComposerThreadTarget = ScopedThreadRef | DraftId; +export type ComposerThreadTarget = ScopedThreadRef | DraftId; /** * Persisted store for composer content plus draft-session metadata. @@ -472,6 +486,12 @@ interface ComposerDraftStoreState { addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; + /** Advances one attachment's upload state (progress, ready, or failure). */ + setImageUpload: ( + threadRef: ComposerThreadTarget, + imageId: string, + upload: ComposerAttachmentUpload, + ) => void; insertTerminalContext: ( threadRef: ComposerThreadTarget, prompt: string, @@ -515,11 +535,6 @@ interface ComposerDraftStoreState { comments: ReadonlyArray, ) => void; removeReviewComment: (threadRef: ComposerThreadTarget, commentId: string) => void; - clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; - syncPersistedAttachments: ( - threadRef: ComposerThreadTarget, - attachments: PersistedComposerImageAttachment[], - ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; /** * Clears only the prompt text and image attachments, preserving terminal / @@ -603,15 +618,11 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze( const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, - nonPersistedImageIds: EMPTY_IDS, - persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, elementContexts: EMPTY_ELEMENT_CONTEXTS, previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS, @@ -647,8 +656,6 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], - nonPersistedImageIds: [], - persistedAttachments: [], terminalContexts: [], elementContexts: [], previewAnnotations: [], @@ -721,7 +728,6 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && - draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && draft.previewAnnotations.length === 0 && @@ -1088,34 +1094,43 @@ function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefine } } +/** + * Decodes one persisted attachment. Entries missing `environmentId` are the + * pre-upload `dataUrl` shape: their bytes were never uploaded anywhere, so + * there is nothing to migrate and they are dropped. + */ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { if (!value || typeof value !== "object") { return null; } const candidate = value as Record; const id = candidate.id; + const attachmentId = candidate.attachmentId; const name = candidate.name; const mimeType = candidate.mimeType; const sizeBytes = candidate.sizeBytes; - const dataUrl = candidate.dataUrl; + const environmentId = candidate.environmentId; if ( typeof id !== "string" || + typeof attachmentId !== "string" || typeof name !== "string" || typeof mimeType !== "string" || typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || - typeof dataUrl !== "string" || + typeof environmentId !== "string" || id.length === 0 || - dataUrl.length === 0 + attachmentId.length === 0 || + environmentId.length === 0 ) { return null; } return { id, + attachmentId, name, mimeType, sizeBytes, - dataUrl, + environmentId, }; } @@ -1898,9 +1913,13 @@ function partializeComposerDraftStoreState( } const hasModelData = Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null; + // Only uploaded attachments are persistable. An in-flight upload has no + // server id yet, and its job does not survive a reload, so it is simply + // absent when the draft comes back. + const attachments = persistableAttachmentsFromImages(draft.images); if ( draft.prompt.length === 0 && - draft.persistedAttachments.length === 0 && + attachments.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && draft.previewAnnotations.length === 0 && @@ -1913,7 +1932,7 @@ function partializeComposerDraftStoreState( } const persistedDraft: DeepMutable = { prompt: draft.prompt, - attachments: draft.persistedAttachments, + attachments, ...(draft.terminalContexts.length > 0 ? { terminalContexts: draft.terminalContexts.map((context) => ({ @@ -2060,127 +2079,51 @@ function normalizeCurrentPersistedComposerDraftStoreState( }; } -function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] { - if (threadKey.length === 0) { - return []; - } - try { - const persisted = getLocalStorageItem( - COMPOSER_DRAFT_STORAGE_KEY, - PersistedComposerDraftStoreStorage, - ); - if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) { - return []; - } - return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map( - (attachment) => attachment.id, - ); - } catch { - return []; - } -} - -function verifyPersistedAttachments( - threadKey: string, - attachments: PersistedComposerImageAttachment[], - set: ( - partial: - | ComposerDraftStoreState - | Partial - | (( - state: ComposerDraftStoreState, - ) => ComposerDraftStoreState | Partial), - replace?: false, - ) => void, -): void { - let persistedIdSet = new Set(); - try { - composerDebouncedStorage.flush(); - persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey)); - } catch { - persistedIdSet = new Set(); - } - set((state) => { - const current = state.draftsByThreadKey[threadKey]; - if (!current) { - return state; - } - const imageIdSet = new Set(current.images.map((image) => image.id)); - const persistedAttachments = attachments.filter( - (attachment) => imageIdSet.has(attachment.id) && persistedIdSet.has(attachment.id), - ); - const nonPersistedImageIds: string[] = []; - for (const image of current.images) { - if (!persistedIdSet.has(image.id)) { - nonPersistedImageIds.push(image.id); - } - } - const nextDraft: ComposerThreadDraftState = { - ...current, - persistedAttachments, - nonPersistedImageIds, - }; - const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadKey[threadKey]; - } else { - nextDraftsByThreadKey[threadKey] = nextDraft; - } - return { draftsByThreadKey: nextDraftsByThreadKey }; - }); -} - -function hydratePersistedComposerImageAttachment( - attachment: PersistedComposerImageAttachment, -): File | null { - const commaIndex = attachment.dataUrl.indexOf(","); - const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex); - const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1); - if (payload.length === 0) { - return null; - } - try { - const isBase64 = header.includes(";base64"); - if (!isBase64) { - const decodedText = decodeURIComponent(payload); - const inferredMimeType = - header.startsWith("data:") && header.includes(";") - ? header.slice("data:".length, header.indexOf(";")) - : attachment.mimeType; - return new File([decodedText], attachment.name, { - type: inferredMimeType || attachment.mimeType, - }); - } - const binary = atob(payload); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return new File([bytes], attachment.name, { type: attachment.mimeType }); - } catch { - return null; - } +/** The persistable projection of a draft's images: everything already uploaded. */ +function persistableAttachmentsFromImages( + images: ReadonlyArray, +): DeepMutable[] { + return images.flatMap((image) => + image.upload.status === "ready" + ? [ + { + id: image.id, + attachmentId: image.upload.attachmentId, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + environmentId: image.upload.environmentId, + }, + ] + : [], + ); } +/** + * Rebuilds composer chips from persisted attachments. The bytes are on the + * server, not in the page, so there is no `File` and no blob preview: the chip + * resolves a signed asset URL for its thumbnail instead. + */ export function hydrateImagesFromPersisted( attachments: ReadonlyArray, ): ComposerImageAttachment[] { - return attachments.flatMap((attachment) => { - const file = hydratePersistedComposerImageAttachment(attachment); - if (!file) return []; - - return [ - { + return attachments.map( + (attachment) => + ({ type: "image" as const, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: attachment.dataUrl, - file, - } satisfies ComposerImageAttachment, - ]; - }); + previewUrl: "", + file: null, + upload: { + status: "ready", + attachmentId: attachment.attachmentId, + environmentId: attachment.environmentId as EnvironmentId, + }, + }) satisfies ComposerImageAttachment, + ); } function toHydratedThreadDraft( @@ -2194,8 +2137,6 @@ function toHydratedThreadDraft( return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), - nonPersistedImageIds: [], - persistedAttachments: [...persistedDraft.attachments], terminalContexts: persistedDraft.terminalContexts?.map((context) => ({ ...context, @@ -3025,10 +2966,6 @@ const composerDraftStore = create()( const nextDraft: ComposerThreadDraftState = { ...current, images: current.images.filter((image) => image.id !== imageId), - nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), - persistedAttachments: current.persistedAttachments.filter( - (attachment) => attachment.id !== imageId, - ), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -3039,6 +2976,35 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + setImageUpload: (threadRef, imageId, upload) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + let changed = false; + const images = current.images.map((image) => { + if (image.id !== imageId || image.upload === upload) { + return image; + } + changed = true; + return { ...image, upload }; + }); + if (!changed) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { ...current, images }, + }, + }; + }); + }, insertTerminalContext: (threadRef, prompt, context, index) => { const threadKey = resolveComposerDraftKey(get(), threadRef); const threadId = resolveComposerThreadId(get(), threadRef); @@ -3308,12 +3274,6 @@ const composerDraftStore = create()( ...current, previewAnnotations, images: current.images.filter((image) => image.id !== annotationId), - persistedAttachments: current.persistedAttachments.filter( - (image) => image.id !== annotationId, - ), - nonPersistedImageIds: current.nonPersistedImageIds.filter( - (imageId) => imageId !== annotationId, - ), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey]; @@ -3370,61 +3330,6 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - clearPersistedAttachments: (threadRef) => { - const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; - if (threadKey.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadKey[threadKey]; - if (!current) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - persistedAttachments: [], - nonPersistedImageIds: [], - }; - const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadKey[threadKey]; - } else { - nextDraftsByThreadKey[threadKey] = nextDraft; - } - return { draftsByThreadKey: nextDraftsByThreadKey }; - }); - }, - syncPersistedAttachments: (threadRef, attachments) => { - const threadKey = resolveComposerDraftKey(get(), threadRef); - if (!threadKey) { - return; - } - const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); - set((state) => { - const current = state.draftsByThreadKey[threadKey]; - if (!current) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - // Stage attempted attachments so persist middleware can try writing them. - persistedAttachments: attachments, - nonPersistedImageIds: current.nonPersistedImageIds.filter( - (id) => !attachmentIdSet.has(id), - ), - }; - const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadKey[threadKey]; - } else { - nextDraftsByThreadKey[threadKey] = nextDraft; - } - return { draftsByThreadKey: nextDraftsByThreadKey }; - }); - Promise.resolve().then(() => { - verifyPersistedAttachments(threadKey, attachments, set); - }); - }, clearComposerContent: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -3439,8 +3344,6 @@ const composerDraftStore = create()( ...current, prompt: "", images: [], - nonPersistedImageIds: [], - persistedAttachments: [], terminalContexts: [], elementContexts: [], previewAnnotations: [], @@ -3472,8 +3375,6 @@ const composerDraftStore = create()( ...current, prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length), images: [], - nonPersistedImageIds: [], - persistedAttachments: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -3507,24 +3408,18 @@ const composerDraftStore = create()( ...destination, prompt: movedPrompt, images: [...destination.images, ...source.images], - nonPersistedImageIds: [ - ...destination.nonPersistedImageIds, - ...source.nonPersistedImageIds, - ], - persistedAttachments: [ - ...destination.persistedAttachments, - ...source.persistedAttachments, - ], }; // Same clearing shape as clearComposerPromptAndImages, but the // preview URLs are NOT revoked: the images moved and their blobs // are still referenced from the destination. + // + // Callers must retarget in-flight uploads to the destination + // (`retargetAttachmentUploads`) or progress writes keep aiming at + // the source draft and the moved chip never leaves `uploading`. const nextSource: ComposerThreadDraftState = { ...source, prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), images: [], - nonPersistedImageIds: [], - persistedAttachments: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextSource)) { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 64176c0873a7..63640b35e86d 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -15,6 +15,7 @@ import { type DraftThreadState, useComposerDraftStore, } from "../composerDraftStore"; +import { retargetAttachmentUploads } from "../lib/attachmentUploadQueue"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; import { @@ -153,7 +154,13 @@ export function useNewThreadHandler() { !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) && composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId)) ) { + const movedImageIds = + getComposerDraft(carryContentSourceDraftId)?.images.map((image) => image.id) ?? []; moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId); + // In-flight uploads keep writing to their original target; point + // them at the destination or the moved chips never leave + // `uploading`. + retargetAttachmentUploads(movedImageIds, destinationDraftId); } }; const project = projects.find( diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts new file mode 100644 index 000000000000..1f3014a126f2 --- /dev/null +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -0,0 +1,344 @@ +/** + * Upload-on-attach queue for composer image attachments. + * + * Each attached image walks: mint a signed upload URL over ws (with the exact + * post-compression byte count) -> POST the raw bytes to that URL over HTTP -> + * mark the chip ready. XHR rather than fetch, because the chip needs + * `upload.onprogress` and the user needs `abort()`. + * + * Lives outside React so an upload survives a thread switch, a composer + * remount, or the panel that started it being unmounted mid-flight. + */ +import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; + +import { + type ComposerImageAttachment, + type ComposerThreadTarget, + useComposerDraftStore, +} from "../composerDraftStore"; +import { + type ComposerAttachmentUpload, + MAX_CONCURRENT_ATTACHMENT_UPLOADS, +} from "./attachmentUploadState"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { attachmentEnvironment } from "../state/attachments"; +import { readPreparedConnection } from "../state/session"; + +interface UploadJob { + readonly imageId: string; + /** Mutable: a draft move retargets live jobs (`retargetAttachmentUploads`). */ + target: ComposerThreadTarget; + readonly environmentId: EnvironmentId; + /** + * A prior copy of the same image in another environment, deleted only once + * this upload succeeds. Deleting it up front would leave zero server copies + * if the re-upload fails. + */ + readonly supersedes: { + readonly environmentId: EnvironmentId; + readonly attachmentId: string; + } | null; + readonly file: File; + readonly name: string; + readonly mimeType: string; + /** Resolves with the terminal upload state once the job stops running. */ + readonly settled: Promise; + resolveSettled: (upload: ComposerAttachmentUpload | null) => void; + finalUpload: ComposerAttachmentUpload | null; + /** Set once the URL has been minted, so a cancel can release the reservation. */ + attachmentId: string | null; + cancelled: boolean; + abort: (() => void) | null; +} + +const jobsByImageId = new Map(); +/** + * Terminal states of finished jobs, kept after the job itself is discarded. + * `awaitAttachmentUploads` reads through this so an upload that completed + * before the await started is still reported instead of silently dropped. + * Entries die on cancel/release; the map is bounded by images attached in a + * session. + */ +const settledUploadsByImageId = new Map(); +const queue: UploadJob[] = []; +let activeCount = 0; + +function setUploadState(job: UploadJob, upload: ComposerAttachmentUpload): void { + if (job.cancelled) return; + job.finalUpload = upload; + useComposerDraftStore.getState().setImageUpload(job.target, job.imageId, upload); +} + +function finishJob(job: UploadJob): void { + if (jobsByImageId.get(job.imageId) === job) { + jobsByImageId.delete(job.imageId); + if (job.finalUpload !== null) { + settledUploadsByImageId.set(job.imageId, job.finalUpload); + } + } + job.resolveSettled(job.finalUpload); +} + +/** Best-effort release of server-side bytes. Failures are not worth surfacing. */ +function deleteAttachment(environmentId: EnvironmentId, attachmentId: string): void { + void runAtomCommand( + appAtomRegistry, + attachmentEnvironment.remove, + { environmentId, input: { attachmentId } }, + { reportFailure: false, reportDefect: false }, + ); +} + +interface ByteUpload { + readonly done: Promise<"ok" | "aborted">; + readonly abort: () => void; +} + +/** + * Hard ceiling on one upload attempt. Compressed images are a few MB at most, + * so five minutes only ever triggers on a genuinely stalled connection — + * without it a stalled POST never settles, the chip stays `uploading`, and + * everything gated on settlement (send, pick-and-send) hangs with it. + */ +const UPLOAD_TIMEOUT_MS = 5 * 60_000; + +function postBytes( + url: string, + file: File, + mimeType: string, + onProgress: (progress: number) => void, +): ByteUpload { + const xhr = new XMLHttpRequest(); + const done = new Promise<"ok" | "aborted">((resolve, reject) => { + xhr.open("POST", url, true); + xhr.timeout = UPLOAD_TIMEOUT_MS; + xhr.setRequestHeader("Content-Type", mimeType); + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(event.loaded / event.total); + } + }); + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve("ok"); + return; + } + reject(new Error(`Upload rejected (${xhr.status})`)); + }); + xhr.addEventListener("abort", () => resolve("aborted")); + xhr.addEventListener("error", () => reject(new Error("Upload failed"))); + xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); + xhr.send(file); + }); + return { done, abort: () => xhr.abort() }; +} + +async function runJob(job: UploadJob): Promise { + const minted = await runAtomCommand( + appAtomRegistry, + attachmentEnvironment.createUploadUrl, + { + environmentId: job.environmentId, + input: { name: job.name, mimeType: job.mimeType, sizeBytes: job.file.size }, + }, + { reportFailure: false }, + ); + if (job.cancelled) return; + if (minted._tag !== "Success") { + setUploadState(job, { status: "failed", reason: "Upload could not start" }); + return; + } + job.attachmentId = minted.value.attachmentId; + + const connection = readPreparedConnection(job.environmentId); + const uploadUrl = connection + ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) + : null; + if (uploadUrl === null) { + setUploadState(job, { status: "failed", reason: "Not connected" }); + return; + } + + // Whole-percent updates only: the raw progress event fires far more often + // than the chip has anything new to say, and every update is a store write. + let lastPercent = -1; + const byteUpload = postBytes(uploadUrl, job.file, job.mimeType, (progress) => { + const percent = Math.floor(progress * 100); + if (percent === lastPercent) return; + lastPercent = percent; + setUploadState(job, { status: "uploading", progress }); + }); + job.abort = byteUpload.abort; + + try { + const outcome = await byteUpload.done; + if (job.cancelled || outcome === "aborted") return; + setUploadState(job, { + status: "ready", + attachmentId: minted.value.attachmentId, + environmentId: job.environmentId, + }); + if (job.supersedes) { + deleteAttachment(job.supersedes.environmentId, job.supersedes.attachmentId); + } + } catch (error) { + if (job.cancelled) return; + setUploadState(job, { + status: "failed", + reason: error instanceof Error ? error.message : "Upload failed", + }); + } finally { + job.abort = null; + } +} + +function pump(): void { + while (activeCount < MAX_CONCURRENT_ATTACHMENT_UPLOADS && queue.length > 0) { + const job = queue.shift(); + if (!job || job.cancelled) continue; + activeCount += 1; + void runJob(job) + .catch(() => { + setUploadState(job, { status: "failed", reason: "Upload failed" }); + }) + .finally(() => { + activeCount -= 1; + finishJob(job); + pump(); + }); + } +} + +/** + * Queues an image for upload and marks its chip `uploading`. Safe to call for + * an image that is already queued or in flight: the existing job wins. + */ +export function startAttachmentUpload(input: { + target: ComposerThreadTarget; + environmentId: EnvironmentId; + image: ComposerImageAttachment; + /** See UploadJob.supersedes — set when re-uploading across environments. */ + supersedes?: { readonly environmentId: EnvironmentId; readonly attachmentId: string }; +}): void { + const { image } = input; + if (!image.file || jobsByImageId.has(image.id)) { + return; + } + let resolveSettled: (upload: ComposerAttachmentUpload | null) => void = () => {}; + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); + const job: UploadJob = { + imageId: image.id, + target: input.target, + environmentId: input.environmentId, + supersedes: input.supersedes ?? null, + file: image.file, + name: image.name, + mimeType: image.mimeType, + settled, + resolveSettled, + finalUpload: null, + attachmentId: null, + cancelled: false, + abort: null, + }; + jobsByImageId.set(image.id, job); + queue.push(job); + setUploadState(job, { status: "uploading", progress: 0 }); + pump(); +} + +/** + * Aborts an in-flight upload and releases anything already reserved for it. + * Used by chip removal and by the retry path before it re-queues. + */ +export function cancelAttachmentUpload(imageId: string): void { + settledUploadsByImageId.delete(imageId); + const job = jobsByImageId.get(imageId); + if (!job) return; + job.cancelled = true; + jobsByImageId.delete(imageId); + const queuedIndex = queue.indexOf(job); + if (queuedIndex >= 0) { + queue.splice(queuedIndex, 1); + } + job.abort?.(); + if (job.attachmentId) { + deleteAttachment(job.environmentId, job.attachmentId); + } + job.resolveSettled(null); +} + +/** + * Drops an image from the composer's point of view: cancels an in-flight + * upload, and releases the server copy when the upload already landed. + */ +export function releaseComposerAttachment(image: ComposerImageAttachment): void { + cancelAttachmentUpload(image.id); + if (image.upload.status === "ready") { + deleteAttachment(image.upload.environmentId, image.upload.attachmentId); + } +} + +/** + * Points in-flight uploads at a new composer target after + * `moveComposerPromptAndImages`. Without this, progress and completion writes + * keep landing on the source draft and the moved chip never leaves + * `uploading`. + */ +export function retargetAttachmentUploads( + imageIds: ReadonlyArray, + target: ComposerThreadTarget, +): void { + for (const imageId of imageIds) { + const job = jobsByImageId.get(imageId); + if (job) { + job.target = target; + } + } +} + +/** Re-runs a failed (or environment-stale) upload with the File still in memory. */ +export function retryAttachmentUpload(input: { + target: ComposerThreadTarget; + environmentId: EnvironmentId; + image: ComposerImageAttachment; +}): void { + cancelAttachmentUpload(input.image.id); + startAttachmentUpload(input); +} + +/** + * Resolves once every listed image has settled, keyed by image id. The send + * button is disabled while uploads run, but the preview-annotation "pick and + * send" path attaches and sends in one gesture, so it has to wait here — and + * it needs the settled states, because the draft it read them from is already + * cleared by then. + */ +export async function awaitAttachmentUploads( + imageIds: ReadonlyArray, +): Promise> { + const results = new Map(); + const pending: Array> = []; + for (const imageId of imageIds) { + const job = jobsByImageId.get(imageId); + if (job) { + pending.push(job.settled.then((upload) => [imageId, upload] as const)); + continue; + } + // The job may have settled (and been discarded) before this await began. + const settledUpload = settledUploadsByImageId.get(imageId); + if (settledUpload) { + results.set(imageId, settledUpload); + } + } + for (const [imageId, upload] of await Promise.all(pending)) { + if (upload) { + results.set(imageId, upload); + } + } + return results; +} diff --git a/apps/web/src/lib/attachmentUploadState.test.ts b/apps/web/src/lib/attachmentUploadState.test.ts new file mode 100644 index 000000000000..210b1b315b9b --- /dev/null +++ b/apps/web/src/lib/attachmentUploadState.test.ts @@ -0,0 +1,140 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + attachmentUploadBlockReason, + formatAttachmentUploadProgress, + isAttachmentInWrongEnvironment, + readyAttachmentRefs, + resolveAttachmentEnvironmentAction, + summarizeAttachmentUploads, + type ComposerAttachmentUpload, +} from "./attachmentUploadState"; + +const ENVIRONMENT_ID = EnvironmentId.make("env-1"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.make("env-2"); + +function image( + name: string, + upload: ComposerAttachmentUpload, + file: File | null = new File([], name), +) { + return { name, mimeType: "image/png", sizeBytes: 10, upload, file }; +} + +const ready = (attachmentId: string, environmentId = ENVIRONMENT_ID): ComposerAttachmentUpload => ({ + status: "ready", + attachmentId, + environmentId, +}); + +describe("attachment upload gating", () => { + it("blocks the send while any attachment is uploading", () => { + const summary = summarizeAttachmentUploads( + [image("a.png", ready("pending-a")), image("b.png", { status: "uploading", progress: 0.4 })], + ENVIRONMENT_ID, + ); + expect(summary).toEqual({ ready: 1, uploading: 1, failed: 0, wrongEnvironment: 0 }); + expect(attachmentUploadBlockReason(summary)).toBe("Image still uploading"); + }); + + it("blocks the send on a failed attachment so it cannot be silently dropped", () => { + const summary = summarizeAttachmentUploads( + [image("a.png", { status: "failed", reason: "Upload failed" })], + ENVIRONMENT_ID, + ); + expect(attachmentUploadBlockReason(summary)).toBe("Retry or remove the failed image"); + }); + + it("does not block once every attachment is ready", () => { + const summary = summarizeAttachmentUploads( + [image("a.png", ready("pending-a"))], + ENVIRONMENT_ID, + ); + expect(attachmentUploadBlockReason(summary)).toBeNull(); + }); + + it("blocks the send on a ready attachment whose bytes live elsewhere", () => { + const summary = summarizeAttachmentUploads( + [image("a.png", ready("pending-a", OTHER_ENVIRONMENT_ID))], + ENVIRONMENT_ID, + ); + expect(summary).toEqual({ ready: 0, uploading: 0, failed: 0, wrongEnvironment: 1 }); + expect(attachmentUploadBlockReason(summary)).toBe("Remove the image from another environment"); + }); + + it("derives the wrong-environment condition without mutating upload state", () => { + const wrongEnvironmentImage = image("a.png", ready("pending-a", OTHER_ENVIRONMENT_ID)); + expect(isAttachmentInWrongEnvironment(wrongEnvironmentImage, ENVIRONMENT_ID)).toBe(true); + // Pointing the composer back at the bytes' environment recovers it. + expect(isAttachmentInWrongEnvironment(wrongEnvironmentImage, OTHER_ENVIRONMENT_ID)).toBe(false); + }); +}); + +describe("readyAttachmentRefs", () => { + it("emits id references for uploaded attachments only", () => { + expect( + readyAttachmentRefs( + [ + image("a.png", ready("pending-a")), + image("b.png", { status: "uploading", progress: 0.9 }), + // Uploaded, but to a different environment: not sendable here. + image("c.png", ready("pending-c", OTHER_ENVIRONMENT_ID)), + ], + ENVIRONMENT_ID, + ), + ).toEqual([ + { type: "image", id: "pending-a", name: "a.png", mimeType: "image/png", sizeBytes: 10 }, + ]); + }); +}); + +describe("formatAttachmentUploadProgress", () => { + it("floors so a chip never reads 100% before the bytes land", () => { + expect(formatAttachmentUploadProgress(0)).toBe("0%"); + expect(formatAttachmentUploadProgress(0.999)).toBe("99%"); + expect(formatAttachmentUploadProgress(1)).toBe("100%"); + }); + + it("clamps values outside 0..1", () => { + expect(formatAttachmentUploadProgress(-1)).toBe("0%"); + expect(formatAttachmentUploadProgress(Number.NaN)).toBe("0%"); + }); +}); + +describe("resolveAttachmentEnvironmentAction", () => { + it("keeps an attachment already uploaded to the target environment", () => { + expect( + resolveAttachmentEnvironmentAction(image("a.png", ready("pending-a")), ENVIRONMENT_ID), + ).toBe("keep"); + }); + + it("re-uploads when the File is still in memory", () => { + expect( + resolveAttachmentEnvironmentAction( + image("a.png", ready("pending-a", OTHER_ENVIRONMENT_ID)), + ENVIRONMENT_ID, + ), + ).toBe("reupload"); + }); + + it("keeps (does not destroy) a File-less attachment from another environment", () => { + // No File means no re-upload is possible; the ready state survives so + // switching back to the bytes' environment restores the attachment. + expect( + resolveAttachmentEnvironmentAction( + image("a.png", ready("pending-a", OTHER_ENVIRONMENT_ID), null), + ENVIRONMENT_ID, + ), + ).toBe("keep"); + }); + + it("leaves an in-flight upload alone", () => { + expect( + resolveAttachmentEnvironmentAction( + image("a.png", { status: "uploading", progress: 0.1 }), + ENVIRONMENT_ID, + ), + ).toBe("keep"); + }); +}); diff --git a/apps/web/src/lib/attachmentUploadState.ts b/apps/web/src/lib/attachmentUploadState.ts new file mode 100644 index 000000000000..06b8f59a3222 --- /dev/null +++ b/apps/web/src/lib/attachmentUploadState.ts @@ -0,0 +1,160 @@ +/** + * Chip-level state for composer image attachments. + * + * Attachments upload the moment they are attached (paste / drop / picker), so + * a chip is visible long before its bytes are on the server. The chip shows + * upload progress as plain text — never a repainting spinner — and the send + * button stays disabled until every chip has settled to `ready`. + * + * This module is deliberately dependency-free so the state machine can be + * tested (and imported by the draft store) without pulling in the ws runtime. + */ +import type { EnvironmentId, ChatAttachment } from "@t3tools/contracts"; + +export type ComposerAttachmentUpload = + /** Bytes are in flight. `progress` is 0..1. */ + | { readonly status: "uploading"; readonly progress: number } + /** + * Bytes are on the server. `attachmentId` is what the turn-start command + * references; `environmentId` records where the bytes actually landed, so a + * draft retargeted to another environment can detect the mismatch. + */ + | { + readonly status: "ready"; + readonly attachmentId: string; + readonly environmentId: EnvironmentId; + } + /** Upload failed or was rejected. `reason` is short enough to render inline. */ + | { readonly status: "failed"; readonly reason: string }; + +/** Shown when a restored attachment belongs to a different environment. */ +export const ATTACHMENT_WRONG_ENVIRONMENT_REASON = "Not in this environment"; + +/** Uploads allowed to run at once; the rest queue behind them. */ +export const MAX_CONCURRENT_ATTACHMENT_UPLOADS = 3; + +interface UploadableImage { + readonly upload: ComposerAttachmentUpload; +} + +/** + * True for an uploaded attachment whose bytes live in a different environment + * than the composer currently targets. This is a *derived* condition, never + * written into the upload state: the ready state (with its attachmentId and + * home environment) survives untouched, so pointing the draft back at the + * bytes' environment makes the attachment sendable again with no re-upload. + */ +export function isAttachmentInWrongEnvironment( + image: UploadableImage, + environmentId: EnvironmentId, +): boolean { + return image.upload.status === "ready" && image.upload.environmentId !== environmentId; +} + +export interface AttachmentUploadSummary { + /** Uploaded into the composer's current environment: sendable. */ + readonly ready: number; + readonly uploading: number; + readonly failed: number; + /** Uploaded, but into a different environment: blocks send until resolved. */ + readonly wrongEnvironment: number; +} + +export function summarizeAttachmentUploads( + images: ReadonlyArray, + environmentId: EnvironmentId, +): AttachmentUploadSummary { + let ready = 0; + let uploading = 0; + let failed = 0; + let wrongEnvironment = 0; + for (const image of images) { + if (image.upload.status === "ready") { + if (image.upload.environmentId === environmentId) ready += 1; + else wrongEnvironment += 1; + } else if (image.upload.status === "uploading") uploading += 1; + else failed += 1; + } + return { ready, uploading, failed, wrongEnvironment }; +} + +/** + * Why the send button is disabled, or null when attachments are not blocking. + * A failed or unreachable chip has to be retried or removed: silently + * dropping it would send a message the user believes carries an image. + */ +export function attachmentUploadBlockReason(summary: AttachmentUploadSummary): string | null { + if (summary.uploading > 0) { + return summary.uploading === 1 ? "Image still uploading" : "Images still uploading"; + } + if (summary.failed > 0) { + return summary.failed === 1 + ? "Retry or remove the failed image" + : "Retry or remove the failed images"; + } + if (summary.wrongEnvironment > 0) { + return summary.wrongEnvironment === 1 + ? "Remove the image from another environment" + : "Remove the images from another environment"; + } + return null; +} + +/** Percent text drawn over an uploading chip. Rounded down so it never reads 100% early. */ +export function formatAttachmentUploadProgress(progress: number): string { + const clamped = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0)); + return `${Math.floor(clamped * 100)}%`; +} + +/** + * Turn-start references for the images that actually made it to the server + * *in the target environment*. Everything else is skipped: the send path is + * gated on all-ready-here, so this only drops attachments that were never + * sendable in the first place. + */ +export function readyAttachmentRefs( + images: ReadonlyArray< + UploadableImage & { + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + } + >, + environmentId: EnvironmentId, +): ChatAttachment[] { + return images.flatMap((image) => + image.upload.status === "ready" && image.upload.environmentId === environmentId + ? [ + { + type: "image" as const, + id: image.upload.attachmentId, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }, + ] + : [], + ); +} + +/** + * What to do with an attachment when the composer's target environment + * changes. The bytes live in exactly one environment, so a ready attachment + * pointing anywhere else has to be re-uploaded — possible only while the + * original `File` is still in memory (it is not, after a reload). Without a + * File the attachment is left untouched: the mismatch is *derived* for + * display and send-gating (`isAttachmentInWrongEnvironment`), and switching + * back to the bytes' environment restores it for free. + */ +export function resolveAttachmentEnvironmentAction( + image: UploadableImage & { readonly file: File | null }, + targetEnvironmentId: EnvironmentId, +): "keep" | "reupload" { + if (image.upload.status !== "ready") { + return "keep"; + } + if (image.upload.environmentId === targetEnvironmentId) { + return "keep"; + } + return image.file ? "reupload" : "keep"; +} diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 63712ca7e295..6ec4e99120f5 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -1,11 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { - compressImageForStash, - compressImageToByteLimit, - MAX_COMPRESSIBLE_SOURCE_BYTES, - MAX_STASH_IMAGE_DATA_URL_CHARS, -} from "./imageCompression"; +import { compressImageToByteLimit, MAX_COMPRESSIBLE_SOURCE_BYTES } from "./imageCompression"; /** * jsdom has no real canvas/codec, so the re-encode path is exercised with @@ -68,102 +63,7 @@ afterEach(() => { globalThis.OffscreenCanvas = originalOffscreenCanvas; }); -describe("compressImageForStash", () => { - it("stores a small image verbatim without re-encoding", async () => { - const bitmapSpy = vi.fn(); - vi.stubGlobal("createImageBitmap", bitmapSpy); - - const result = await compressImageForStash(makeFile(1024)); - - expect(result.ok).toBe(true); - expect(result.ok && result.image.recompressed).toBe(false); - expect(result.ok && result.image.mimeType).toBe("image/png"); - expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true); - // Untouched payloads must not pay for a decode. - expect(bitmapSpy).not.toHaveBeenCalled(); - }); - - it("re-encodes an oversized image to WebP within the budget", async () => { - // Comfortably under budget at the very first quality step. - const { close, fillRect } = stubCanvasPipeline(() => 120_000); - - const result = await compressImageForStash(makeFile(4_000_000)); - - expect(result.ok).toBe(true); - expect(result.ok && result.image.recompressed).toBe(true); - expect(result.ok && result.image.mimeType).toBe("image/webp"); - expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); - // sizeBytes should describe the re-encoded payload, not the 4MB original. - expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000); - // WebP keeps alpha, so no white matte should be painted. - expect(fillRect).not.toHaveBeenCalled(); - expect(close).toHaveBeenCalled(); - }); - - it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => { - const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false }); - - const result = await compressImageForStash(makeFile(4_000_000)); - - expect(result.ok && result.image.recompressed).toBe(true); - expect(result.ok && result.image.mimeType).toBe("image/jpeg"); - // JPEG has no alpha, so transparent regions must be matted white. - expect(fillRect).toHaveBeenCalled(); - }); - - it("steps quality down until the encoded image fits", async () => { - // Only the lowest quality step (0.68) lands under the budget. - const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000)); - - const result = await compressImageForStash(makeFile(9_000_000)); - - expect(result.ok && result.image.recompressed).toBe(true); - expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); - expect(close).toHaveBeenCalled(); - }); - - it("reports too-large when even the smallest encoding overflows the budget", async () => { - const { close } = stubCanvasPipeline(() => 8_000_000); - - const result = await compressImageForStash(makeFile(9_000_000)); - - expect(result).toEqual({ ok: false, reason: "too-large" }); - // The bitmap must still be released on the give-up path. - expect(close).toHaveBeenCalled(); - }); - - it("reports too-large for an oversized image when the browser cannot re-encode", async () => { - vi.stubGlobal("createImageBitmap", undefined); - vi.stubGlobal("OffscreenCanvas", undefined); - - expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ - ok: false, - reason: "too-large", - }); - }); - - it("reports unreadable when the image fails to decode", async () => { - vi.stubGlobal( - "createImageBitmap", - vi.fn(async () => { - throw new Error("corrupt image"); - }), - ); - vi.stubGlobal( - "OffscreenCanvas", - class { - getContext() { - return null; - } - }, - ); - - expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ - ok: false, - reason: "unreadable", - }); - }); - +describe("compressImageToByteLimit", () => { it("compressImageToByteLimit passes small files through byte-for-byte", async () => { const bitmapSpy = vi.fn(); vi.stubGlobal("createImageBitmap", bitmapSpy); @@ -243,7 +143,7 @@ describe("compressImageForStash", () => { }, ); - const result = await compressImageForStash(makeFile(4_000_000)); + const result = await compressImageToByteLimit(makeFile(4_000_000), 1_000_000); expect(result.ok).toBe(true); // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index be45024f38c4..926f259749df 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -1,12 +1,12 @@ /** * Downscale + re-encode for image attachments that are too big for where - * they're headed. Two consumers share the same pipeline: + * they're headed. * - * - The prompt stash persists images as base64 in localStorage (~5MB origin - * quota), so `compressImageForStash` targets a per-image character budget. - * - The composer accepts pasted/dropped images larger than the provider's - * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit - * via `compressImageToByteLimit` instead of rejecting the paste. + * The composer accepts pasted/dropped images larger than the provider's + * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit via + * `compressImageToByteLimit` instead of rejecting the paste. The result is + * what gets uploaded, so its exact byte length is what the upload URL is + * minted against. * * Images already within budget pass through untouched. */ @@ -16,8 +16,6 @@ * retina screenshot (3024px wide) stays legible rather than being halved. */ const MAX_DIMENSION = 2048; -/** Base64 budget for a single stashed image (~975KB of binary). */ -export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; /** * Ceiling on the *source* file handed to the re-encoder. File size is a * proxy for pixel count, and decoding hundreds of megapixels into an @@ -33,24 +31,12 @@ const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; /** Extra downscale passes applied when even the lowest quality overflows. */ const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; -export interface CompressedStashImage { - dataUrl: string; - mimeType: string; - sizeBytes: number; - /** True when the payload was re-encoded rather than stored verbatim. */ - recompressed: boolean; -} - /** * Why an image could not be compressed. Callers report these differently: * "too large" is a budget outcome, "unreadable" is a decode failure. */ export type ImageCompressionFailureReason = "too-large" | "unreadable"; -export type CompressStashImageResult = - | { ok: true; image: CompressedStashImage } - | { ok: false; reason: ImageCompressionFailureReason }; - export type CompressImageFileResult = | { ok: true; file: File; recompressed: boolean } | { ok: false; reason: ImageCompressionFailureReason }; @@ -76,14 +62,6 @@ async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Prom return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; } -/** Approximate decoded byte count for a base64 data URL. */ -function dataUrlByteLength(dataUrl: string): number { - const commaIndex = dataUrl.indexOf(","); - const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); - const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; - return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); -} - /** Base64 payload of a data URL decoded back into a `File`. */ function dataUrlToFile(dataUrl: string, name: string, mimeType: string): File { const payload = dataUrl.slice(dataUrl.indexOf(",") + 1); @@ -251,50 +229,6 @@ async function reencodeWithinBudget(file: File, budgetChars: number): Promise { - let originalDataUrl: string; - try { - originalDataUrl = await blobToDataUrl(file); - } catch { - return { ok: false, reason: "unreadable" }; - } - if (originalDataUrl.length <= budgetChars) { - return { - ok: true, - image: { - dataUrl: originalDataUrl, - mimeType: file.type, - sizeBytes: file.size, - recompressed: false, - }, - }; - } - const reencoded = await reencodeWithinBudget(file, budgetChars); - if (!reencoded.ok) { - return reencoded; - } - return { - ok: true, - image: { - dataUrl: reencoded.dataUrl, - mimeType: reencoded.mimeType, - sizeBytes: dataUrlByteLength(reencoded.dataUrl), - recompressed: true, - }, - }; -} - /** * Shrinks `file` until its binary size fits `maxBytes`, returning a new * `File` (WebP or JPEG). Files already within the limit pass through diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 20894713d1d9..18b9294bbf7e 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -5,8 +5,6 @@ import { removeLocalStorageItem } from "./hooks/useLocalStorage"; import { MAX_STASH_ENTRIES, PROMPT_STASH_STORAGE_KEY, - MAX_STASH_ENTRY_ATTACHMENT_CHARS, - partitionStashAttachments, usePromptStashStore, writePromptStashStorageForTest, type PromptStashEntry, @@ -15,24 +13,24 @@ import { function makeEntry(input: { id: string; prompt?: string; - attachmentChars?: number; + withAttachment?: boolean; }): PromptStashEntry { return { id: input.id, createdAt: "2026-07-24T12:00:00.000Z", prompt: input.prompt ?? `prompt ${input.id}`, - attachments: - input.attachmentChars !== undefined - ? [ - { - id: `${input.id}-img`, - name: "shot.png", - mimeType: "image/png", - sizeBytes: input.attachmentChars, - dataUrl: "x".repeat(input.attachmentChars), - }, - ] - : [], + attachments: input.withAttachment + ? [ + { + id: `img-${input.id}`, + attachmentId: `pending-${input.id}`, + name: "shot.png", + mimeType: "image/png", + sizeBytes: 1024, + environmentId: "env-1", + }, + ] + : [], droppedImageNames: [], }; } @@ -43,48 +41,6 @@ function resetPromptStashStore() { removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); } -describe("partitionStashAttachments", () => { - it("keeps attachments within the budget and reports dropped names in order", () => { - const small = { - id: "a", - name: "small.png", - mimeType: "image/png", - sizeBytes: 10, - dataUrl: "x".repeat(10), - }; - const huge = { - id: "b", - name: "huge.png", - mimeType: "image/png", - sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, - dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), - }; - const alsoSmall = { - id: "c", - name: "also-small.png", - mimeType: "image/png", - sizeBytes: 10, - dataUrl: "x".repeat(10), - }; - const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]); - expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]); - expect(droppedNames).toEqual(["huge.png"]); - }); - - it("admits a single attachment that exactly fits the budget", () => { - const exact = { - id: "a", - name: "exact.png", - mimeType: "image/png", - sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, - dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), - }; - const { kept, droppedNames } = partitionStashAttachments([exact]); - expect(kept).toHaveLength(1); - expect(droppedNames).toEqual([]); - }); -}); - describe("promptStashStore", () => { beforeEach(() => { resetPromptStashStore(); @@ -137,70 +93,54 @@ describe("promptStashStore", () => { expect(entries.map((entry) => entry.id)).toEqual(["keep"]); }); - it("finalizeEntryImages attaches images and clears the pending count", () => { - const store = usePromptStashStore.getState(); - store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); - - const { attached } = store.finalizeEntryImages("pending", { - attachments: [ - { - id: "img-1", - name: "a.webp", - mimeType: "image/webp", - sizeBytes: 10, - dataUrl: "data:image/webp;base64,AAAA", - }, - ], - droppedImageNames: ["big.png"], - unreadableImageNames: [], - }); - - expect(attached).toBe(true); - const entry = usePromptStashStore.getState().entries[0]; - expect(entry?.attachments).toHaveLength(1); - expect(entry?.droppedImageNames).toEqual(["big.png"]); - expect(entry?.pendingImageCount).toBe(0); - }); - - it("finalizeEntryImages reports false when the entry was already taken", () => { - const store = usePromptStashStore.getState(); - store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); - // Restored (or deleted) while its images were still encoding. - store.takeEntry("racing"); - - const { attached } = store.finalizeEntryImages("racing", { - attachments: [], - droppedImageNames: [], - unreadableImageNames: [], - }); - - expect(attached).toBe(false); - }); - - it("settles a pending count left behind by a crashed or closed session", () => { + it("decodes a v3 payload with environment-scoped attachment references", () => { writePromptStashStorageForTest( JSON.stringify({ - version: 2, - state: { - entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], - }, + version: 3, + state: { entries: [makeEntry({ id: "restored", withAttachment: true })] }, }), ); - // Hydration must settle the stale count, or the entry would stay stuck - // showing "saving…" with images that no longer exist anywhere. const entry = usePromptStashStore.getState().entries[0]; - expect(entry?.pendingImageCount).toBe(0); - expect(entry?.unreadableImageNames).toHaveLength(2); + expect(entry?.id).toBe("restored"); + expect(entry?.attachments).toEqual([ + { + id: "img-restored", + attachmentId: "pending-restored", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 1024, + environmentId: "env-1", + }, + ]); }); - it("ignores an unreadable v1 payload seeded under the current key", () => { - // The v1 shape (per-provider queues) does not decode as v2; hydration - // must fall back to an empty stash rather than throw. + it("ignores a v2 payload seeded under the current key", () => { + // v2 stored each image inline as a data URL and has no `environmentId`, + // so it cannot decode as v3; hydration must fall back to an empty stash + // rather than throw. writePromptStashStorageForTest( JSON.stringify({ - version: 1, - state: { queuesByScopeKey: { "provider:claudeAgent": [] } }, + version: 2, + state: { + entries: [ + { + id: "legacy", + createdAt: "2026-07-24T12:00:00.000Z", + prompt: "legacy prompt", + attachments: [ + { + id: "img-1", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + droppedImageNames: [], + }, + ], + }, }), ); expect(usePromptStashStore.getState().entries).toEqual([]); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index d7c541e7a947..64a2db82fb9d 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -4,55 +4,42 @@ import { create } from "zustand"; import { PersistedComposerImageAttachment } from "./composerDraftStore"; import { createMemoryStorage, type StateStorage } from "./lib/storage"; -export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; +export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v3"; /** - * v1 bucketed entries into per-provider-instance queues and stored a model - * selection with each prompt. The stash is provider-agnostic now, so the old - * payload is deleted at startup rather than migrated — left behind it would - * silently hold megabytes of the origin's ~5MB localStorage quota forever. + * Superseded payloads, deleted at startup rather than migrated. + * + * v1 bucketed entries into per-provider-instance queues; v2 stored each image + * inline as a base64 data URL. Neither can be rewritten into the current shape + * (v3 entries reference already-uploaded attachments by id), and left behind + * they would hold megabytes of the origin's ~5MB localStorage quota forever. */ -const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; -const PROMPT_STASH_STORAGE_VERSION = 2; +const SUPERSEDED_PROMPT_STASH_STORAGE_KEYS = ["t3code:prompt-stash:v1", "t3code:prompt-stash:v2"]; +const PROMPT_STASH_STORAGE_VERSION = 3; export const MAX_STASH_ENTRIES = 20; -/** - * Budget for an entry's serialized attachment payload. localStorage is a - * ~5MB origin-wide quota shared with the composer draft store, so oversized - * images are dropped (tracked in `droppedImageNames`) rather than persisted. - * - * Sized to hold two images at the per-image compression budget - * (`MAX_STASH_IMAGE_DATA_URL_CHARS`) so a typical before/after screenshot - * pair survives intact. - */ -export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; /** * A stashed prompt carries only what every provider can accept: text and * image attachments. Deliberately no provider instance or model selection — * the point of stashing is to move a prompt into a different thread or * provider, so restoring must never drag the old model choice along. + * + * Attachments are id references to bytes already on the server, so writing an + * entry is synchronous and costs a few hundred bytes of storage. */ const StashEntrySchema = Schema.Struct({ id: Schema.String, createdAt: Schema.String, prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), - /** Names of images that exceeded the attachment budget and were not saved. */ + /** Names of images that were not uploaded yet, so they could not be stashed. */ droppedImageNames: Schema.Array(Schema.String), /** - * Names of images that could not be decoded or re-encoded at all — a - * distinct failure from exceeding the size budget, so the menu can explain - * which actually happened. Optional: entries written before this field - * existed decode without it. + * Names of images whose upload had failed outright — a distinct outcome from + * "still uploading", so the menu can explain which actually happened. + * Optional: entries written before this field existed decode without it. */ unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)), - /** - * Images still being encoded when the entry was written. The entry is - * persisted before its images so a crash mid-encode cannot lose the prompt; - * this field lets the UI show "N images still saving" until - * `finalizeEntryImages` lands, and flags entries orphaned by a reload. - */ - pendingImageCount: Schema.optionalKey(Schema.Number), }); export type PromptStashEntry = typeof StashEntrySchema.Type; @@ -63,61 +50,6 @@ type PersistedPromptStashState = typeof PersistedPromptStashState.Type; const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); -/** - * `pendingImageCount` only has meaning within the session that wrote it: the - * encode loop that would clear it does not survive a reload. Any entry that - * comes back from storage still pending was orphaned by a closed tab or a - * crash mid-encode, so the count is settled here — otherwise the entry would - * be stuck showing "saving…" and refuse to restore forever. - * - * The images are genuinely gone (they were never written), so they are - * recorded as unreadable to keep the prompt itself restorable. - */ -function clearOrphanedPendingImages( - entries: ReadonlyArray, -): ReadonlyArray { - return entries.map((entry) => { - if (!entry.pendingImageCount) return entry; - const lostCount = entry.pendingImageCount; - return { - ...entry, - pendingImageCount: 0, - unreadableImageNames: [ - ...(entry.unreadableImageNames ?? []), - ...Array.from( - { length: lostCount }, - (_, index) => `image ${index + 1} (not saved before reload)`, - ), - ], - }; - }); -} - -/** - * Splits candidate attachments into a persistable set within the entry - * budget plus the names of any that had to be dropped. Attachments are - * admitted in order so the earliest-added images win. - */ -export function partitionStashAttachments( - attachments: ReadonlyArray, -): { - kept: PersistedComposerImageAttachment[]; - droppedNames: string[]; -} { - const kept: PersistedComposerImageAttachment[] = []; - const droppedNames: string[] = []; - let usedChars = 0; - for (const attachment of attachments) { - if (usedChars + attachment.dataUrl.length > MAX_STASH_ENTRY_ATTACHMENT_CHARS) { - droppedNames.push(attachment.name); - continue; - } - usedChars += attachment.dataUrl.length; - kept.push(attachment); - } - return { kept, droppedNames }; -} - /** * Reading the `localStorage` property itself can throw `SecurityError` when * storage is blocked by policy or the page is a sandboxed iframe — so the @@ -171,7 +103,6 @@ function persistEntries(entries: ReadonlyArray): { } } -/** Reads the persisted queue, settling stale pending counts. */ function readPersistedEntries(): ReadonlyArray | null { try { const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); @@ -179,7 +110,7 @@ function readPersistedEntries(): ReadonlyArray | null { const parsed: unknown = JSON.parse(raw); const state = (parsed as { state?: unknown } | null)?.state; if (!state) return null; - return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries); + return decodePersistedPromptStashState(state).entries; } catch { return null; } @@ -207,20 +138,6 @@ interface PromptStashStoreState { * reload would resurrect the entry. */ takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean }; - /** - * Attaches the encoded images to an entry written earlier by `stashEntry`, - * clearing its pending count. Returns attached=false when the entry is gone - * (restored or deleted while encoding was still running) so the caller can - * tell the user their images did not make it. - */ - finalizeEntryImages: ( - entryId: string, - images: { - attachments: ReadonlyArray; - droppedImageNames: ReadonlyArray; - unreadableImageNames: ReadonlyArray; - }, - ) => { attached: boolean; durable: boolean }; } export const usePromptStashStore = create()((set, get) => ({ @@ -247,34 +164,18 @@ export const usePromptStashStore = create()((set, get) => set(() => ({ entries: nextEntries })); return { entry, durable }; }, - finalizeEntryImages: (entryId, images) => { - const entries = get().entries; - const index = entries.findIndex((candidate) => candidate.id === entryId); - const existing = index === -1 ? undefined : entries[index]; - // Restored or deleted mid-encode: nothing to attach to. - if (!existing) return { attached: false, durable: true }; - const nextEntries = [...entries]; - nextEntries[index] = { - ...existing, - attachments: images.attachments, - droppedImageNames: images.droppedImageNames, - unreadableImageNames: images.unreadableImageNames, - pendingImageCount: 0, - }; - const { durable } = persistEntries(nextEntries); - set(() => ({ entries: nextEntries })); - return { attached: true, durable }; - }, })); // Hydrate once at startup. Like the app's other persisted stores, tabs are // last-write-wins: no cross-tab merging or storage-event syncing. { - try { - baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); - } catch { - // Purging the v1 payload is best-effort; a storage policy that rejects - // the delete must not take down module init. + for (const supersededKey of SUPERSEDED_PROMPT_STASH_STORAGE_KEYS) { + try { + baseStashStorage.removeItem(supersededKey); + } catch { + // Purging an old payload is best-effort; a storage policy that rejects + // the delete must not take down module init. + } } const persisted = readPersistedEntries(); if (persisted) { diff --git a/apps/web/src/state/attachments.ts b/apps/web/src/state/attachments.ts new file mode 100644 index 000000000000..c059adce7ec8 --- /dev/null +++ b/apps/web/src/state/attachments.ts @@ -0,0 +1,21 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** + * Composer attachments upload the moment they are attached, before any thread + * exists. `createUploadUrl` mints a short-lived signed URL (the token in the + * URL carries authorization, so the byte PUT needs no headers of its own); + * `remove` releases an attachment the user cancelled or deleted. + */ +export const attachmentEnvironment = { + createUploadUrl: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-command:attachments:create-upload-url", + tag: WS_METHODS.attachmentsCreateUploadUrl, + }), + remove: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-command:attachments:delete", + tag: WS_METHODS.attachmentsDelete, + }), +}; diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index e3922073455d..6a74344ae701 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; -import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { ProjectFaviconPath } from "./orchestration.ts"; +import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, ProjectFaviconPath } from "./orchestration.ts"; const ASSET_PATH_MAX_LENGTH = 1024; @@ -36,6 +36,56 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +// ── Attachment upload (upload-on-attach) ──────────────────────────────── +// +// Attachments upload the moment they are added to the composer, before any +// thread exists. The client asks for a signed upload URL over ws (which +// carries auth), then PUTs raw bytes to it over HTTP. The returned id is +// `pending-`; the turn-start Normalizer re-scopes it to the thread. + +/** How long a minted upload URL stays valid. */ +export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000; + +const AttachmentIdSchema = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); + +export const AttachmentCreateUploadUrlInput = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), + /** + * The exact byte length the client will upload. The upload route rejects a + * body that does not match, so a truncated or padded transfer can never + * become a "ready" attachment. + */ + sizeBytes: NonNegativeInt.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), + ), +}); +export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type; + +export const AttachmentCreateUploadUrlResult = Schema.Struct({ + attachmentId: AttachmentIdSchema, + relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + expiresAt: Schema.Number, +}); +export type AttachmentCreateUploadUrlResult = typeof AttachmentCreateUploadUrlResult.Type; + +export const AttachmentDeleteInput = Schema.Struct({ + attachmentId: AttachmentIdSchema, +}); +export type AttachmentDeleteInput = typeof AttachmentDeleteInput.Type; + +export class AttachmentUploadSigningKeyError extends Schema.TaggedErrorClass()( + "AttachmentUploadSigningKeyError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load the attachment upload signing key."; + } +} + export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( "AssetWorkspaceContextNotFoundError", { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 35fef721efa7..c5dfdbcbeab1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -144,7 +144,6 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; -const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; // Correlation id is command id by design in this model. export const CorrelationId = CommandId; @@ -165,21 +164,8 @@ export const ChatImageAttachment = Schema.Struct({ }); export type ChatImageAttachment = typeof ChatImageAttachment.Type; -const UploadChatImageAttachment = Schema.Struct({ - type: Schema.Literal("image"), - name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), - mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), - sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), - dataUrl: TrimmedNonEmptyString.check( - Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), - ), -}); -export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; - export const ChatAttachment = Schema.Union([ChatImageAttachment]); export type ChatAttachment = typeof ChatAttachment.Type; -const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); -export type UploadChatAttachment = typeof UploadChatAttachment.Type; export const ProjectScriptIcon = Schema.Literals([ "play", @@ -837,7 +823,10 @@ const ClientThreadTurnStartCommand = Schema.Struct({ messageId: MessageId, role: Schema.Literal("user"), text: Schema.String, - attachments: Schema.Array(UploadChatAttachment), + // Id references to attachments already uploaded via the signed upload + // URL flow (`attachments.createUploadUrl` + HTTP PUT). Bytes never ride + // this command; the Normalizer re-scopes pending ids to the thread. + attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b5bd91cad59c..783ce83d20cb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -18,7 +18,15 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; -import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; +import { + AssetAccessError, + AssetCreateUrlInput, + AssetCreateUrlResult, + AttachmentCreateUploadUrlInput, + AttachmentCreateUploadUrlResult, + AttachmentDeleteInput, + AttachmentUploadSigningKeyError, +} from "./assets.ts"; import { GitActionProgressEvent, VcsSwitchRefInput, @@ -208,6 +216,10 @@ export const WS_METHODS = { filesystemBrowse: "filesystem.browse", assetsCreateUrl: "assets.createUrl", + // Attachment upload methods + attachmentsCreateUploadUrl: "attachments.createUploadUrl", + attachmentsDelete: "attachments.delete", + // VCS methods vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", @@ -655,6 +667,19 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); +export const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { + payload: AttachmentCreateUploadUrlInput, + success: AttachmentCreateUploadUrlResult, + error: Schema.Union([AttachmentUploadSigningKeyError, EnvironmentAuthorizationError]), +}); + +// Delete is a validated no-op for anything it cannot remove, so the only +// failure it can surface is authorization. +export const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { + payload: AttachmentDeleteInput, + error: EnvironmentAuthorizationError, +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -1021,6 +1046,8 @@ export const WsRpcGroup = RpcGroup.make( WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, + WsAttachmentsCreateUploadUrlRpc, + WsAttachmentsDeleteRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc,