authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 01:44:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 01:44:39-07:00
loge9f50c3efcb02a199042364ead292e164274e716
treebcff92529017a600605fce875f00f58a9ab1c426
parent7c6163c67aa122fa7c13466536c012888db102c6
signature Signed by PGP key B5690EEEBB952194

feat(web): upload image attachments before sending (#8048)


37 files changed, 2475 insertions(+), 123 deletions(-)

apps/server/src/assets/AttachmentUpload.test.ts created+128
...@@ -0,0 +1,128 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as TestClock from "effect/testing/TestClock";
10
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as ServerConfig from "../config.ts";
13import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";
14import {
15 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
16 deletePendingAttachment,
17 issueAttachmentUploadUrl,
18 storeAttachmentUpload,
19 validateAttachmentUploadToken,
20} from "./AttachmentUpload.ts";
21
22const testLayer = ServerSecretStore.layer.pipe(
23 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })),
24 Layer.provideMerge(NodeServices.layer),
25);
26
27const uploadInput = {
28 name: "screenshot.png",
29 mimeType: "image/png",
30 sizeBytes: 6,
31} as const;
32
33describe("AttachmentUpload", () => {
34 it.effect("signs the attachment metadata and validates the upload token", () =>
35 Effect.gen(function* () {
36 const issued = yield* issueAttachmentUploadUrl(uploadInput);
37 expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending");
38
39 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
40 expect(yield* validateAttachmentUploadToken(token)).toMatchObject({
41 kind: "attachment-upload",
42 attachmentId: issued.attachmentId,
43 name: "screenshot.png",
44 mimeType: "image/png",
45 sizeBytes: 6,
46 });
47 }).pipe(Effect.provide(testLayer)),
48 );
49
50 it.effect("rejects tampered and malformed upload tokens", () =>
51 Effect.gen(function* () {
52 const issued = yield* issueAttachmentUploadUrl(uploadInput);
53 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
54 const [payload, signature] = token.split(".");
55
56 expect(yield* validateAttachmentUploadToken(`${payload}x.${signature}`)).toBeNull();
57 expect(yield* validateAttachmentUploadToken(`${token}.extra`)).toBeNull();
58 expect(yield* validateAttachmentUploadToken("garbage")).toBeNull();
59 }).pipe(Effect.provide(testLayer)),
60 );
61
62 it.effect("rejects expired upload tokens", () =>
63 Effect.gen(function* () {
64 const issued = yield* issueAttachmentUploadUrl(uploadInput);
65 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
66
67 yield* TestClock.adjust("11 minutes");
68 expect(yield* validateAttachmentUploadToken(token)).toBeNull();
69 }).pipe(Effect.provide(testLayer)),
70 );
71
72 it.effect("removes expired pending uploads while issuing a new upload URL", () =>
73 Effect.gen(function* () {
74 const config = yield* ServerConfig.ServerConfig;
75 const staleId = "pending-00000000-0000-4000-8000-0000000000cc";
76 const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`);
77 NodeFS.writeFileSync(stalePath, Buffer.from("pixels"));
78 NodeFS.utimesSync(stalePath, 0, 0);
79
80 yield* TestClock.adjust("25 hours");
81 yield* issueAttachmentUploadUrl(uploadInput);
82
83 expect(NodeFS.existsSync(stalePath)).toBe(false);
84 }).pipe(Effect.provide(testLayer)),
85 );
86
87 it.effect("stores the expected bytes without leaving temporary files", () =>
88 Effect.gen(function* () {
89 const config = yield* ServerConfig.ServerConfig;
90 const issued = yield* issueAttachmentUploadUrl(uploadInput);
91 const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
92 const claims = yield* validateAttachmentUploadToken(token);
93 if (!claims) {
94 throw new Error("Expected valid upload claims.");
95 }
96
97 expect(yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3]))).toMatchObject({
98 ok: false,
99 status: 400,
100 });
101 expect(yield* storeAttachmentUpload(claims, new Uint8Array(6))).toEqual({ ok: true });
102 expect(
103 NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`)),
104 ).toBe(true);
105 expect(
106 NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")),
107 ).toEqual([]);
108 }).pipe(Effect.provide(testLayer)),
109 );
110
111 it.effect("deletes pending uploads without deleting thread-owned copies", () =>
112 Effect.gen(function* () {
113 const config = yield* ServerConfig.ServerConfig;
114 const uuid = "00000000-0000-4000-8000-0000000000dd";
115 const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`);
116 const claimedPath = NodePath.join(config.attachmentsDir, `thread-1-${uuid}.png`);
117 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
118 NodeFS.writeFileSync(claimedPath, Buffer.from("pixels"));
119
120 yield* deletePendingAttachment(`pending-${uuid}`);
121 yield* deletePendingAttachment(`pending-${uuid}`);
122 yield* deletePendingAttachment(`thread-1-${uuid}`);
123
124 expect(NodeFS.existsSync(pendingPath)).toBe(false);
125 expect(NodeFS.existsSync(claimedPath)).toBe(true);
126 }).pipe(Effect.provide(testLayer)),
127 );
128});
apps/server/src/assets/AttachmentUpload.ts created+214
...@@ -0,0 +1,214 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";
3
4import {
5 ATTACHMENT_UPLOAD_URL_TTL_MS,
6 type AttachmentCreateUploadUrlInput,
7 AttachmentUploadSigningKeyError,
8} from "@t3tools/contracts";
9import * as Clock from "effect/Clock";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";
15
16import {
17 createPendingAttachmentId,
18 parseThreadSegmentFromAttachmentId,
19 PENDING_ATTACHMENT_THREAD_SEGMENT,
20 resolveAttachmentPathById,
21 sweepStalePendingAttachments,
22} from "../attachmentStore.ts";
23import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
24import {
25 base64UrlDecodeUtf8,
26 base64UrlEncode,
27 signPayload,
28 timingSafeEqualBase64Url,
29} from "../auth/utils.ts";
30import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
31import * as ServerConfig from "../config.ts";
32import { inferImageExtension } from "../imageMime.ts";
33
34export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload";
35
36// Asset download tokens share this key, but their signed claim kind is different.
37const SIGNING_SECRET_NAME = "asset-access-signing-key";
38const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000;
39const lastPendingSweepByDirectory = new Map<string, number>();
40
41const AttachmentUploadClaims = Schema.Struct({
42 version: Schema.Literal(1),
43 kind: Schema.Literal("attachment-upload"),
44 attachmentId: Schema.String,
45 name: Schema.String,
46 mimeType: Schema.String,
47 sizeBytes: Schema.Number,
48 expiresAt: Schema.Number,
49});
50export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type;
51
52const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims);
53const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson);
54const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson);
55
56function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null {
57 try {
58 return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload)));
59 } catch {
60 return null;
61 }
62}
63
64const loadSigningSecret = Effect.gen(function* () {
65 const secretStore = yield* ServerSecretStore.ServerSecretStore;
66 return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
67});
68
69export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* (
70 input: AttachmentCreateUploadUrlInput,
71) {
72 const secret = yield* loadSigningSecret.pipe(
73 Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })),
74 );
75 const config = yield* ServerConfig.ServerConfig;
76 const nowMs = yield* Clock.currentTimeMillis;
77 const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir);
78 if (
79 previousSweep === undefined ||
80 nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS
81 ) {
82 lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs);
83 const swept = sweepStalePendingAttachments({
84 attachmentsDir: config.attachmentsDir,
85 nowMs,
86 });
87 if (swept.deleted > 0) {
88 yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted });
89 }
90 }
91
92 const attachmentId = createPendingAttachmentId();
93 const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
94 const encodedPayload = base64UrlEncode(
95 encodeAttachmentUploadClaims({
96 version: 1,
97 kind: "attachment-upload",
98 attachmentId,
99 name: input.name,
100 mimeType: input.mimeType,
101 sizeBytes: input.sizeBytes,
102 expiresAt,
103 }),
104 );
105
106 return {
107 attachmentId,
108 relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`,
109 expiresAt,
110 };
111});
112
113export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* (
114 token: string,
115) {
116 const [encodedPayload, signature, unexpectedSegment] = token.split(".");
117 if (!encodedPayload || !signature || unexpectedSegment) {
118 return null;
119 }
120
121 const secret = yield* loadSigningSecret.pipe(
122 Effect.tapError((cause) =>
123 Effect.logError("Failed to load the attachment upload signing key.", { cause }),
124 ),
125 Effect.orElseSucceed(() => null),
126 );
127 if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) {
128 return null;
129 }
130
131 const claims = decodeClaims(encodedPayload);
132 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) {
133 return null;
134 }
135 return claims;
136});
137
138export type StoreAttachmentUploadResult =
139 | { readonly ok: true }
140 | { readonly ok: false; readonly status: number; readonly detail: string };
141
142export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (
143 claims: AttachmentUploadClaims,
144 bytes: Uint8Array,
145) {
146 if (bytes.byteLength !== claims.sizeBytes) {
147 return {
148 ok: false,
149 status: 400,
150 detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`,
151 } satisfies StoreAttachmentUploadResult;
152 }
153
154 const config = yield* ServerConfig.ServerConfig;
155 const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
156 const relativePath = `${claims.attachmentId}${extension}`;
157 const finalPath = resolveAttachmentRelativePath({
158 attachmentsDir: config.attachmentsDir,
159 relativePath,
160 });
161 const partPath = resolveAttachmentRelativePath({
162 attachmentsDir: config.attachmentsDir,
163 relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`,
164 });
165 if (!finalPath || !partPath) {
166 return { ok: false, status: 500, detail: "Failed to resolve attachment path." };
167 }
168
169 const fileSystem = yield* FileSystem.FileSystem;
170 const path = yield* Path.Path;
171 return yield* Effect.gen(function* () {
172 yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });
173 yield* fileSystem.writeFile(partPath, bytes);
174 yield* fileSystem.rename(partPath, finalPath);
175 return { ok: true } satisfies StoreAttachmentUploadResult;
176 }).pipe(
177 Effect.catch((cause) =>
178 fileSystem.remove(partPath, { force: true }).pipe(
179 Effect.orElseSucceed(() => undefined),
180 Effect.andThen(
181 Effect.logError("Failed to persist attachment upload.", {
182 attachmentId: claims.attachmentId,
183 cause,
184 }),
185 ),
186 Effect.as({
187 ok: false,
188 status: 500,
189 detail: "Failed to persist upload.",
190 } satisfies StoreAttachmentUploadResult),
191 ),
192 ),
193 );
194});
195
196export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* (
197 attachmentId: string,
198) {
199 if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) {
200 return;
201 }
202
203 const config = yield* ServerConfig.ServerConfig;
204 const attachmentPath = resolveAttachmentPathById({
205 attachmentsDir: config.attachmentsDir,
206 attachmentId,
207 });
208 if (!attachmentPath) {
209 return;
210 }
211
212 const fileSystem = yield* FileSystem.FileSystem;
213 yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {}));
214});
apps/server/src/attachmentStore.test.ts+85
...@@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test";...@@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test";
77
8import {8import {
9 createAttachmentId,9 createAttachmentId,
10 createPendingAttachmentId,
11 parseAttachmentUuid,
12 planAttachmentClaim,
10 parseThreadSegmentFromAttachmentId,13 parseThreadSegmentFromAttachmentId,
11 resolveAttachmentPathById,14 resolveAttachmentPathById,
15 sweepStalePendingAttachments,
12} from "./attachmentStore.ts";16} from "./attachmentStore.ts";
1317
14describe("attachmentStore", () => {18describe("attachmentStore", () => {
...@@ -44,6 +48,16 @@ describe("attachmentStore", () => {...@@ -44,6 +48,16 @@ describe("attachmentStore", () => {
44 expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo");48 expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo");
45 });49 });
4650
51 it("reserves the pending attachment segment", () => {
52 const pendingId = createPendingAttachmentId();
53 expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending");
54 expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/);
55 expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending");
56 expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe(
57 "pending_thread",
58 );
59 });
60
47 it("resolves attachment path by id using the extension that exists on disk", () => {61 it("resolves attachment path by id using the extension that exists on disk", () => {
48 const attachmentsDir = NodeFS.mkdtempSync(62 const attachmentsDir = NodeFS.mkdtempSync(
49 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),63 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
...@@ -77,4 +91,75 @@ describe("attachmentStore", () => {...@@ -77,4 +91,75 @@ describe("attachmentStore", () => {
77 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });91 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
78 }92 }
79 });93 });
94
95 it("plans pending attachment claims with direct filename lookups", () => {
96 const attachmentsDir = NodeFS.mkdtempSync(
97 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),
98 );
99 try {
100 const uuid = "00000000-0000-4000-8000-000000000001";
101 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
102 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
103
104 const claim = planAttachmentClaim({
105 attachmentsDir,
106 threadId: "thread-1",
107 attachmentId: `pending-${uuid}`,
108 });
109 expect(claim).toMatchObject({
110 ok: true,
111 currentPath: pendingPath,
112 });
113 if (!claim.ok) {
114 return;
115 }
116 expect(parseThreadSegmentFromAttachmentId(claim.finalId)).toBe("thread-1");
117 expect(parseAttachmentUuid(claim.finalId)).not.toBe(uuid);
118 expect(claim.finalPath).toBe(NodePath.join(attachmentsDir, `${claim.finalId}.png`));
119 } finally {
120 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
121 }
122 });
123
124 it("rejects thread-owned attachments even when thread segments collide", () => {
125 const attachmentsDir = NodeFS.mkdtempSync(
126 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-ownership-"),
127 );
128 try {
129 const attachmentId = "a-b-00000000-0000-4000-8000-000000000003";
130 NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "pixels");
131
132 expect(planAttachmentClaim({ attachmentsDir, threadId: "a b", attachmentId })).toEqual({
133 ok: false,
134 reason: "attachment must be a pending upload",
135 });
136 } finally {
137 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
138 }
139 });
140
141 it("removes expired pending and partial files without touching thread attachments", () => {
142 const attachmentsDir = NodeFS.mkdtempSync(
143 NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"),
144 );
145 try {
146 const now = 1_800_000_000_000;
147 const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;
148 const uuid = "00000000-0000-4000-8000-000000000002";
149 const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
150 const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);
151 const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);
152 for (const filePath of [pendingPath, threadPath, partialPath]) {
153 NodeFS.writeFileSync(filePath, Buffer.from("pixels"));
154 NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);
155 }
156
157 expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 });
158 expect(NodeFS.existsSync(pendingPath)).toBe(false);
159 expect(NodeFS.existsSync(partialPath)).toBe(false);
160 expect(NodeFS.existsSync(threadPath)).toBe(true);
161 } finally {
162 NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
163 }
164 });
80});165});
apps/server/src/attachmentStore.ts+117-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
45
5import type { ChatAttachment } from "@t3tools/contracts";6import type { ChatAttachment } from "@t3tools/contracts";
67
...@@ -19,6 +20,10 @@ const ATTACHMENT_ID_PATTERN = new RegExp(...@@ -19,6 +20,10 @@ const ATTACHMENT_ID_PATTERN = new RegExp(
19 "i",20 "i",
20);21);
2122
23export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending";
24export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
25const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000;
26
22export function toSafeThreadAttachmentSegment(threadId: string): string | null {27export function toSafeThreadAttachmentSegment(threadId: string): string | null {
23 const segment = threadId28 const segment = threadId
24 .trim()29 .trim()
...@@ -31,7 +36,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {...@@ -31,7 +36,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {
31 if (segment.length === 0) {36 if (segment.length === 0) {
32 return null;37 return null;
33 }38 }
34 return segment;39 return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
40}
41
42export function createPendingAttachmentId(): string {
43 return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`;
44}
45
46export function parseAttachmentUuid(attachmentId: string): string | null {
47 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
48 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
49 return null;
50 }
51 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;
35}52}
3653
37export function createAttachmentId(threadId: string): string | null {54export function createAttachmentId(threadId: string): string | null {
...@@ -96,6 +113,105 @@ export function resolveAttachmentPathById(input: {...@@ -96,6 +113,105 @@ export function resolveAttachmentPathById(input: {
96 return null;113 return null;
97}114}
98115
116export type AttachmentClaimPlan =
117 | {
118 readonly ok: true;
119 readonly finalId: string;
120 readonly currentPath: string;
121 readonly finalPath: string;
122 }
123 | { readonly ok: false; readonly reason: string };
124
125export function planAttachmentClaim(input: {
126 readonly attachmentsDir: string;
127 readonly threadId: string;
128 readonly attachmentId: string;
129}): AttachmentClaimPlan {
130 const uuid = parseAttachmentUuid(input.attachmentId);
131 const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId);
132 if (!uuid || !requestedSegment) {
133 return { ok: false, reason: "invalid attachment id" };
134 }
135
136 if (!toSafeThreadAttachmentSegment(input.threadId)) {
137 return { ok: false, reason: "invalid thread id" };
138 }
139 if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) {
140 return { ok: false, reason: "attachment must be a pending upload" };
141 }
142
143 const currentPath = resolveAttachmentPathById({
144 attachmentsDir: input.attachmentsDir,
145 attachmentId: input.attachmentId,
146 });
147 if (!currentPath) {
148 return { ok: false, reason: "attachment not found (removed or expired)" };
149 }
150 const finalId = createAttachmentId(input.threadId);
151 if (!finalId) {
152 return { ok: false, reason: "failed to create attachment id" };
153 }
154
155 const expectedFinalPath = resolveAttachmentRelativePath({
156 attachmentsDir: input.attachmentsDir,
157 relativePath: `${finalId}${NodePath.extname(currentPath)}`,
158 });
159 if (!expectedFinalPath) {
160 return { ok: false, reason: "failed to resolve attachment path" };
161 }
162 return {
163 ok: true,
164 finalId,
165 currentPath,
166 finalPath: expectedFinalPath,
167 };
168}
169
170export function sweepStalePendingAttachments(input: {
171 readonly attachmentsDir: string;
172 readonly nowMs: number;
173}): { readonly deleted: number } {
174 let entries: string[];
175 try {
176 entries = NodeFS.readdirSync(input.attachmentsDir);
177 } catch {
178 return { deleted: 0 };
179 }
180
181 let deleted = 0;
182 for (const entry of entries) {
183 const isPartial = entry.endsWith(".part");
184 if (!isPartial) {
185 const attachmentId = parseAttachmentIdFromRelativePath(entry);
186 if (
187 !attachmentId ||
188 parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT
189 ) {
190 continue;
191 }
192 }
193
194 const resolved = resolveAttachmentRelativePath({
195 attachmentsDir: input.attachmentsDir,
196 relativePath: entry,
197 });
198 if (!resolved) {
199 continue;
200 }
201 try {
202 const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS;
203 if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) {
204 NodeFS.unlinkSync(resolved);
205 deleted += 1;
206 }
207 } catch {
208 continue;
209 }
210 }
211
212 return { deleted };
213}
214
99export function parseAttachmentIdFromRelativePath(relativePath: string): string | null {215export function parseAttachmentIdFromRelativePath(relativePath: string): string | null {
100 const normalized = normalizeAttachmentRelativePath(relativePath);216 const normalized = normalizeAttachmentRelativePath(relativePath);
101 if (!normalized || normalized.includes("/")) {217 if (!normalized || normalized.includes("/")) {
apps/server/src/auth/RpcAuthorization.ts+2
...@@ -84,6 +84,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -84,6 +84,8 @@ export const RPC_REQUIRED_SCOPES = {
84 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,84 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
85 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,85 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
86 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,86 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
87 [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
88 [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
87 [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,89 [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
88 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,90 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
89 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,91 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
apps/server/src/config.ts+11
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7 * @module ServerConfig7 * @module ServerConfig
8 */8 */
9import * as Context from "effect/Context";9import * as Context from "effect/Context";
10import * as Clock from "effect/Clock";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";12import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
...@@ -14,6 +15,8 @@ import * as LogLevel from "effect/LogLevel";...@@ -14,6 +15,8 @@ import * as LogLevel from "effect/LogLevel";
14import * as Path from "effect/Path";15import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
1617
18import { sweepStalePendingAttachments } from "./attachmentStore.ts";
19
17export const DEFAULT_PORT = 3773;20export const DEFAULT_PORT = 3773;
1821
19export const RuntimeMode = Schema.Literals(["web", "desktop"]);22export const RuntimeMode = Schema.Literals(["web", "desktop"]);
...@@ -152,6 +155,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server...@@ -152,6 +155,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
152 ],155 ],
153 { concurrency: "unbounded" },156 { concurrency: "unbounded" },
154 );157 );
158
159 const swept = sweepStalePendingAttachments({
160 attachmentsDir: derivedPaths.attachmentsDir,
161 nowMs: yield* Clock.currentTimeMillis,
162 });
163 if (swept.deleted > 0) {
164 yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted });
165 }
155});166});
156167
157const makeTest = Effect.fn("ServerConfig.makeTest")(function* (168const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -90,6 +90,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -90,6 +90,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
90 expect(first.environmentId).toBe(second.environmentId);90 expect(first.environmentId).toBe(second.environmentId);
91 expect(second.capabilities.repositoryIdentity).toBe(true);91 expect(second.capabilities.repositoryIdentity).toBe(true);
92 expect(second.capabilities.connectionProbe).toBe(true);92 expect(second.capabilities.connectionProbe).toBe(true);
93 expect(second.capabilities.attachmentUploads).toBe(true);
93 expect(second.capabilities.pullRequests).toBe(true);94 expect(second.capabilities.pullRequests).toBe(true);
94 expect(second.capabilities.threadTitleRegeneration).toBe(true);95 expect(second.capabilities.threadTitleRegeneration).toBe(true);
95 expect(second.capabilities.agentActivityPublishing).toBe(false);96 expect(second.capabilities.agentActivityPublishing).toBe(false);
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -146,6 +146,7 @@ export const make = Effect.gen(function* () {...@@ -146,6 +146,7 @@ export const make = Effect.gen(function* () {
146 capabilities: {146 capabilities: {
147 repositoryIdentity: true,147 repositoryIdentity: true,
148 connectionProbe: true,148 connectionProbe: true,
149 attachmentUploads: true,
149 pullRequests: true,150 pullRequests: true,
150 threadSettlement: true,151 threadSettlement: true,
151 threadSnooze: true,152 threadSnooze: true,
apps/server/src/http.ts+50
...@@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability";...@@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability";
2828
29import * as ServerConfig from "./config.ts";29import * as ServerConfig from "./config.ts";
30import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";30import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
31import {
32 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
33 storeAttachmentUpload,
34 validateAttachmentUploadToken,
35} from "./assets/AttachmentUpload.ts";
31import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";36import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
32import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";37import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
33import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";38import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
...@@ -230,6 +235,51 @@ export const assetRouteLayer = HttpRouter.add(...@@ -230,6 +235,51 @@ export const assetRouteLayer = HttpRouter.add(
230 }),235 }),
231);236);
232237
238export const attachmentUploadRouteLayer = HttpRouter.add(
239 "POST",
240 `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`,
241 Effect.gen(function* () {
242 const request = yield* HttpServerRequest.HttpServerRequest;
243 const url = HttpServerRequest.toURL(request);
244 if (Option.isNone(url)) {
245 return HttpServerResponse.text("Bad Request", { status: 400 });
246 }
247
248 const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
249 if (!token) {
250 return HttpServerResponse.text("Not Found", { status: 404 });
251 }
252 const claims = yield* validateAttachmentUploadToken(token);
253 if (!claims) {
254 return HttpServerResponse.text("Not Found", { status: 404 });
255 }
256
257 const contentLengthHeader = request.headers["content-length"];
258 if (
259 contentLengthHeader !== undefined &&
260 (!Number.isInteger(Number(contentLengthHeader)) ||
261 Number(contentLengthHeader) !== claims.sizeBytes)
262 ) {
263 return HttpServerResponse.text("Content-Length must match the upload size.", {
264 status: 400,
265 });
266 }
267
268 const body = yield* request.arrayBuffer.pipe(
269 Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)),
270 Effect.orElseSucceed(() => null),
271 );
272 if (body === null) {
273 return HttpServerResponse.text("Failed to read the upload body.", { status: 400 });
274 }
275
276 const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body));
277 return stored.ok
278 ? HttpServerResponse.empty({ status: 204 })
279 : HttpServerResponse.text(stored.detail, { status: stored.status });
280 }),
281);
282
233export const staticAndDevRouteLayer = HttpRouter.add(283export const staticAndDevRouteLayer = HttpRouter.add(
234 "GET",284 "GET",
235 "*",285 "*",
apps/server/src/orchestration/Normalizer.attachments.test.ts created+318
...@@ -0,0 +1,318 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, it } from "@effect/vitest";
7import {
8 type ClientOrchestrationCommand,
9 CommandId,
10 MessageId,
11 ThreadId,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15
16import * as ServerConfig from "../config.ts";
17import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
18import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts";
19
20const testLayer = Layer.mergeAll(
21 WorkspacePaths.layer,
22 ServerConfig.layerTest(process.cwd(), { prefix: "t3-normalizer-attachments-" }),
23).pipe(Layer.provideMerge(NodeServices.layer));
24
25const attachmentUuid = "00000000-0000-4000-8000-0000000000aa";
26
27function turnStartCommand(input: {
28 readonly threadId?: string;
29 readonly attachments: ReadonlyArray<
30 | { readonly id: string; readonly sizeBytes: number }
31 | { readonly dataUrl: string; readonly sizeBytes: number }
32 >;
33}): ClientOrchestrationCommand {
34 return {
35 type: "thread.turn.start",
36 commandId: CommandId.make("command-1"),
37 threadId: ThreadId.make(input.threadId ?? "thread-1"),
38 message: {
39 messageId: MessageId.make("message-1"),
40 role: "user",
41 text: "look at this",
42 attachments: input.attachments.map((attachment) => ({
43 type: "image" as const,
44 name: "screenshot.png",
45 mimeType: "image/png",
46 ...attachment,
47 })),
48 },
49 runtimeMode: "full-access",
50 interactionMode: "default",
51 createdAt: "2026-08-01T00:00:00.000Z",
52 };
53}
54
55describe("normalizeDispatchCommand attachments", () => {
56 it.effect("preserves inline image attachments from existing mobile clients", () =>
57 Effect.gen(function* () {
58 const config = yield* ServerConfig.ServerConfig;
59 const normalized = yield* normalizeDispatchCommand(
60 turnStartCommand({
61 attachments: [{ dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }],
62 }),
63 );
64 if (normalized.type !== "thread.turn.start") {
65 throw new Error("Expected a thread.turn.start command.");
66 }
67
68 const attachment = normalized.message.attachments[0]!;
69 expect(attachment.id.startsWith("thread-1-")).toBe(true);
70 expect(
71 NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${attachment.id}.png`)),
72 ).toEqual(Buffer.from("pixels"));
73 }).pipe(Effect.provide(testLayer)),
74 );
75
76 it.effect("claims uploaded attachments while retaining a retryable pending copy", () =>
77 Effect.gen(function* () {
78 const config = yield* ServerConfig.ServerConfig;
79 const bytes = Buffer.from("pixels");
80 const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`);
81 NodeFS.writeFileSync(pendingPath, bytes);
82
83 const normalized = yield* normalizeDispatchCommand(
84 turnStartCommand({
85 attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }],
86 }),
87 );
88 if (normalized.type !== "thread.turn.start") {
89 throw new Error("Expected a thread.turn.start command.");
90 }
91
92 const attachmentId = normalized.message.attachments[0]!.id;
93 expect(attachmentId.startsWith("thread-1-")).toBe(true);
94 expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`);
95 expect(NodeFS.existsSync(pendingPath)).toBe(true);
96 expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe(
97 true,
98 );
99 }).pipe(Effect.provide(testLayer)),
100 );
101
102 it.effect("normalizes inline and uploaded attachments in the same turn", () =>
103 Effect.gen(function* () {
104 const config = yield* ServerConfig.ServerConfig;
105 NodeFS.writeFileSync(
106 NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`),
107 Buffer.from("pixels"),
108 );
109
110 const normalized = yield* normalizeDispatchCommand(
111 turnStartCommand({
112 attachments: [
113 { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 },
114 { id: `pending-${attachmentUuid}`, sizeBytes: 6 },
115 ],
116 }),
117 );
118 if (normalized.type !== "thread.turn.start") {
119 throw new Error("Expected a thread.turn.start command.");
120 }
121
122 expect(normalized.message.attachments).toHaveLength(2);
123 expect(normalized.message.attachments[1]?.id.startsWith("thread-1-")).toBe(true);
124 }).pipe(Effect.provide(testLayer)),
125 );
126
127 it.effect("retries a failed bootstrap with a fresh thread id", () =>
128 Effect.gen(function* () {
129 const config = yield* ServerConfig.ServerConfig;
130 const bytes = Buffer.from("pixels");
131 NodeFS.writeFileSync(
132 NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`),
133 bytes,
134 );
135
136 const first = yield* normalizeDispatchCommand(
137 turnStartCommand({
138 attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }],
139 }),
140 );
141 if (first.type !== "thread.turn.start") {
142 throw new Error("Expected a thread.turn.start command.");
143 }
144 NodeFS.rmSync(
145 NodePath.join(config.attachmentsDir, `${first.message.attachments[0]!.id}.png`),
146 );
147
148 const retried = yield* normalizeDispatchCommand(
149 turnStartCommand({
150 threadId: "thread-retry",
151 attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }],
152 }),
153 );
154 if (retried.type !== "thread.turn.start") {
155 throw new Error("Expected a thread.turn.start command.");
156 }
157 expect(retried.message.attachments[0]?.id.startsWith("thread-retry-")).toBe(true);
158 }).pipe(Effect.provide(testLayer)),
159 );
160
161 it.effect("removes failed attachment claims without deleting their pending uploads", () =>
162 Effect.gen(function* () {
163 const config = yield* ServerConfig.ServerConfig;
164 const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`);
165 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
166 const command = turnStartCommand({
167 attachments: [
168 { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 },
169 { id: `pending-${attachmentUuid}`, sizeBytes: 6 },
170 ],
171 });
172 const normalized = yield* normalizeDispatchCommand(command);
173 if (normalized.type !== "thread.turn.start") {
174 throw new Error("Expected a thread.turn.start command.");
175 }
176
177 const inlinePath = NodePath.join(
178 config.attachmentsDir,
179 `${normalized.message.attachments[0]!.id}.png`,
180 );
181 const claimedPath = NodePath.join(
182 config.attachmentsDir,
183 `${normalized.message.attachments[1]!.id}.png`,
184 );
185 yield* cleanupFailedUploadedAttachments(command, normalized);
186
187 expect(NodeFS.existsSync(pendingPath)).toBe(true);
188 expect(NodeFS.existsSync(claimedPath)).toBe(false);
189 expect(NodeFS.existsSync(inlinePath)).toBe(true);
190 }).pipe(Effect.provide(testLayer)),
191 );
192
193 it.effect("removes a failed claimed copy after its pending original was removed", () =>
194 Effect.gen(function* () {
195 const config = yield* ServerConfig.ServerConfig;
196 const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`);
197 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
198 const command = turnStartCommand({
199 attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }],
200 });
201 const normalized = yield* normalizeDispatchCommand(command);
202 if (normalized.type !== "thread.turn.start") {
203 throw new Error("Expected a thread.turn.start command.");
204 }
205
206 const claimedPath = NodePath.join(
207 config.attachmentsDir,
208 `${normalized.message.attachments[0]!.id}.png`,
209 );
210 NodeFS.rmSync(pendingPath);
211
212 yield* cleanupFailedUploadedAttachments(command, normalized);
213
214 expect(NodeFS.existsSync(claimedPath)).toBe(false);
215 }).pipe(Effect.provide(testLayer)),
216 );
217
218 it.effect("keeps concurrent claims independent when one dispatch fails", () =>
219 Effect.gen(function* () {
220 const config = yield* ServerConfig.ServerConfig;
221 const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`);
222 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
223 const command = turnStartCommand({
224 attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }],
225 });
226
227 const [failed, succeeded] = yield* Effect.all(
228 [normalizeDispatchCommand(command), normalizeDispatchCommand(command)],
229 { concurrency: 2 },
230 );
231 if (failed.type !== "thread.turn.start" || succeeded.type !== "thread.turn.start") {
232 throw new Error("Expected thread.turn.start commands.");
233 }
234
235 const failedPath = NodePath.join(
236 config.attachmentsDir,
237 `${failed.message.attachments[0]!.id}.png`,
238 );
239 const succeededPath = NodePath.join(
240 config.attachmentsDir,
241 `${succeeded.message.attachments[0]!.id}.png`,
242 );
243 expect(failedPath).not.toBe(succeededPath);
244
245 yield* cleanupFailedUploadedAttachments(command, failed);
246
247 expect(NodeFS.existsSync(pendingPath)).toBe(true);
248 expect(NodeFS.existsSync(failedPath)).toBe(false);
249 expect(NodeFS.existsSync(succeededPath)).toBe(true);
250 }).pipe(Effect.provide(testLayer)),
251 );
252
253 it.effect("removes earlier claimed copies when a later attachment cannot be normalized", () =>
254 Effect.gen(function* () {
255 const config = yield* ServerConfig.ServerConfig;
256 const pendingId = `pending-${attachmentUuid}`;
257 const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.png`);
258 NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
259
260 const failure = yield* normalizeDispatchCommand(
261 turnStartCommand({
262 attachments: [
263 { id: pendingId, sizeBytes: 6 },
264 {
265 id: "pending-00000000-0000-4000-8000-0000000000ff",
266 sizeBytes: 6,
267 },
268 ],
269 }),
270 ).pipe(Effect.flip);
271
272 expect(failure.message).toContain("not found");
273 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${pendingId}.png`]);
274 }).pipe(Effect.provide(testLayer)),
275 );
276
277 it.effect("rejects uploaded attachments with the wrong size or thread", () =>
278 Effect.gen(function* () {
279 const config = yield* ServerConfig.ServerConfig;
280 NodeFS.writeFileSync(
281 NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`),
282 Buffer.from("pixels"),
283 );
284
285 const wrongSize = yield* normalizeDispatchCommand(
286 turnStartCommand({
287 attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 999 }],
288 }),
289 ).pipe(Effect.flip);
290 expect(wrongSize.message).toContain("size");
291
292 const wrongThread = yield* normalizeDispatchCommand(
293 turnStartCommand({
294 attachments: [{ id: `another-thread-${attachmentUuid}`, sizeBytes: 6 }],
295 }),
296 ).pipe(Effect.flip);
297 expect(wrongThread.message).toContain("pending upload");
298
299 const mismatchedTypeCommand = turnStartCommand({
300 attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }],
301 });
302 if (mismatchedTypeCommand.type !== "thread.turn.start") {
303 throw new Error("Expected a thread.turn.start command.");
304 }
305 const mismatchedType = yield* normalizeDispatchCommand({
306 ...mismatchedTypeCommand,
307 message: {
308 ...mismatchedTypeCommand.message,
309 attachments: mismatchedTypeCommand.message.attachments.map((attachment) => ({
310 ...attachment,
311 mimeType: "image/jpeg",
312 })),
313 },
314 }).pipe(Effect.flip);
315 expect(mismatchedType.message).toContain("image type");
316 }).pipe(Effect.provide(testLayer)),
317 );
318});
apps/server/src/orchestration/Normalizer.ts+120-2
...@@ -10,7 +10,13 @@ import {...@@ -10,7 +10,13 @@ import {
10 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,10 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
1212
13import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";13import {
14 createAttachmentId,
15 planAttachmentClaim,
16 PENDING_ATTACHMENT_THREAD_SEGMENT,
17 parseThreadSegmentFromAttachmentId,
18 resolveAttachmentPath,
19} from "../attachmentStore.ts";
14import { ServerConfig } from "../config.ts";20import { ServerConfig } from "../config.ts";
15import { parseBase64DataUrl } from "../imageMime.ts";21import { parseBase64DataUrl } from "../imageMime.ts";
16import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
...@@ -43,6 +49,29 @@ export const canonicalizeClientCommandTimestamps = (...@@ -43,6 +49,29 @@ export const canonicalizeClientCommandTimestamps = (
43 };49 };
44};50};
4551
52const removeClaimedAttachmentPaths = Effect.fn("Normalizer.removeClaimedAttachmentPaths")(
53 function* (attachmentPaths: ReadonlyArray<string>) {
54 if (attachmentPaths.length === 0) {
55 return;
56 }
57 const fileSystem = yield* FileSystem.FileSystem;
58 yield* Effect.forEach(
59 attachmentPaths,
60 (attachmentPath) =>
61 fileSystem.remove(attachmentPath, { force: true }).pipe(
62 Effect.tapError((cause) =>
63 Effect.logWarning("Failed to remove an unclaimed attachment copy.", {
64 attachmentPath,
65 cause,
66 }),
67 ),
68 Effect.orElseSucceed(() => undefined),
69 ),
70 { concurrency: 1 },
71 );
72 },
73);
74
46export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>75export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
47 Effect.gen(function* () {76 Effect.gen(function* () {
48 const receivedAt = DateTime.formatIso(yield* DateTime.now);77 const receivedAt = DateTime.formatIso(yield* DateTime.now);
...@@ -104,10 +133,69 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -104,10 +133,69 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
104 return canonicalCommand as OrchestrationCommand;133 return canonicalCommand as OrchestrationCommand;
105 }134 }
106135
136 const claimedAttachmentPaths: string[] = [];
107 const normalizedAttachments = yield* Effect.forEach(137 const normalizedAttachments = yield* Effect.forEach(
108 canonicalCommand.message.attachments,138 canonicalCommand.message.attachments,
109 (attachment) =>139 (attachment) =>
110 Effect.gen(function* () {140 Effect.gen(function* () {
141 if (!("dataUrl" in attachment)) {
142 const claim = planAttachmentClaim({
143 attachmentsDir: serverConfig.attachmentsDir,
144 threadId: canonicalCommand.threadId,
145 attachmentId: attachment.id,
146 });
147 if (!claim.ok) {
148 return yield* new OrchestrationDispatchCommandError({
149 message: `Attachment '${attachment.name}' cannot be sent: ${claim.reason}.`,
150 });
151 }
152
153 const info = yield* fileSystem.stat(claim.currentPath).pipe(
154 Effect.mapError(
155 (cause) =>
156 new OrchestrationDispatchCommandError({
157 message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`,
158 cause,
159 }),
160 ),
161 );
162 if (Number(info.size) !== attachment.sizeBytes) {
163 return yield* new OrchestrationDispatchCommandError({
164 message: `Attachment '${attachment.name}' cannot be sent: stored size does not match.`,
165 });
166 }
167
168 const normalizedAttachment = {
169 ...attachment,
170 id: claim.finalId,
171 mimeType: attachment.mimeType.toLowerCase(),
172 };
173 const expectedPath = resolveAttachmentPath({
174 attachmentsDir: serverConfig.attachmentsDir,
175 attachment: normalizedAttachment,
176 });
177 if (expectedPath !== claim.finalPath) {
178 return yield* new OrchestrationDispatchCommandError({
179 message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`,
180 });
181 }
182
183 // Keep the pending copy until the turn succeeds. A failed thread
184 // bootstrap can then retry with a fresh thread id.
185 yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe(
186 Effect.mapError(
187 (cause) =>
188 new OrchestrationDispatchCommandError({
189 message: `Failed to claim attachment '${attachment.name}' for this thread.`,
190 cause,
191 }),
192 ),
193 );
194 claimedAttachmentPaths.push(claim.finalPath);
195
196 return normalizedAttachment;
197 }
198
111 const parsed = parseBase64DataUrl(attachment.dataUrl);199 const parsed = parseBase64DataUrl(attachment.dataUrl);
112 if (!parsed || !parsed.mimeType.startsWith("image/")) {200 if (!parsed || !parsed.mimeType.startsWith("image/")) {
113 return yield* new OrchestrationDispatchCommandError({201 return yield* new OrchestrationDispatchCommandError({
...@@ -167,7 +255,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -167,7 +255,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
167 return persistedAttachment;255 return persistedAttachment;
168 }),256 }),
169 { concurrency: 1 },257 { concurrency: 1 },
170 );258 ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths)));
171259
172 return {260 return {
173 ...canonicalCommand,261 ...canonicalCommand,
...@@ -177,3 +265,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -177,3 +265,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
177 },265 },
178 } satisfies OrchestrationCommand;266 } satisfies OrchestrationCommand;
179 });267 });
268
269export const cleanupFailedUploadedAttachments = Effect.fn(
270 "Normalizer.cleanupFailedUploadedAttachments",
271)(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) {
272 if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") {
273 return;
274 }
275
276 const serverConfig = yield* ServerConfig;
277 const claimedPaths: string[] = [];
278 for (const [index, attachment] of normalizedCommand.message.attachments.entries()) {
279 const original = command.message.attachments[index];
280 if (
281 !original ||
282 "dataUrl" in original ||
283 parseThreadSegmentFromAttachmentId(original.id) !== PENDING_ATTACHMENT_THREAD_SEGMENT
284 ) {
285 continue;
286 }
287
288 const claimedPath = resolveAttachmentPath({
289 attachmentsDir: serverConfig.attachmentsDir,
290 attachment,
291 });
292 if (claimedPath) {
293 claimedPaths.push(claimedPath);
294 }
295 }
296 yield* removeClaimedAttachmentPaths(claimedPaths);
297});
apps/server/src/orchestration/http.ts+9-8
...@@ -8,7 +8,7 @@ import * as Option from "effect/Option";...@@ -8,7 +8,7 @@ import * as Option from "effect/Option";
8import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";8import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
99
10import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts";10import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts";
11import { normalizeDispatchCommand } from "./Normalizer.ts";11import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts";
12import {12import {
13 annotateEnvironmentRequest,13 annotateEnvironmentRequest,
14 failEnvironmentInternal,14 failEnvironmentInternal,
...@@ -96,13 +96,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(...@@ -96,13 +96,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
96 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(96 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(
97 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),97 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),
98 );98 );
99 return yield* orchestrationEngine99 return yield* orchestrationEngine.dispatch(normalizedCommand).pipe(
100 .dispatch(normalizedCommand)100 Effect.tapError(() =>
101 .pipe(101 cleanupFailedUploadedAttachments(args.payload, normalizedCommand),
102 Effect.catch((cause) =>102 ),
103 failEnvironmentInternal("orchestration_dispatch_failed", cause),103 Effect.catch((cause) =>
104 ),104 failEnvironmentInternal("orchestration_dispatch_failed", cause),
105 );105 ),
106 );
106 }),107 }),
107 );108 );
108 }),109 }),
apps/server/src/server.test.ts+111-31
...@@ -4486,6 +4486,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4486,6 +4486,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4486 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4486 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4487 );4487 );
44884488
4489 it.effect("uploads image bytes through a signed URL issued by websocket rpc", () =>
4490 Effect.gen(function* () {
4491 const config = yield* buildAppUnderTest();
4492 const fileSystem = yield* FileSystem.FileSystem;
4493 const path = yield* Path.Path;
4494 const wsUrl = yield* getWsServerUrl("/ws");
4495
4496 yield* Effect.scoped(
4497 withWsRpcClient(wsUrl, (client) =>
4498 Effect.gen(function* () {
4499 const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({
4500 name: "screenshot.png",
4501 mimeType: "image/png",
4502 sizeBytes: 6,
4503 });
4504 const rejected = yield* HttpClient.post(issued.relativeUrl, {
4505 body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "image/png"),
4506 });
4507 assert.equal(rejected.status, 400);
4508
4509 const response = yield* HttpClient.post(issued.relativeUrl, {
4510 headers: { origin: crossOriginClientOrigin },
4511 body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"),
4512 });
4513 assert.equal(response.status, 204);
4514 assertBrowserApiCorsResponseHeaders(response.headers);
4515
4516 const attachmentPath = path.join(config.attachmentsDir, `${issued.attachmentId}.png`);
4517 assert.isTrue(yield* fileSystem.exists(attachmentPath));
4518
4519 yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: issued.attachmentId });
4520 assert.isFalse(yield* fileSystem.exists(attachmentPath));
4521
4522 const streamed = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({
4523 name: "streamed.png",
4524 mimeType: "image/png",
4525 sizeBytes: 6,
4526 });
4527 const streamedResponse = yield* HttpClient.post(streamed.relativeUrl, {
4528 body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6])), "image/png"),
4529 });
4530 assert.equal(streamedResponse.status, 204);
4531 yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId });
4532 }),
4533 ),
4534 );
4535 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4536 );
4537
4489 it.effect("keeps feedback errors structured across websocket rpc", () =>4538 it.effect("keeps feedback errors structured across websocket rpc", () =>
4490 Effect.gen(function* () {4539 Effect.gen(function* () {
4491 const threadId = ThreadId.make("thread-feedback-failure");4540 const threadId = ThreadId.make("thread-feedback-failure");
...@@ -7993,12 +8042,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -7993,12 +8042,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
7993 it.effect("cleans up created bootstrap threads when worktree creation defects", () =>8042 it.effect("cleans up created bootstrap threads when worktree creation defects", () =>
7994 Effect.gen(function* () {8043 Effect.gen(function* () {
7995 const dispatchedCommands: Array<OrchestrationCommand> = [];8044 const dispatchedCommands: Array<OrchestrationCommand> = [];
8045 const fileSystem = yield* FileSystem.FileSystem;
8046 const path = yield* Path.Path;
7996 const createWorktree = vi.fn(8047 const createWorktree = vi.fn(
7997 (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>8048 (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>
7998 Effect.die(new Error("worktree exploded")),8049 Effect.die(new Error("worktree exploded")),
7999 );8050 );
80008051
8001 yield* buildAppUnderTest({8052 const config = yield* buildAppUnderTest({
8002 layers: {8053 layers: {
8003 gitVcsDriver: {8054 gitVcsDriver: {
8004 createWorktree,8055 createWorktree,
...@@ -8016,40 +8067,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -8016,40 +8067,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
80168067
8017 const createdAt = "2026-01-01T00:00:00.000Z";8068 const createdAt = "2026-01-01T00:00:00.000Z";
8018 const wsUrl = yield* getWsServerUrl("/ws");8069 const wsUrl = yield* getWsServerUrl("/ws");
8070 let pendingAttachmentId: string | undefined;
8019 const result = yield* Effect.scoped(8071 const result = yield* Effect.scoped(
8020 withWsRpcClient(wsUrl, (client) =>8072 withWsRpcClient(wsUrl, (client) =>
8021 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({8073 Effect.gen(function* () {
8022 type: "thread.turn.start",8074 const upload = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({
8023 commandId: CommandId.make("cmd-bootstrap-turn-start-defect"),8075 name: "screenshot.png",
8024 threadId: ThreadId.make("thread-bootstrap-defect"),8076 mimeType: "image/png",
8025 message: {8077 sizeBytes: 6,
8026 messageId: MessageId.make("msg-bootstrap-defect"),8078 });
8027 role: "user",8079 pendingAttachmentId = upload.attachmentId;
8028 text: "hello",8080 const uploadResponse = yield* HttpClient.post(upload.relativeUrl, {
8029 attachments: [],8081 body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"),
8030 },8082 });
8031 modelSelection: defaultModelSelection,8083 assert.equal(uploadResponse.status, 204);
8032 runtimeMode: "full-access",8084
8033 interactionMode: "default",8085 return yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
8034 bootstrap: {8086 type: "thread.turn.start",
8035 createThread: {8087 commandId: CommandId.make("cmd-bootstrap-turn-start-defect"),
8036 projectId: defaultProjectId,8088 threadId: ThreadId.make("thread-bootstrap-defect"),
8037 title: "Bootstrap Thread",8089 message: {
8038 modelSelection: defaultModelSelection,8090 messageId: MessageId.make("msg-bootstrap-defect"),
8039 runtimeMode: "full-access",8091 role: "user",
8040 interactionMode: "default",8092 text: "hello",
8041 branch: "main",8093 attachments: [
8042 worktreePath: null,8094 {
8043 createdAt,8095 type: "image",
8096 id: upload.attachmentId,
8097 name: "screenshot.png",
8098 mimeType: "image/png",
8099 sizeBytes: 6,
8100 },
8101 ],
8044 },8102 },
8045 prepareWorktree: {8103 modelSelection: defaultModelSelection,
8046 projectCwd: "/tmp/project",8104 runtimeMode: "full-access",
8047 baseBranch: "main",8105 interactionMode: "default",
8048 branch: "t3code/bootstrap-refName",8106 bootstrap: {
8107 createThread: {
8108 projectId: defaultProjectId,
8109 title: "Bootstrap Thread",
8110 modelSelection: defaultModelSelection,
8111 runtimeMode: "full-access",
8112 interactionMode: "default",
8113 branch: "main",
8114 worktreePath: null,
8115 createdAt,
8116 },
8117 prepareWorktree: {
8118 projectCwd: "/tmp/project",
8119 baseBranch: "main",
8120 branch: "t3code/bootstrap-refName",
8121 },
8122 runSetupScript: false,
8049 },8123 },
8050 runSetupScript: false,8124 createdAt,
8051 },8125 });
8052 createdAt,
8053 }),8126 }),
8054 ).pipe(Effect.result),8127 ).pipe(Effect.result),
8055 );8128 );
...@@ -8062,6 +8135,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -8062,6 +8135,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8062 dispatchedCommands.map((command) => command.type),8135 dispatchedCommands.map((command) => command.type),
8063 ["thread.create", "thread.delete"],8136 ["thread.create", "thread.delete"],
8064 );8137 );
8138 assert.isDefined(pendingAttachmentId);
8139 assert.isTrue(
8140 yield* fileSystem.exists(path.join(config.attachmentsDir, `${pendingAttachmentId}.png`)),
8141 );
8142 assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), [
8143 `${pendingAttachmentId}.png`,
8144 ]);
8065 }).pipe(Effect.provide(NodeHttpServer.layerTest)),8145 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8066 );8146 );
80678147
apps/server/src/server.ts+2
...@@ -13,6 +13,7 @@ import * as ServerConfig from "./config.ts";...@@ -13,6 +13,7 @@ import * as ServerConfig from "./config.ts";
13import {13import {
14 otlpTracesProxyRouteLayer,14 otlpTracesProxyRouteLayer,
15 assetRouteLayer,15 assetRouteLayer,
16 attachmentUploadRouteLayer,
16 serverEnvironmentHttpApiLayer,17 serverEnvironmentHttpApiLayer,
17 staticAndDevRouteLayer,18 staticAndDevRouteLayer,
18 browserApiCorsLayer,19 browserApiCorsLayer,
...@@ -456,6 +457,7 @@ export const makeRoutesLayer = Layer.mergeAll(...@@ -456,6 +457,7 @@ export const makeRoutesLayer = Layer.mergeAll(
456 ),457 ),
457 otlpTracesProxyRouteLayer,458 otlpTracesProxyRouteLayer,
458 assetRouteLayer,459 assetRouteLayer,
460 attachmentUploadRouteLayer,
459 staticAndDevRouteLayer,461 staticAndDevRouteLayer,
460 websocketRpcRouteLayer,462 websocketRpcRouteLayer,
461 ),463 ),
apps/server/src/ws.ts+18-2
...@@ -73,7 +73,10 @@ import {...@@ -73,7 +73,10 @@ import {
73 projectActivityEvent,73 projectActivityEvent,
74 projectThreadDetailSnapshot,74 projectThreadDetailSnapshot,
75} from "./orchestration/ActivityPayloadProjection.ts";75} from "./orchestration/ActivityPayloadProjection.ts";
76import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";76import {
77 cleanupFailedUploadedAttachments,
78 normalizeDispatchCommand,
79} from "./orchestration/Normalizer.ts";
77import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";80import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
78import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";81import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
79import {82import {
...@@ -92,6 +95,7 @@ import * as TerminalManager from "./terminal/Manager.ts";...@@ -92,6 +95,7 @@ import * as TerminalManager from "./terminal/Manager.ts";
92import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";95import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
93import * as PreviewManager from "./preview/Manager.ts";96import * as PreviewManager from "./preview/Manager.ts";
94import { issueAssetUrl } from "./assets/AssetAccess.ts";97import { issueAssetUrl } from "./assets/AssetAccess.ts";
98import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
95import * as PortScanner from "./preview/PortScanner.ts";99import * as PortScanner from "./preview/PortScanner.ts";
96import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";100import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
97import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";101import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
...@@ -1159,7 +1163,9 @@ const makeWsRpcLayer = (...@@ -1159,7 +1163,9 @@ const makeWsRpcLayer = (
1159 ),1163 ),
1160 )1164 )
1161 : false;1165 : false;
1162 const result = yield* dispatchNormalizedCommand(normalizedCommand);1166 const result = yield* dispatchNormalizedCommand(normalizedCommand).pipe(
1167 Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)),
1168 );
1163 yield* recordClientCommandAnalytics(normalizedCommand);1169 yield* recordClientCommandAnalytics(normalizedCommand);
1164 if (parkingCommand) {1170 if (parkingCommand) {
1165 const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle";1171 const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle";
...@@ -1951,6 +1957,16 @@ const makeWsRpcLayer = (...@@ -1951,6 +1957,16 @@ const makeWsRpcLayer = (
1951 ),1957 ),
1952 { "rpc.aggregate": "workspace" },1958 { "rpc.aggregate": "workspace" },
1953 ),1959 ),
1960 [WS_METHODS.attachmentsCreateUploadUrl]: (input) =>
1961 observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), {
1962 "rpc.aggregate": "workspace",
1963 }),
1964 [WS_METHODS.attachmentsDelete]: (input) =>
1965 observeRpcEffect(
1966 WS_METHODS.attachmentsDelete,
1967 deletePendingAttachment(input.attachmentId),
1968 { "rpc.aggregate": "workspace" },
1969 ),
1954 [WS_METHODS.assetsCreateUrl]: (input) =>1970 [WS_METHODS.assetsCreateUrl]: (input) =>
1955 observeRpcEffect(1971 observeRpcEffect(
1956 WS_METHODS.assetsCreateUrl,1972 WS_METHODS.assetsCreateUrl,
apps/web/src/components/ChatView.tsx+44-8
...@@ -352,6 +352,12 @@ import {...@@ -352,6 +352,12 @@ import {
352import type { ThreadSyncPhase } from "../threadSync";352import type { ThreadSyncPhase } from "../threadSync";
353import { useLocalStorage } from "~/hooks/useLocalStorage";353import { useLocalStorage } from "~/hooks/useLocalStorage";
354import { useComposerHandleContext } from "../composerHandleContext";354import { useComposerHandleContext } from "../composerHandleContext";
355import {
356 awaitAttachmentUploads,
357 getUploadedAttachments,
358 releaseAttachmentUploads,
359 startAttachmentUpload,
360} from "../lib/attachmentUploadQueue";
355import { sanitizeThreadErrorMessage } from "~/rpc/transportError";361import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
356import { RightPanelSheet } from "./RightPanelSheet";362import { RightPanelSheet } from "./RightPanelSheet";
357import { previewEnvironment } from "../state/preview";363import { previewEnvironment } from "../state/preview";
...@@ -2094,6 +2100,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2094,6 +2100,10 @@ function ChatViewContent(props: ChatViewProps) {
2094 : (primaryEnvironment?.serverConfig ?? null);2100 : (primaryEnvironment?.serverConfig ?? null);
2095 const pullRequestsCapabilityKnown = serverConfig !== null;2101 const pullRequestsCapabilityKnown = serverConfig !== null;
2096 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;2102 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
2103 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;
2104 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
2105 const supportsAttachmentUploads =
2106 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
2097 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);2107 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
2098 const versionMismatchDismissKey =2108 const versionMismatchDismissKey =
2099 versionMismatch && activeThread2109 versionMismatch && activeThread
...@@ -5406,9 +5416,21 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5406,9 +5416,21 @@ function ChatViewContent(props: ChatViewProps) {
5406 return;5416 return;
5407 }5417 }
54085418
5419 sendInFlightRef.current = true;
5420 if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) {
5421 for (const image of composerImagesSnapshot) {
5422 startAttachmentUpload({ environmentId, image });
5423 }
5424 await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id));
5425 if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) {
5426 sendInFlightRef.current = false;
5427 setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending.");
5428 return;
5429 }
5430 }
5431
5409 const resolvedSubmissionIntent =5432 const resolvedSubmissionIntent =
5410 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";5433 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";
5411 sendInFlightRef.current = true;
5412 if (5434 if (
5413 shouldDockDraftHeroForSubmission({5435 shouldDockDraftHeroForSubmission({
5414 isDraftHeroState,5436 isDraftHeroState,
...@@ -5439,13 +5461,22 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5439,13 +5461,22 @@ function ChatViewContent(props: ChatViewProps) {
5439 const messageIdForSend = newMessageId();5461 const messageIdForSend = newMessageId();
5440 const messageCreatedAt = new Date().toISOString();5462 const messageCreatedAt = new Date().toISOString();
5441 const turnAttachmentsPromise = Promise.all(5463 const turnAttachmentsPromise = Promise.all(
5442 composerImagesSnapshot.map(async (image) => ({5464 composerImagesSnapshot.map(async (image) => {
5443 type: "image" as const,5465 if (supportsAttachmentUploads) {
5444 name: image.name,5466 const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0];
5445 mimeType: image.mimeType,5467 if (!uploaded) {
5446 sizeBytes: image.sizeBytes,5468 throw new Error(`Image '${image.name}' did not finish uploading.`);
5447 dataUrl: await readFileAsDataUrl(image.file),5469 }
5448 })),5470 return uploaded;
5471 }
5472 return {
5473 type: "image" as const,
5474 name: image.name,
5475 mimeType: image.mimeType,
5476 sizeBytes: image.sizeBytes,
5477 dataUrl: await readFileAsDataUrl(image.file),
5478 };
5479 }),
5449 );5480 );
5450 const optimisticAttachments = composerImagesSnapshot.map((image) => ({5481 const optimisticAttachments = composerImagesSnapshot.map((image) => ({
5451 type: "image" as const,5482 type: "image" as const,
...@@ -5632,6 +5663,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5632,6 +5663,9 @@ function ChatViewContent(props: ChatViewProps) {
5632 failure = startResult;5663 failure = startResult;
5633 } else {5664 } else {
5634 turnStartSucceeded = true;5665 turnStartSucceeded = true;
5666 if (supportsAttachmentUploads) {
5667 releaseAttachmentUploads(composerImagesSnapshot);
5668 }
5635 acknowledgeActiveThreadWoke();5669 acknowledgeActiveThreadWoke();
5636 if (backgroundThreadRef) {5670 if (backgroundThreadRef) {
5637 markPromotedDraftThreadByRef(backgroundThreadRef);5671 markPromotedDraftThreadByRef(backgroundThreadRef);
...@@ -6774,6 +6808,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6774,6 +6808,8 @@ function ChatViewContent(props: ChatViewProps) {
6774 composerRef={composerRef}6808 composerRef={composerRef}
6775 composerDraftTarget={composerDraftTarget}6809 composerDraftTarget={composerDraftTarget}
6776 environmentId={environmentId}6810 environmentId={environmentId}
6811 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
6812 supportsAttachmentUploads={supportsAttachmentUploads}
6777 routeKind={routeKind}6813 routeKind={routeKind}
6778 routeThreadRef={routeThreadRef}6814 routeThreadRef={routeThreadRef}
6779 draftId={draftId}6815 draftId={draftId}
apps/web/src/components/LegacySidebar.tsx+2
...@@ -75,6 +75,7 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra...@@ -75,6 +75,7 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra
75import { isElectron } from "../env";75import { isElectron } from "../env";
76import { useTerminalFocus } from "../hooks/useTerminalFocus";76import { useTerminalFocus } from "../hooks/useTerminalFocus";
77import { useOpenPrLink } from "../lib/openPullRequestLink";77import { useOpenPrLink } from "../lib/openPullRequestLink";
78import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";
78import { isTerminalFocused } from "../lib/terminalFocus";79import { isTerminalFocused } from "../lib/terminalFocus";
79import { isMacPlatform } from "../lib/utils";80import { isMacPlatform } from "../lib/utils";
80import {81import {
...@@ -1462,6 +1463,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1462,6 +1463,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1462 return result;1463 return result;
1463 }1464 }
1464 const draftStore = useComposerDraftStore.getState();1465 const draftStore = useComposerDraftStore.getState();
1466 releaseProjectDraftUploads(memberProjectRef);
1465 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);1467 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);
1466 if (projectDraftThread) {1468 if (projectDraftThread) {
1467 draftStore.clearDraftThread(projectDraftThread.draftId);1469 draftStore.clearDraftThread(projectDraftThread.draftId);
apps/web/src/components/Sidebar.tsx+2
...@@ -89,6 +89,7 @@ import { isModelPickerOpen } from "../modelPickerVisibility";...@@ -89,6 +89,7 @@ import { isModelPickerOpen } from "../modelPickerVisibility";
89import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";89import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
90import { isMacPlatform } from "~/lib/utils";90import { isMacPlatform } from "~/lib/utils";
91import { useOpenPrLink } from "../lib/openPullRequestLink";91import { useOpenPrLink } from "../lib/openPullRequestLink";
92import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
92import { readLocalApi } from "../localApi";93import { readLocalApi } from "../localApi";
93import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";94import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
94import {95import {
...@@ -654,6 +655,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -654,6 +655,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
654 // The /draft/$draftId route redirects home on its own when the draft655 // The /draft/$draftId route redirects home on its own when the draft
655 // it renders disappears, so discarding the open draft needs no656 // it renders disappears, so discarding the open draft needs no
656 // special-casing here.657 // special-casing here.
658 releaseComposerDraftUploads(draftId);
657 clearDraftThread(draftId);659 clearDraftThread(draftId);
658 },660 },
659 [clearDraftThread],661 [clearDraftThread],
apps/web/src/components/chat/ChatComposer.tsx+154-65
...@@ -74,6 +74,16 @@ import {...@@ -74,6 +74,16 @@ import {
74 type ComposerTasksProgress,74 type ComposerTasksProgress,
75} from "./ComposerTasksBadge";75} from "./ComposerTasksBadge";
76import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";76import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
77import {
78 releaseAttachmentUpload,
79 retryAttachmentUpload,
80 startAttachmentUpload,
81 useAttachmentUploadStore,
82} from "../../lib/attachmentUploadQueue";
83import {
84 attachmentUploadBlockReason,
85 formatAttachmentUploadProgress,
86} from "../../lib/attachmentUploadState";
77import { isCommandPaletteOpen } from "../../commandPaletteBus";87import { isCommandPaletteOpen } from "../../commandPaletteBus";
78import { getTerminalFocusOwner } from "../../lib/terminalFocus";88import { getTerminalFocusOwner } from "../../lib/terminalFocus";
79import { resolveShortcutCommand } from "../../keybindings";89import { resolveShortcutCommand } from "../../keybindings";
...@@ -232,6 +242,7 @@ import {...@@ -232,6 +242,7 @@ import {
232 LockIcon,242 LockIcon,
233 LockOpenIcon,243 LockOpenIcon,
234 PenLineIcon,244 PenLineIcon,
245 RotateCcwIcon,
235 SparklesIcon,246 SparklesIcon,
236 XIcon,247 XIcon,
237} from "lucide-react";248} from "lucide-react";
...@@ -533,6 +544,8 @@ export interface ChatComposerHandle {...@@ -533,6 +544,8 @@ export interface ChatComposerHandle {
533export interface ChatComposerProps {544export interface ChatComposerProps {
534 composerDraftTarget: ScopedThreadRef | DraftId;545 composerDraftTarget: ScopedThreadRef | DraftId;
535 environmentId: EnvironmentId;546 environmentId: EnvironmentId;
547 attachmentUploadsCapabilityKnown: boolean;
548 supportsAttachmentUploads: boolean;
536 routeKind: "server" | "draft";549 routeKind: "server" | "draft";
537 routeThreadRef: ScopedThreadRef;550 routeThreadRef: ScopedThreadRef;
538 draftId: DraftId | null;551 draftId: DraftId | null;
...@@ -647,6 +660,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -647,6 +660,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
647 const {660 const {
648 composerDraftTarget,661 composerDraftTarget,
649 environmentId,662 environmentId,
663 attachmentUploadsCapabilityKnown,
664 supportsAttachmentUploads,
650 routeKind,665 routeKind,
651 routeThreadRef,666 routeThreadRef,
652 draftId,667 draftId,
...@@ -660,7 +675,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -660,7 +675,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
660 phase,675 phase,
661 isConnecting,676 isConnecting,
662 isSendBusy,677 isSendBusy,
663 sendDisabledReason,678 sendDisabledReason: externalSendDisabledReason,
664 isPreparingWorktree,679 isPreparingWorktree,
665 environmentUnavailable,680 environmentUnavailable,
666 activePendingApproval,681 activePendingApproval,
...@@ -711,8 +726,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -711,8 +726,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
711 setThreadError,726 setThreadError,
712 onExpandImage,727 onExpandImage,
713 } = props;728 } = props;
714 const isSendDisabled = sendDisabledReason !== null;
715
716 // ------------------------------------------------------------------729 // ------------------------------------------------------------------
717 // Store subscriptions (prompt / images / terminal contexts)730 // Store subscriptions (prompt / images / terminal contexts)
718 // ------------------------------------------------------------------731 // ------------------------------------------------------------------
...@@ -724,6 +737,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -724,6 +737,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
724 const composerPreviewAnnotations = composerDraft.previewAnnotations;737 const composerPreviewAnnotations = composerDraft.previewAnnotations;
725 const composerReviewComments = composerDraft.reviewComments;738 const composerReviewComments = composerDraft.reviewComments;
726 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;739 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
740 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
741 const attachmentBlockReason = supportsAttachmentUploads
742 ? attachmentUploadBlockReason({
743 imageIds: composerImages.map((image) => image.id),
744 uploadsByImageId,
745 environmentId,
746 })
747 : null;
748 const sendDisabledReason =
749 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);
750 const isSendDisabled = sendDisabledReason !== null;
727751
728 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);752 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
729 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);753 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
...@@ -758,6 +782,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -758,6 +782,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
758 );782 );
759 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);783 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
760784
785 useEffect(() => {
786 if (!attachmentUploadsCapabilityKnown) {
787 return;
788 }
789 if (!supportsAttachmentUploads) {
790 for (const image of composerImages) {
791 releaseAttachmentUpload(image.id);
792 }
793 return;
794 }
795 for (const image of composerImages) {
796 startAttachmentUpload({ environmentId, image });
797 }
798 }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]);
799
761 // ------------------------------------------------------------------800 // ------------------------------------------------------------------
762 // Model state801 // Model state
763 // ------------------------------------------------------------------802 // ------------------------------------------------------------------
...@@ -1337,6 +1376,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1337,6 +1376,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13371376
1338 const removeComposerImageFromDraft = useCallback(1377 const removeComposerImageFromDraft = useCallback(
1339 (imageId: string) => {1378 (imageId: string) => {
1379 releaseAttachmentUpload(imageId);
1340 removeComposerDraftImage(composerDraftTarget, imageId);1380 removeComposerDraftImage(composerDraftTarget, imageId);
1341 },1381 },
1342 [composerDraftTarget, removeComposerDraftImage],1382 [composerDraftTarget, removeComposerDraftImage],
...@@ -2244,6 +2284,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2244,6 +2284,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2244 // destroying them here would be unrecoverable.2284 // destroying them here would be unrecoverable.
2245 promptRef.current = "";2285 promptRef.current = "";
2246 clearComposerDraftPromptAndImages(stashTarget);2286 clearComposerDraftPromptAndImages(stashTarget);
2287 for (const image of images) {
2288 releaseAttachmentUpload(image.id);
2289 }
2247 setComposerCursor(0);2290 setComposerCursor(0);
2248 setComposerTrigger(null);2291 setComposerTrigger(null);
2249 pulseStashBadge();2292 pulseStashBadge();
...@@ -3106,9 +3149,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3106,9 +3149,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3106 <ComposerPreviewAnnotationCards3149 <ComposerPreviewAnnotationCards
3107 annotations={composerPreviewAnnotations}3150 annotations={composerPreviewAnnotations}
3108 images={composerImages}3151 images={composerImages}
3109 onRemove={(annotationId) =>3152 {...(supportsAttachmentUploads
3110 removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId)3153 ? {
3111 }3154 uploadsByImageId,
3155 onRetryUpload: (image: ComposerImageAttachment) =>
3156 retryAttachmentUpload({ environmentId, image }),
3157 }
3158 : {})}
3159 onRemove={(annotationId) => {
3160 releaseAttachmentUpload(annotationId);
3161 removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId);
3162 }}
3112 onExpandImage={(imageId) => {3163 onExpandImage={(imageId) => {
3113 const preview = buildExpandedImagePreview(composerImages, imageId);3164 const preview = buildExpandedImagePreview(composerImages, imageId);
3114 if (preview) onExpandImage(preview);3165 if (preview) onExpandImage(preview);
...@@ -3158,66 +3209,104 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3158,66 +3209,104 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3158 (annotation) => annotation.id === image.id,3209 (annotation) => annotation.id === image.id,
3159 ),3210 ),
3160 )3211 )
3161 .map((image) => (3212 .map((image) => {
3162 <div3213 const upload = supportsAttachmentUploads
3163 key={image.id}3214 ? uploadsByImageId[image.id]
3164 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"3215 : undefined;
3165 >3216 return (
3166 {image.previewUrl ? (3217 <div
3167 <button3218 key={image.id}
3168 type="button"3219 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
3169 className="h-full w-full cursor-zoom-in"
3170 aria-label={`Preview ${image.name}`}
3171 onClick={() => {
3172 const preview = buildExpandedImagePreview(composerImages, image.id);
3173 if (!preview) return;
3174 onExpandImage(preview);
3175 }}
3176 >
3177 <img
3178 src={image.previewUrl}
3179 alt={image.name}
3180 className="h-full w-full object-cover"
3181 />
3182 </button>
3183 ) : (
3184 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label">
3185 {image.name}
3186 </div>
3187 )}
3188 {nonPersistedComposerImageIdSet.has(image.id) && (
3189 <Tooltip>
3190 <TooltipTrigger
3191 render={
3192 <span
3193 role="img"
3194 aria-label="Draft attachment may not persist"
3195 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3196 >
3197 <CircleAlertIcon className="size-3" />
3198 </span>
3199 }
3200 />
3201 <TooltipPopup
3202 side="top"
3203 className="max-w-64 whitespace-normal leading-tight"
3204 >
3205 Draft attachment could not be saved locally and may be lost on
3206 navigation.
3207 </TooltipPopup>
3208 </Tooltip>
3209 )}
3210 <Button
3211 variant="ghost"
3212 size="icon-xs"
3213 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3214 onClick={() => removeComposerImage(image.id)}
3215 aria-label={`Remove ${image.name}`}
3216 >3220 >
3217 <XIcon />3221 {image.previewUrl ? (
3218 </Button>3222 <button
3219 </div>3223 type="button"
3220 ))}3224 className="h-full w-full cursor-zoom-in"
3225 aria-label={`Preview ${image.name}`}
3226 onClick={() => {
3227 const preview = buildExpandedImagePreview(
3228 composerImages,
3229 image.id,
3230 );
3231 if (!preview) return;
3232 onExpandImage(preview);
3233 }}
3234 >
3235 <img
3236 src={image.previewUrl}
3237 alt={image.name}
3238 className="h-full w-full object-cover"
3239 />
3240 </button>
3241 ) : (
3242 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label">
3243 {image.name}
3244 </div>
3245 )}
3246 {nonPersistedComposerImageIdSet.has(image.id) && (
3247 <Tooltip>
3248 <TooltipTrigger
3249 render={
3250 <span
3251 role="img"
3252 aria-label="Draft attachment may not persist"
3253 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3254 >
3255 <CircleAlertIcon className="size-3" />
3256 </span>
3257 }
3258 />
3259 <TooltipPopup
3260 side="top"
3261 className="max-w-64 whitespace-normal leading-tight"
3262 >
3263 Draft attachment could not be saved locally and may be lost on
3264 navigation.
3265 </TooltipPopup>
3266 </Tooltip>
3267 )}
3268 {upload?.status === "uploading" && (
3269 <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-[10px] text-foreground">
3270 {formatAttachmentUploadProgress(upload.progress)}
3271 </span>
3272 )}
3273 {upload?.status === "failed" && (
3274 <Tooltip>
3275 <TooltipTrigger
3276 render={
3277 <Button
3278 variant="ghost"
3279 size="icon-xs"
3280 className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
3281 onClick={() =>
3282 retryAttachmentUpload({ environmentId, image })
3283 }
3284 aria-label={`Retry upload for ${image.name}`}
3285 />
3286 }
3287 >
3288 <RotateCcwIcon />
3289 </TooltipTrigger>
3290 <TooltipPopup
3291 side="top"
3292 className="max-w-64 whitespace-normal leading-tight"
3293 >
3294 {upload.reason}
3295 </TooltipPopup>
3296 </Tooltip>
3297 )}
3298 <Button
3299 variant="ghost"
3300 size="icon-xs"
3301 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3302 onClick={() => removeComposerImage(image.id)}
3303 aria-label={`Remove ${image.name}`}
3304 >
3305 <XIcon />
3306 </Button>
3307 </div>
3308 );
3309 })}
3221 </div>3310 </div>
3222 )}3311 )}
32233312
apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx+31-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { PreviewAnnotationPayload } from "@t3tools/contracts";1import { EnvironmentId, type PreviewAnnotationPayload } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";3import { describe, expect, it, vi } from "vite-plus/test";
44
...@@ -58,4 +58,34 @@ describe("ComposerPreviewAnnotationCards", () => {...@@ -58,4 +58,34 @@ describe("ComposerPreviewAnnotationCards", () => {
58 expect(markup).toContain('aria-label="Remove preview annotation"');58 expect(markup).toContain('aria-label="Remove preview annotation"');
59 expect(markup).toContain('data-slot="button"');59 expect(markup).toContain('data-slot="button"');
60 });60 });
61
62 it("shows a retry action for a failed screenshot upload", () => {
63 const image = {
64 type: "image" as const,
65 id: annotation.id,
66 name: "annotation.png",
67 mimeType: "image/png",
68 sizeBytes: 3,
69 previewUrl: "blob:annotation",
70 file: new File([new Uint8Array([1, 2, 3])], "annotation.png", { type: "image/png" }),
71 };
72 const markup = renderToStaticMarkup(
73 <ComposerPreviewAnnotationCards
74 annotations={[annotation]}
75 images={[image]}
76 uploadsByImageId={{
77 [image.id]: {
78 status: "failed",
79 environmentId: EnvironmentId.make("environment-1"),
80 reason: "Upload rejected",
81 },
82 }}
83 onRetryUpload={vi.fn()}
84 onRemove={vi.fn()}
85 onExpandImage={vi.fn()}
86 />,
87 );
88
89 expect(markup).toContain('aria-label="Retry upload for annotation.png"');
90 });
61});91});
apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx+32-1
...@@ -1,9 +1,13 @@...@@ -1,9 +1,13 @@
1import type { PreviewAnnotationPayload } from "@t3tools/contracts";1import type { PreviewAnnotationPayload } from "@t3tools/contracts";
2import { Frame, MousePointerClick, Paintbrush, PenLine, X } from "lucide-react";2import { Frame, MousePointerClick, Paintbrush, PenLine, RotateCcw, X } from "lucide-react";
3import type { ReactNode } from "react";3import type { ReactNode } from "react";
44
5import type { ComposerImageAttachment } from "~/composerDraftStore";5import type { ComposerImageAttachment } from "~/composerDraftStore";
6import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext";6import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext";
7import {
8 formatAttachmentUploadProgress,
9 type AttachmentUploadState,
10} from "~/lib/attachmentUploadState";
7import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
8import { Button } from "../ui/button";12import { Button } from "../ui/button";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -13,6 +17,8 @@ interface ComposerPreviewAnnotationCardsProps {...@@ -13,6 +17,8 @@ interface ComposerPreviewAnnotationCardsProps {
13 images: ReadonlyArray<ComposerImageAttachment>;17 images: ReadonlyArray<ComposerImageAttachment>;
14 onRemove: (annotationId: string) => void;18 onRemove: (annotationId: string) => void;
15 onExpandImage: (imageId: string) => void;19 onExpandImage: (imageId: string) => void;
20 uploadsByImageId?: Readonly<Record<string, AttachmentUploadState>>;
21 onRetryUpload?: (image: ComposerImageAttachment) => void;
16 className?: string;22 className?: string;
17}23}
1824
...@@ -38,6 +44,8 @@ export function ComposerPreviewAnnotationCards({...@@ -38,6 +44,8 @@ export function ComposerPreviewAnnotationCards({
38 images,44 images,
39 onRemove,45 onRemove,
40 onExpandImage,46 onExpandImage,
47 uploadsByImageId,
48 onRetryUpload,
41 className,49 className,
42}: ComposerPreviewAnnotationCardsProps) {50}: ComposerPreviewAnnotationCardsProps) {
43 if (annotations.length === 0) return null;51 if (annotations.length === 0) return null;
...@@ -47,6 +55,7 @@ export function ComposerPreviewAnnotationCards({...@@ -47,6 +55,7 @@ export function ComposerPreviewAnnotationCards({
47 <div className={cn("flex flex-wrap gap-1.5", className)}>55 <div className={cn("flex flex-wrap gap-1.5", className)}>
48 {annotations.map((annotation) => {56 {annotations.map((annotation) => {
49 const image = imagesById.get(annotation.id);57 const image = imagesById.get(annotation.id);
58 const upload = image ? uploadsByImageId?.[image.id] : undefined;
50 const elementLabels = annotation.elements.flatMap((target) => {59 const elementLabels = annotation.elements.flatMap((target) => {
51 const context = normalizeElementContextSelection(target.element);60 const context = normalizeElementContextSelection(target.element);
52 return context ? [{ id: target.id, label: formatElementContextLabel(context) }] : [];61 return context ? [{ id: target.id, label: formatElementContextLabel(context) }] : [];
...@@ -132,6 +141,28 @@ export function ComposerPreviewAnnotationCards({...@@ -132,6 +141,28 @@ export function ComposerPreviewAnnotationCards({
132 label="style change"141 label="style change"
133 />142 />
134 ) : null}143 ) : null}
144 {upload?.status === "uploading" ? (
145 <span className="text-[10px] text-secondary-label">
146 {formatAttachmentUploadProgress(upload.progress)}
147 </span>
148 ) : null}
149 {upload?.status === "failed" && image && onRetryUpload ? (
150 <Tooltip>
151 <TooltipTrigger
152 render={
153 <Button
154 size="icon-micro"
155 variant="ghost-muted"
156 aria-label={`Retry upload for ${image.name}`}
157 onClick={() => onRetryUpload(image)}
158 />
159 }
160 >
161 <RotateCcw className="size-3" />
162 </TooltipTrigger>
163 <TooltipPopup side="top">{upload.reason}</TooltipPopup>
164 </Tooltip>
165 ) : null}
135 </div>166 </div>
136 </div>167 </div>
137 </div>168 </div>
apps/web/src/components/settings/ProjectSettingsPanel.tsx+2
...@@ -46,6 +46,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";...@@ -46,6 +46,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
46import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";46import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
47import { shortcutLabelForCommand } from "../../keybindings";47import { shortcutLabelForCommand } from "../../keybindings";
48import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";48import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";
49import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
49import { readLocalApi } from "../../localApi";50import { readLocalApi } from "../../localApi";
50import {51import {
51 buildProjectScript,52 buildProjectScript,
...@@ -722,6 +723,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -722,6 +723,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
722 return;723 return;
723 }724 }
724 const projectRef = scopeProjectRef(member.environmentId, member.id);725 const projectRef = scopeProjectRef(member.environmentId, member.id);
726 releaseProjectDraftUploads(projectRef);
725 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);727 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
726 if (projectDraftThread) {728 if (projectDraftThread) {
727 draftStore.clearDraftThread(projectDraftThread.draftId);729 draftStore.clearDraftThread(projectDraftThread.draftId);
apps/web/src/hooks/useThreadActions.ts+2
...@@ -20,6 +20,7 @@ import { threadEnvironment } from "../state/threads";...@@ -20,6 +20,7 @@ import { threadEnvironment } from "../state/threads";
20import { vcsEnvironment } from "../state/vcs";20import { vcsEnvironment } from "../state/vcs";
21import { useNewThreadHandler } from "./useHandleNewThread";21import { useNewThreadHandler } from "./useHandleNewThread";
22import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";22import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
23import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
23import { readLocalApi } from "../localApi";24import { readLocalApi } from "../localApi";
24import {25import {
25 readEnvironmentSupportsPinning,26 readEnvironmentSupportsPinning,
...@@ -364,6 +365,7 @@ export function useThreadActions() {...@@ -364,6 +365,7 @@ export function useThreadActions() {
364 return deleteResult;365 return deleteResult;
365 }366 }
366 refreshArchivedThreadsForEnvironment(threadRef.environmentId);367 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
368 releaseComposerDraftUploads(threadRef);
367 clearComposerDraftForThread(threadRef);369 clearComposerDraftForThread(threadRef);
368 clearProjectDraftThreadById(370 clearProjectDraftThreadById(
369 scopeProjectRef(threadRef.environmentId, thread.projectId),371 scopeProjectRef(threadRef.environmentId, thread.projectId),
apps/web/src/lib/attachmentUploadQueue.test.ts created+306
...@@ -0,0 +1,306 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import type { ComposerImageAttachment } from "../composerDraftStore";
5
6const mocks = vi.hoisted(() => ({
7 createUploadUrl: Symbol("create-upload-url"),
8 removeUpload: Symbol("remove-upload"),
9 runAtomCommand: vi.fn(),
10 readPreparedConnection: vi.fn(),
11}));
12
13vi.mock("@t3tools/client-runtime/state/runtime", () => ({
14 runAtomCommand: mocks.runAtomCommand,
15}));
16
17vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
18
19vi.mock("../state/attachments", () => ({
20 attachmentEnvironment: {
21 createUploadUrl: mocks.createUploadUrl,
22 remove: mocks.removeUpload,
23 },
24}));
25
26vi.mock("../state/session", () => ({
27 readPreparedConnection: mocks.readPreparedConnection,
28}));
29
30import {
31 awaitAttachmentUploads,
32 getUploadedAttachments,
33 readAttachmentUpload,
34 releaseAttachmentUpload,
35 releaseAttachmentUploads,
36 retryAttachmentUpload,
37 startAttachmentUpload,
38 useAttachmentUploadStore,
39} from "./attachmentUploadQueue";
40
41type ProgressListener = (event: {
42 readonly lengthComputable: boolean;
43 readonly loaded: number;
44 readonly total: number;
45}) => void;
46
47class TestXmlHttpRequest {
48 static requests: TestXmlHttpRequest[] = [];
49
50 status = 0;
51 timeout = 0;
52 method: string | null = null;
53 url: string | null = null;
54 readonly headers = new Map<string, string>();
55 readonly listeners = new Map<string, () => void>();
56 progressListener: ProgressListener | null = null;
57
58 readonly upload = {
59 addEventListener: (_event: string, listener: ProgressListener) => {
60 this.progressListener = listener;
61 },
62 };
63
64 constructor() {
65 TestXmlHttpRequest.requests.push(this);
66 }
67
68 open(method: string, url: string): void {
69 this.method = method;
70 this.url = url;
71 }
72
73 setRequestHeader(name: string, value: string): void {
74 this.headers.set(name, value);
75 }
76
77 addEventListener(event: string, listener: () => void): void {
78 this.listeners.set(event, listener);
79 }
80
81 send(): void {}
82
83 abort(): void {
84 this.listeners.get("abort")?.();
85 }
86
87 progress(loaded: number, total: number): void {
88 this.progressListener?.({ lengthComputable: true, loaded, total });
89 }
90
91 complete(status = 204): void {
92 this.status = status;
93 this.listeners.get("load")?.();
94 }
95}
96
97const firstEnvironment = EnvironmentId.make("environment-1");
98const secondEnvironment = EnvironmentId.make("environment-2");
99
100function makeImage(id: string): ComposerImageAttachment {
101 const file = new File([new Uint8Array([1, 2, 3])], `${id}.png`, { type: "image/png" });
102 return {
103 type: "image",
104 id,
105 name: file.name,
106 mimeType: file.type,
107 sizeBytes: file.size,
108 previewUrl: `blob:${id}`,
109 file,
110 };
111}
112
113describe("attachmentUploadQueue", () => {
114 beforeEach(() => {
115 TestXmlHttpRequest.requests = [];
116 mocks.runAtomCommand.mockReset();
117 mocks.readPreparedConnection.mockReset();
118 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });
119 mocks.runAtomCommand.mockImplementation(
120 async (
121 _registry: unknown,
122 command: unknown,
123 target: {
124 readonly environmentId: EnvironmentId;
125 readonly input: { readonly name?: string };
126 },
127 ) => {
128 if (command === mocks.createUploadUrl) {
129 const attachmentId = `pending-${target.environmentId}-${target.input.name}`;
130 return {
131 _tag: "Success",
132 value: {
133 attachmentId,
134 relativeUrl: `/api/attachments/upload/${attachmentId}`,
135 expiresAt: 1,
136 },
137 };
138 }
139 return { _tag: "Success", value: undefined };
140 },
141 );
142 vi.stubGlobal("XMLHttpRequest", TestXmlHttpRequest);
143 });
144
145 afterEach(() => {
146 for (const imageId of Object.keys(useAttachmentUploadStore.getState().uploadsByImageId)) {
147 releaseAttachmentUpload(imageId);
148 }
149 vi.unstubAllGlobals();
150 });
151
152 it("uploads images immediately and sends attachment references", async () => {
153 const image = makeImage("image-1");
154 startAttachmentUpload({ environmentId: firstEnvironment, image });
155 await Promise.resolve();
156
157 const request = TestXmlHttpRequest.requests[0]!;
158 expect(request.method).toBe("POST");
159 expect(request.url).toBe(
160 "https://environment.test/api/attachments/upload/pending-environment-1-image-1.png",
161 );
162 request.progress(1, 3);
163 expect(readAttachmentUpload(image.id)).toMatchObject({ status: "uploading", progress: 1 / 3 });
164
165 const settled = awaitAttachmentUploads([image.id]);
166 request.complete();
167 await settled;
168
169 expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [image] })).toEqual([
170 {
171 type: "image",
172 id: "pending-environment-1-image-1.png",
173 name: "image-1.png",
174 mimeType: "image/png",
175 sizeBytes: 3,
176 },
177 ]);
178
179 releaseAttachmentUploads([image]);
180 expect(readAttachmentUpload(image.id)).toBeUndefined();
181 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
182 expect.anything(),
183 mocks.removeUpload,
184 {
185 environmentId: firstEnvironment,
186 input: { attachmentId: "pending-environment-1-image-1.png" },
187 },
188 expect.anything(),
189 );
190 });
191
192 it("retries rejected uploads", async () => {
193 const image = makeImage("image-retry");
194 startAttachmentUpload({ environmentId: firstEnvironment, image });
195 await Promise.resolve();
196
197 let settled = awaitAttachmentUploads([image.id]);
198 TestXmlHttpRequest.requests[0]!.complete(500);
199 await settled;
200 expect(readAttachmentUpload(image.id)).toMatchObject({
201 status: "failed",
202 reason: "Upload rejected (500)",
203 });
204
205 retryAttachmentUpload({ environmentId: firstEnvironment, image });
206 await Promise.resolve();
207 settled = awaitAttachmentUploads([image.id]);
208 TestXmlHttpRequest.requests[1]!.complete();
209 await settled;
210
211 expect(readAttachmentUpload(image.id)).toMatchObject({ status: "ready" });
212 });
213
214 it("releases an upload URL that resolves after its image was removed", async () => {
215 const image = makeImage("image-cancelled");
216 const minted = {
217 _tag: "Success" as const,
218 value: {
219 attachmentId: "pending-environment-1-image-cancelled.png",
220 relativeUrl: "/api/attachments/upload/cancelled",
221 expiresAt: 1,
222 },
223 };
224 let resolveMint: (result: typeof minted) => void = () => {};
225 const pendingMint = new Promise<typeof minted>((resolve) => {
226 resolveMint = resolve;
227 });
228 let resolveDelete: () => void = () => {};
229 const deleted = new Promise<void>((resolve) => {
230 resolveDelete = resolve;
231 });
232 mocks.runAtomCommand.mockImplementation((_registry: unknown, command: unknown) => {
233 if (command === mocks.createUploadUrl) {
234 return pendingMint;
235 }
236 resolveDelete();
237 return Promise.resolve({ _tag: "Success", value: undefined });
238 });
239
240 startAttachmentUpload({ environmentId: firstEnvironment, image });
241 releaseAttachmentUpload(image.id);
242 resolveMint(minted);
243 await deleted;
244
245 expect(TestXmlHttpRequest.requests).toEqual([]);
246 expect(readAttachmentUpload(image.id)).toBeUndefined();
247 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
248 expect.anything(),
249 mocks.removeUpload,
250 {
251 environmentId: firstEnvironment,
252 input: { attachmentId: minted.value.attachmentId },
253 },
254 expect.anything(),
255 );
256 });
257
258 it("restores the previous environment after a replacement upload fails", async () => {
259 const image = makeImage("image-move");
260 startAttachmentUpload({ environmentId: firstEnvironment, image });
261 await Promise.resolve();
262 let settled = awaitAttachmentUploads([image.id]);
263 TestXmlHttpRequest.requests[0]!.complete();
264 await settled;
265
266 startAttachmentUpload({ environmentId: secondEnvironment, image });
267 await Promise.resolve();
268 settled = awaitAttachmentUploads([image.id]);
269 TestXmlHttpRequest.requests[1]!.complete(500);
270 await settled;
271
272 startAttachmentUpload({ environmentId: firstEnvironment, image });
273 expect(readAttachmentUpload(image.id)).toMatchObject({
274 status: "ready",
275 environmentId: firstEnvironment,
276 attachmentId: "pending-environment-1-image-move.png",
277 });
278 });
279
280 it("does not let stalled uploads block another environment", async () => {
281 const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage);
282 for (const image of images) {
283 startAttachmentUpload({ environmentId: firstEnvironment, image });
284 }
285 const otherEnvironmentImage = makeImage("image-other");
286 startAttachmentUpload({ environmentId: secondEnvironment, image: otherEnvironmentImage });
287 await Promise.resolve();
288
289 expect(TestXmlHttpRequest.requests).toHaveLength(4);
290 const otherRequest = TestXmlHttpRequest.requests.find((request) =>
291 request.url?.includes("environment-2"),
292 );
293 expect(otherRequest).toBeDefined();
294
295 for (const request of TestXmlHttpRequest.requests) {
296 request.complete();
297 }
298 await Promise.all([
299 ...images.slice(0, 3).map((image) => awaitAttachmentUploads([image.id])),
300 awaitAttachmentUploads([otherEnvironmentImage.id]),
301 ]);
302 await Promise.resolve();
303 TestXmlHttpRequest.requests[4]!.complete();
304 await awaitAttachmentUploads([images[3]!.id]);
305 });
306});
apps/web/src/lib/attachmentUploadQueue.ts created+389
...@@ -0,0 +1,389 @@
1import {
2 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
3 type ChatAttachment,
4 type EnvironmentId,
5} from "@t3tools/contracts";
6import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
7import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
8import { create } from "zustand";
9
10import type { ComposerImageAttachment } from "../composerDraftStore";
11import { appAtomRegistry } from "../rpc/atomRegistry";
12import { attachmentEnvironment } from "../state/attachments";
13import { readPreparedConnection } from "../state/session";
14import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";
15
16const MAX_UPLOADS_PER_ENVIRONMENT = 3;
17const UPLOAD_TIMEOUT_MS = 5 * 60_000;
18
19interface AttachmentUploadStore {
20 readonly uploadsByImageId: Readonly<Record<string, AttachmentUploadState>>;
21}
22
23export const useAttachmentUploadStore = create<AttachmentUploadStore>(() => ({
24 uploadsByImageId: {},
25}));
26
27interface UploadJob {
28 readonly image: ComposerImageAttachment;
29 readonly environmentId: EnvironmentId;
30 readonly previous?: ReadyAttachmentUpload;
31 readonly settled: Promise<void>;
32 resolveSettled: () => void;
33 attachmentId: string | null;
34 cancelled: boolean;
35 abort: (() => void) | null;
36}
37
38const jobsByImageId = new Map<string, UploadJob>();
39const queue: UploadJob[] = [];
40const activeUploadsByEnvironment = new Map<EnvironmentId, number>();
41
42function setUploadState(imageId: string, upload: AttachmentUploadState): void {
43 useAttachmentUploadStore.setState((state) => ({
44 uploadsByImageId: { ...state.uploadsByImageId, [imageId]: upload },
45 }));
46}
47
48function clearUploadState(imageId: string): void {
49 useAttachmentUploadStore.setState((state) => {
50 if (!(imageId in state.uploadsByImageId)) {
51 return state;
52 }
53 const uploadsByImageId = { ...state.uploadsByImageId };
54 delete uploadsByImageId[imageId];
55 return { uploadsByImageId };
56 });
57}
58
59export function readAttachmentUpload(imageId: string): AttachmentUploadState | undefined {
60 return useAttachmentUploadStore.getState().uploadsByImageId[imageId];
61}
62
63function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void {
64 void runAtomCommand(
65 appAtomRegistry,
66 attachmentEnvironment.remove,
67 { environmentId, input: { attachmentId } },
68 { reportFailure: false, reportDefect: false },
69 );
70}
71
72function uploadBytes(input: {
73 readonly url: string;
74 readonly file: File;
75 readonly onProgress: (progress: number) => void;
76}): { readonly done: Promise<void>; readonly abort: () => void } {
77 const xhr = new XMLHttpRequest();
78 const done = new Promise<void>((resolve, reject) => {
79 xhr.open("POST", input.url, true);
80 xhr.timeout = UPLOAD_TIMEOUT_MS;
81 xhr.setRequestHeader("Content-Type", input.file.type);
82 xhr.upload.addEventListener("progress", (event) => {
83 if (event.lengthComputable && event.total > 0) {
84 input.onProgress(event.loaded / event.total);
85 }
86 });
87 xhr.addEventListener("load", () => {
88 if (xhr.status >= 200 && xhr.status < 300) {
89 resolve();
90 } else {
91 reject(new Error(`Upload rejected (${xhr.status})`));
92 }
93 });
94 xhr.addEventListener("error", () => reject(new Error("Upload failed")));
95 xhr.addEventListener("timeout", () => reject(new Error("Upload timed out")));
96 xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
97 xhr.send(input.file);
98 });
99
100 return { done, abort: () => xhr.abort() };
101}
102
103async function runUpload(job: UploadJob): Promise<void> {
104 const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find(
105 (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(),
106 );
107 if (!mimeType) {
108 setUploadState(job.image.id, {
109 status: "failed",
110 environmentId: job.environmentId,
111 reason: "Unsupported image type",
112 ...(job.previous ? { previous: job.previous } : {}),
113 });
114 return;
115 }
116
117 const minted = await runAtomCommand(
118 appAtomRegistry,
119 attachmentEnvironment.createUploadUrl,
120 {
121 environmentId: job.environmentId,
122 input: {
123 name: job.image.name,
124 mimeType,
125 sizeBytes: job.image.file.size,
126 },
127 },
128 { reportFailure: false },
129 );
130 if (job.cancelled) {
131 if (minted._tag === "Success") {
132 deletePendingUpload(job.environmentId, minted.value.attachmentId);
133 }
134 return;
135 }
136 if (minted._tag !== "Success") {
137 setUploadState(job.image.id, {
138 status: "failed",
139 environmentId: job.environmentId,
140 reason: "Upload could not start",
141 ...(job.previous ? { previous: job.previous } : {}),
142 });
143 return;
144 }
145 job.attachmentId = minted.value.attachmentId;
146
147 const connection = readPreparedConnection(job.environmentId);
148 const url = connection ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) : null;
149 if (!url) {
150 setUploadState(job.image.id, {
151 status: "failed",
152 environmentId: job.environmentId,
153 reason: "Not connected",
154 attachmentId: minted.value.attachmentId,
155 ...(job.previous ? { previous: job.previous } : {}),
156 });
157 return;
158 }
159
160 let lastStep = -1;
161 const upload = uploadBytes({
162 url,
163 file: job.image.file,
164 onProgress: (progress) => {
165 const step = Math.floor(progress * 20);
166 if (step === lastStep || job.cancelled) {
167 return;
168 }
169 lastStep = step;
170 setUploadState(job.image.id, {
171 status: "uploading",
172 environmentId: job.environmentId,
173 progress,
174 ...(job.previous ? { previous: job.previous } : {}),
175 });
176 },
177 });
178 job.abort = upload.abort;
179
180 try {
181 await upload.done;
182 if (job.cancelled) {
183 return;
184 }
185 setUploadState(job.image.id, {
186 status: "ready",
187 environmentId: job.environmentId,
188 attachmentId: minted.value.attachmentId,
189 });
190 if (job.previous) {
191 deletePendingUpload(job.previous.environmentId, job.previous.attachmentId);
192 }
193 } catch (error) {
194 if (job.cancelled) {
195 return;
196 }
197 setUploadState(job.image.id, {
198 status: "failed",
199 environmentId: job.environmentId,
200 reason: error instanceof Error ? error.message : "Upload failed",
201 attachmentId: minted.value.attachmentId,
202 ...(job.previous ? { previous: job.previous } : {}),
203 });
204 } finally {
205 job.abort = null;
206 }
207}
208
209function pumpUploads(): void {
210 for (let index = 0; index < queue.length; ) {
211 const job = queue[index]!;
212 const active = activeUploadsByEnvironment.get(job.environmentId) ?? 0;
213 if (active >= MAX_UPLOADS_PER_ENVIRONMENT) {
214 index += 1;
215 continue;
216 }
217
218 queue.splice(index, 1);
219 if (job.cancelled) {
220 continue;
221 }
222 activeUploadsByEnvironment.set(job.environmentId, active + 1);
223 void runUpload(job)
224 .catch(() => {
225 if (!job.cancelled) {
226 setUploadState(job.image.id, {
227 status: "failed",
228 environmentId: job.environmentId,
229 reason: "Upload failed",
230 ...(job.previous ? { previous: job.previous } : {}),
231 });
232 }
233 })
234 .finally(() => {
235 if (jobsByImageId.get(job.image.id) === job) {
236 jobsByImageId.delete(job.image.id);
237 }
238 const remaining = (activeUploadsByEnvironment.get(job.environmentId) ?? 1) - 1;
239 if (remaining > 0) {
240 activeUploadsByEnvironment.set(job.environmentId, remaining);
241 } else {
242 activeUploadsByEnvironment.delete(job.environmentId);
243 }
244 job.resolveSettled();
245 pumpUploads();
246 });
247 }
248}
249
250export function startAttachmentUpload(input: {
251 readonly environmentId: EnvironmentId;
252 readonly image: ComposerImageAttachment;
253}): void {
254 const existingJob = jobsByImageId.get(input.image.id);
255 if (existingJob?.environmentId === input.environmentId) {
256 return;
257 }
258
259 const existing = readAttachmentUpload(input.image.id);
260 if (existing?.status === "ready" && existing.environmentId === input.environmentId) {
261 return;
262 }
263 if (existing?.status === "failed" && existing.environmentId === input.environmentId) {
264 return;
265 }
266 if (
267 existing &&
268 "previous" in existing &&
269 existing.previous?.environmentId === input.environmentId
270 ) {
271 cancelAttachmentUpload(input.image.id);
272 if (existing.status === "failed" && existing.attachmentId) {
273 deletePendingUpload(existing.environmentId, existing.attachmentId);
274 }
275 setUploadState(input.image.id, existing.previous);
276 return;
277 }
278
279 if (existingJob) {
280 cancelAttachmentUpload(input.image.id);
281 }
282 const previous = existing?.status === "ready" ? existing : existing?.previous;
283 let resolveSettled: () => void = () => {};
284 const settled = new Promise<void>((resolve) => {
285 resolveSettled = resolve;
286 });
287 const job: UploadJob = {
288 image: input.image,
289 environmentId: input.environmentId,
290 ...(previous ? { previous } : {}),
291 settled,
292 resolveSettled,
293 attachmentId: null,
294 cancelled: false,
295 abort: null,
296 };
297
298 jobsByImageId.set(input.image.id, job);
299 queue.push(job);
300 setUploadState(input.image.id, {
301 status: "uploading",
302 environmentId: input.environmentId,
303 progress: 0,
304 ...(previous ? { previous } : {}),
305 });
306 pumpUploads();
307}
308
309export function cancelAttachmentUpload(imageId: string): void {
310 const job = jobsByImageId.get(imageId);
311 if (!job) {
312 return;
313 }
314 job.cancelled = true;
315 jobsByImageId.delete(imageId);
316 const queuedIndex = queue.indexOf(job);
317 if (queuedIndex !== -1) {
318 queue.splice(queuedIndex, 1);
319 }
320 job.abort?.();
321 if (job.attachmentId) {
322 deletePendingUpload(job.environmentId, job.attachmentId);
323 }
324 job.resolveSettled();
325}
326
327export function releaseAttachmentUpload(imageId: string): void {
328 const upload = readAttachmentUpload(imageId);
329 cancelAttachmentUpload(imageId);
330 if (upload?.status === "ready") {
331 deletePendingUpload(upload.environmentId, upload.attachmentId);
332 } else if (upload) {
333 if (upload.status === "failed" && upload.attachmentId) {
334 deletePendingUpload(upload.environmentId, upload.attachmentId);
335 }
336 if (upload.previous) {
337 deletePendingUpload(upload.previous.environmentId, upload.previous.attachmentId);
338 }
339 }
340 clearUploadState(imageId);
341}
342
343export function retryAttachmentUpload(input: {
344 readonly environmentId: EnvironmentId;
345 readonly image: ComposerImageAttachment;
346}): void {
347 const previous = readAttachmentUpload(input.image.id);
348 cancelAttachmentUpload(input.image.id);
349 if (previous?.status === "failed" && previous.attachmentId) {
350 deletePendingUpload(previous.environmentId, previous.attachmentId);
351 }
352 if (previous && "previous" in previous && previous.previous) {
353 setUploadState(input.image.id, previous.previous);
354 } else {
355 clearUploadState(input.image.id);
356 }
357 startAttachmentUpload(input);
358}
359
360export async function awaitAttachmentUploads(imageIds: ReadonlyArray<string>): Promise<void> {
361 await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled));
362}
363
364export function getUploadedAttachments(input: {
365 readonly environmentId: EnvironmentId;
366 readonly images: ReadonlyArray<ComposerImageAttachment>;
367}): ChatAttachment[] | null {
368 const attachments: ChatAttachment[] = [];
369 for (const image of input.images) {
370 const upload = readAttachmentUpload(image.id);
371 if (upload?.status !== "ready" || upload.environmentId !== input.environmentId) {
372 return null;
373 }
374 attachments.push({
375 type: "image",
376 id: upload.attachmentId,
377 name: image.name,
378 mimeType: image.mimeType,
379 sizeBytes: image.sizeBytes,
380 });
381 }
382 return attachments;
383}
384
385export function releaseAttachmentUploads(images: ReadonlyArray<ComposerImageAttachment>): void {
386 for (const image of images) {
387 releaseAttachmentUpload(image.id);
388 }
389}
apps/web/src/lib/attachmentUploadState.test.ts created+75
...@@ -0,0 +1,75 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 attachmentUploadBlockReason,
6 formatAttachmentUploadProgress,
7} from "./attachmentUploadState";
8
9const environmentId = EnvironmentId.make("environment-1");
10
11describe("attachmentUploadBlockReason", () => {
12 it("allows uploaded images from the active environment", () => {
13 expect(
14 attachmentUploadBlockReason({
15 imageIds: ["image-1"],
16 environmentId,
17 uploadsByImageId: {
18 "image-1": {
19 status: "ready",
20 environmentId,
21 attachmentId: "pending-1",
22 },
23 },
24 }),
25 ).toBeNull();
26 });
27
28 it("blocks images that are missing or uploading", () => {
29 expect(
30 attachmentUploadBlockReason({
31 imageIds: ["image-1", "image-2"],
32 environmentId,
33 uploadsByImageId: {
34 "image-1": { status: "uploading", environmentId, progress: 0.5 },
35 },
36 }),
37 ).toBe("Images still uploading");
38 });
39
40 it("asks the user to retry or remove failed uploads", () => {
41 expect(
42 attachmentUploadBlockReason({
43 imageIds: ["image-1"],
44 environmentId,
45 uploadsByImageId: {
46 "image-1": { status: "failed", environmentId, reason: "Upload failed" },
47 },
48 }),
49 ).toBe("Retry or remove the failed image");
50 });
51
52 it("does not accept an upload from another environment", () => {
53 expect(
54 attachmentUploadBlockReason({
55 imageIds: ["image-1"],
56 environmentId,
57 uploadsByImageId: {
58 "image-1": {
59 status: "ready",
60 environmentId: EnvironmentId.make("environment-2"),
61 attachmentId: "pending-1",
62 },
63 },
64 }),
65 ).toBe("Image still uploading");
66 });
67});
68
69describe("formatAttachmentUploadProgress", () => {
70 it("formats bounded whole percentages", () => {
71 expect(formatAttachmentUploadProgress(0.429)).toBe("42%");
72 expect(formatAttachmentUploadProgress(2)).toBe("100%");
73 expect(formatAttachmentUploadProgress(Number.NaN)).toBe("0%");
74 });
75});
apps/web/src/lib/attachmentUploadState.ts created+54
...@@ -0,0 +1,54 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3export type ReadyAttachmentUpload = {
4 readonly status: "ready";
5 readonly environmentId: EnvironmentId;
6 readonly attachmentId: string;
7};
8
9export type AttachmentUploadState =
10 | {
11 readonly status: "uploading";
12 readonly environmentId: EnvironmentId;
13 readonly progress: number;
14 readonly previous?: ReadyAttachmentUpload;
15 }
16 | ReadyAttachmentUpload
17 | {
18 readonly status: "failed";
19 readonly environmentId: EnvironmentId;
20 readonly reason: string;
21 readonly attachmentId?: string;
22 readonly previous?: ReadyAttachmentUpload;
23 };
24
25export function attachmentUploadBlockReason(input: {
26 readonly imageIds: ReadonlyArray<string>;
27 readonly uploadsByImageId: Readonly<Record<string, AttachmentUploadState>>;
28 readonly environmentId: EnvironmentId;
29}): string | null {
30 let pending = 0;
31 let failed = 0;
32
33 for (const imageId of input.imageIds) {
34 const upload = input.uploadsByImageId[imageId];
35 if (upload?.status === "failed" && upload.environmentId === input.environmentId) {
36 failed += 1;
37 } else if (upload?.status !== "ready" || upload.environmentId !== input.environmentId) {
38 pending += 1;
39 }
40 }
41
42 if (failed > 0) {
43 return failed === 1 ? "Retry or remove the failed image" : "Retry or remove the failed images";
44 }
45 if (pending > 0) {
46 return pending === 1 ? "Image still uploading" : "Images still uploading";
47 }
48 return null;
49}
50
51export function formatAttachmentUploadProgress(progress: number): string {
52 const bounded = Math.max(0, Math.min(1, Number.isFinite(progress) ? progress : 0));
53 return `${Math.floor(bounded * 100)}%`;
54}
apps/web/src/lib/composerDraftUploads.ts created+23
...@@ -0,0 +1,23 @@
1import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
2
3import { type DraftId, useComposerDraftStore } from "../composerDraftStore";
4import { releaseAttachmentUploads } from "./attachmentUploadQueue";
5
6export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void {
7 const draft = useComposerDraftStore.getState().getComposerDraft(target);
8 if (draft) {
9 releaseAttachmentUploads(draft.images);
10 }
11}
12
13export function releaseProjectDraftUploads(projectRef: ScopedProjectRef): void {
14 const store = useComposerDraftStore.getState();
15 for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) {
16 if (
17 session.environmentId === projectRef.environmentId &&
18 session.projectId === projectRef.projectId
19 ) {
20 releaseAttachmentUploads(store.draftsByThreadKey[draftKey]?.images ?? []);
21 }
22 }
23}
apps/web/src/state/attachments.ts created+15
...@@ -0,0 +1,15 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime";
3
4import { connectionAtomRuntime } from "../connection/runtime";
5
6export const attachmentEnvironment = {
7 createUploadUrl: createEnvironmentRpcCommand(connectionAtomRuntime, {
8 label: "environment-command:attachments:create-upload-url",
9 tag: WS_METHODS.attachmentsCreateUploadUrl,
10 }),
11 remove: createEnvironmentRpcCommand(connectionAtomRuntime, {
12 label: "environment-command:attachments:delete",
13 tag: WS_METHODS.attachmentsDelete,
14 }),
15};
docs/user/composer.md+3
...@@ -4,6 +4,9 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee...@@ -4,6 +4,9 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee
4composer and shows how many characters need to be removed. Shorten the draft or split it into4composer and shows how many characters need to be removed. Shorten the draft or split it into
5multiple messages, then send again in the same thread.5multiple messages, then send again in the same thread.
66
7On servers that support direct uploads, images upload as soon as you add them. The send button
8becomes available after every upload finishes. Failed uploads can be retried or removed.
9
7## Commands and skills10## Commands and skills
811
9Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,12Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,
packages/contracts/src/assets.test.ts created+30
...@@ -0,0 +1,30 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { AttachmentCreateUploadUrlInput } from "./assets.ts";
5import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "./orchestration.ts";
6
7const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput);
8
9const uploadInput = {
10 name: "screenshot.png",
11 mimeType: "image/png",
12 sizeBytes: 3,
13} as const;
14
15describe("AttachmentCreateUploadUrlInput", () => {
16 it("accepts supported image attachments", () => {
17 expect(isUploadInput(uploadInput)).toBe(true);
18 });
19
20 it("rejects image types that providers do not support", () => {
21 expect(isUploadInput({ ...uploadInput, mimeType: "image/svg+xml" })).toBe(false);
22 });
23
24 it("rejects empty and oversized uploads", () => {
25 expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false);
26 expect(
27 isUploadInput({ ...uploadInput, sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }),
28 ).toBe(false);
29 });
30});
packages/contracts/src/assets.ts+41-2
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
22
3import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { ProjectFaviconPath } from "./orchestration.ts";4import {
5 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
6 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
7 ProjectFaviconPath,
8} from "./orchestration.ts";
59
6const ASSET_PATH_MAX_LENGTH = 1024;10const ASSET_PATH_MAX_LENGTH = 1024;
711
...@@ -36,6 +40,41 @@ export const AssetCreateUrlResult = Schema.Struct({...@@ -36,6 +40,41 @@ export const AssetCreateUrlResult = Schema.Struct({
36});40});
37export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;41export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
3842
43export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000;
44
45export const AttachmentCreateUploadUrlInput = Schema.Struct({
46 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
47 mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES),
48 sizeBytes: NonNegativeInt.check(
49 Schema.isGreaterThanOrEqualTo(1),
50 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
51 ),
52});
53export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type;
54
55export const AttachmentCreateUploadUrlResult = Schema.Struct({
56 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
57 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
58 expiresAt: Schema.Number,
59});
60export type AttachmentCreateUploadUrlResult = typeof AttachmentCreateUploadUrlResult.Type;
61
62export const AttachmentDeleteInput = Schema.Struct({
63 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
64});
65export type AttachmentDeleteInput = typeof AttachmentDeleteInput.Type;
66
67export class AttachmentUploadSigningKeyError extends Schema.TaggedErrorClass<AttachmentUploadSigningKeyError>()(
68 "AttachmentUploadSigningKeyError",
69 {
70 cause: Schema.Defect(),
71 },
72) {
73 override get message(): string {
74 return "Failed to load the attachment upload signing key.";
75 }
76}
77
39export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceContextNotFoundError>()(78export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceContextNotFoundError>()(
40 "AssetWorkspaceContextNotFoundError",79 "AssetWorkspaceContextNotFoundError",
41 {80 {
packages/contracts/src/environment.test.ts+13
...@@ -26,4 +26,17 @@ describe("ExecutionEnvironmentDescriptor", () => {...@@ -26,4 +26,17 @@ describe("ExecutionEnvironmentDescriptor", () => {
26 }).capabilities.pullRequests,26 }).capabilities.pullRequests,
27 ).toBe(true);27 ).toBe(true);
28 });28 });
29
30 it("treats a missing attachment upload capability as unsupported", () => {
31 expect(decodeDescriptor(descriptor).capabilities.attachmentUploads).toBeUndefined();
32 });
33
34 it("preserves an advertised attachment upload capability", () => {
35 expect(
36 decodeDescriptor({
37 ...descriptor,
38 capabilities: { ...descriptor.capabilities, attachmentUploads: true },
39 }).capabilities.attachmentUploads,
40 ).toBe(true);
41 });
29});42});
packages/contracts/src/environment.ts+2
...@@ -48,6 +48,8 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type;...@@ -48,6 +48,8 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type;
48export const ExecutionEnvironmentCapabilities = Schema.Struct({48export const ExecutionEnvironmentCapabilities = Schema.Struct({
49 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),49 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
50 connectionProbe: Schema.optionalKey(Schema.Boolean),50 connectionProbe: Schema.optionalKey(Schema.Boolean),
51 /** Missing on older servers, which still accept inline image attachments. */
52 attachmentUploads: Schema.optionalKey(Schema.Boolean),
51 /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on53 /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on
52 servers from before the pull-request workspace shipped, so clients must not probe them. */54 servers from before the pull-request workspace shipped, so clients must not probe them. */
53 pullRequests: Schema.optionalKey(Schema.Boolean),55 pullRequests: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.test.ts+43
...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
5import {5import {
6 DEFAULT_PROVIDER_INTERACTION_MODE,6 DEFAULT_PROVIDER_INTERACTION_MODE,
7 DEFAULT_RUNTIME_MODE,7 DEFAULT_RUNTIME_MODE,
8 ClientOrchestrationCommand,
8 ModelSelection,9 ModelSelection,
9 OrchestrationCommand,10 OrchestrationCommand,
10 OrchestrationDispatchCommandError,11 OrchestrationDispatchCommandError,
...@@ -35,6 +36,7 @@ const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateComma...@@ -35,6 +36,7 @@ const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateComma
35const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);36const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
36const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);37const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
37const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);38const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
39const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand);
38const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(40const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
39 ThreadTurnStartRequestedPayload,41 ThreadTurnStartRequestedPayload,
40);42);
...@@ -241,6 +243,47 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()...@@ -241,6 +243,47 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()
241 }),243 }),
242);244);
243245
246it.effect("accepts both inline and uploaded image attachments from clients", () =>
247 Effect.gen(function* () {
248 const command = yield* decodeClientOrchestrationCommand({
249 type: "thread.turn.start",
250 commandId: "cmd-turn-attachments",
251 threadId: "thread-1",
252 message: {
253 messageId: "msg-attachments",
254 role: "user",
255 text: "hello",
256 attachments: [
257 {
258 type: "image",
259 name: "legacy.png",
260 mimeType: "image/png",
261 sizeBytes: 3,
262 dataUrl: "data:image/png;base64,YWJj",
263 },
264 {
265 type: "image",
266 id: "pending-00000000-0000-4000-8000-000000000001",
267 name: "uploaded.png",
268 mimeType: "image/png",
269 sizeBytes: 3,
270 },
271 ],
272 },
273 runtimeMode: "full-access",
274 interactionMode: "default",
275 createdAt: "2026-01-01T00:00:00.000Z",
276 });
277
278 if (command.type !== "thread.turn.start") {
279 assert.fail(`Expected thread.turn.start, received ${command.type}.`);
280 }
281 assert.strictEqual(command.message.attachments.length, 2);
282 assert.strictEqual("dataUrl" in command.message.attachments[0]!, true);
283 assert.strictEqual("id" in command.message.attachments[1]!, true);
284 }),
285);
286
244it.effect("preserves explicit provider and runtime mode in thread.turn.start", () =>287it.effect("preserves explicit provider and runtime mode in thread.turn.start", () =>
245 Effect.gen(function* () {288 Effect.gen(function* () {
246 const parsed = yield* decodeThreadTurnStartCommand({289 const parsed = yield* decodeThreadTurnStartCommand({
packages/contracts/src/orchestration.ts+1-1
...@@ -863,7 +863,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({...@@ -863,7 +863,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
863 messageId: MessageId,863 messageId: MessageId,
864 role: Schema.Literal("user"),864 role: Schema.Literal("user"),
865 text: Schema.String,865 text: Schema.String,
866 attachments: Schema.Array(UploadChatAttachment),866 attachments: Schema.Array(Schema.Union([UploadChatAttachment, ChatAttachment])),
867 }),867 }),
868 modelSelection: Schema.optional(ModelSelection),868 modelSelection: Schema.optional(ModelSelection),
869 titleSeed: Schema.optional(TrimmedNonEmptyString),869 titleSeed: Schema.optional(TrimmedNonEmptyString),
packages/contracts/src/rpc.ts+24-1
...@@ -18,7 +18,15 @@ import {...@@ -18,7 +18,15 @@ import {
18 FilesystemBrowseResult,18 FilesystemBrowseResult,
19 FilesystemBrowseError,19 FilesystemBrowseError,
20} from "./filesystem.ts";20} from "./filesystem.ts";
21import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";21import {
22 AssetAccessError,
23 AssetCreateUrlInput,
24 AssetCreateUrlResult,
25 AttachmentCreateUploadUrlInput,
26 AttachmentCreateUploadUrlResult,
27 AttachmentDeleteInput,
28 AttachmentUploadSigningKeyError,
29} from "./assets.ts";
22import {30import {
23 GitActionProgressEvent,31 GitActionProgressEvent,
24 VcsSwitchRefInput,32 VcsSwitchRefInput,
...@@ -215,6 +223,8 @@ export const WS_METHODS = {...@@ -215,6 +223,8 @@ export const WS_METHODS = {
215 // Filesystem methods223 // Filesystem methods
216 filesystemBrowse: "filesystem.browse",224 filesystemBrowse: "filesystem.browse",
217 assetsCreateUrl: "assets.createUrl",225 assetsCreateUrl: "assets.createUrl",
226 attachmentsCreateUploadUrl: "attachments.createUploadUrl",
227 attachmentsDelete: "attachments.delete",
218228
219 // Provider methods229 // Provider methods
220 providerUploadFeedback: "provider.uploadFeedback",230 providerUploadFeedback: "provider.uploadFeedback",
...@@ -673,6 +683,17 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {...@@ -673,6 +683,17 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
673 error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),683 error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),
674});684});
675685
686export const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, {
687 payload: AttachmentCreateUploadUrlInput,
688 success: AttachmentCreateUploadUrlResult,
689 error: Schema.Union([AttachmentUploadSigningKeyError, EnvironmentAuthorizationError]),
690});
691
692export const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, {
693 payload: AttachmentDeleteInput,
694 error: EnvironmentAuthorizationError,
695});
696
676export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, {697export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, {
677 payload: ProviderUploadFeedbackInput,698 payload: ProviderUploadFeedbackInput,
678 success: ProviderUploadFeedbackResult,699 success: ProviderUploadFeedbackResult,
...@@ -1048,6 +1069,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1048,6 +1069,8 @@ export const WsRpcGroup = RpcGroup.make(
1048 WsShellOpenInEditorRpc,1069 WsShellOpenInEditorRpc,
1049 WsFilesystemBrowseRpc,1070 WsFilesystemBrowseRpc,
1050 WsAssetsCreateUrlRpc,1071 WsAssetsCreateUrlRpc,
1072 WsAttachmentsCreateUploadUrlRpc,
1073 WsAttachmentsDeleteRpc,
1051 WsProviderUploadFeedbackRpc,1074 WsProviderUploadFeedbackRpc,
1052 WsSubscribeVcsStatusRpc,1075 WsSubscribeVcsStatusRpc,
1053 WsVcsPullRpc,1076 WsVcsPullRpc,