diff --git a/packages/contracts/src/attachments.ts b/packages/contracts/src/attachments.ts index 3f42cd8e..7d84806c 100644 --- a/packages/contracts/src/attachments.ts +++ b/packages/contracts/src/attachments.ts @@ -1,8 +1,13 @@ import { Schema } from "effect"; import { uuidSchema } from "./uuid.ts"; +const BYTES_PER_MEGABYTE = 1024 * 1024; + /** The largest file a person can attach to a message, in bytes. */ -export const MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024; +export const MAX_ATTACHMENT_BYTES = 30 * BYTES_PER_MEGABYTE; + +/** `MAX_ATTACHMENT_BYTES` in megabytes, as people are told it. */ +export const MAX_ATTACHMENT_MEGABYTES = MAX_ATTACHMENT_BYTES / BYTES_PER_MEGABYTE; /** The most files one message can carry, and so the most a person can have uploaded and not yet sent. */ export const MAX_ATTACHMENTS_PER_MESSAGE = 20; diff --git a/packages/contracts/src/http/groups/chats.ts b/packages/contracts/src/http/groups/chats.ts index 4ce08eaa..5b0631b4 100644 --- a/packages/contracts/src/http/groups/chats.ts +++ b/packages/contracts/src/http/groups/chats.ts @@ -81,6 +81,15 @@ export class ChatsApi extends HttpApiGroup.make("chats") success: uploadedAttachmentSchema.pipe(HttpApiSchema.status(201)), error: [BadRequest, PayloadTooLarge, Conflict, ...refused], }), + /** + * Removes one of the caller's uploads before it is sent. Removing one + * already gone, sent, or someone else's changes nothing. + */ + HttpApiEndpoint.delete("removeFile", "/chats/:chatId/files/:fileId", { + params: { chatId: Schema.String, fileId: Schema.String }, + success: HttpApiSchema.Empty(204), + error: refused, + }), /** A file attached to a message in the chat, with its own media type. */ HttpApiEndpoint.get("file", "/chats/:chatId/files/:fileId", { params: { chatId: Schema.String, fileId: Schema.String }, diff --git a/packages/core/src/conversations/chats/chat-files.ts b/packages/core/src/conversations/chats/chat-files.ts index ae4e16b4..e28b6c91 100644 --- a/packages/core/src/conversations/chats/chat-files.ts +++ b/packages/core/src/conversations/chats/chat-files.ts @@ -36,6 +36,14 @@ export interface Interface { | BlobStore.Failure, CurrentActor.Service >; + /** + * Removes one of the actor's uploads to the chat before it is sent, such as + * one taken off the message they are writing. Anything else is left as it is. + */ + readonly removeUpload: (input: { + chatId: string; + fileId: string; + }) => Effect.Effect; /** A file attached to a message in the chat. An upload not yet sent is not found. */ readonly read: (input: { chatId: string; @@ -71,6 +79,15 @@ export const make = Effect.gen(function* () { }); }), ), + removeUpload: ({ chatId, fileId }) => + operation( + "removeUpload", + Effect.gen(function* () { + const { mainThreadId } = yield* visibility.chat(chatId); + const { userId } = yield* CurrentActor.Service; + yield* files.removeUnsentUpload({ threadId: mainThreadId, uploaderId: userId, fileId }); + }), + ), read: ({ chatId, fileId }) => operation( "read", diff --git a/packages/core/src/conversations/thread-files/thread-files.test.ts b/packages/core/src/conversations/thread-files/thread-files.test.ts index 381ca53c..5aa06e7d 100644 --- a/packages/core/src/conversations/thread-files/thread-files.test.ts +++ b/packages/core/src/conversations/thread-files/thread-files.test.ts @@ -306,6 +306,36 @@ describe.skipIf(!process.env.DATABASE_URL)("thread files, against Postgres", () await expect(upload(userId, "three.txt")).resolves.toMatchObject({ name: "three.txt" }); }); + it("removes an unsent upload for its uploader only, and never a sent one", async () => { + const { threadId, userId } = await aThread(); + const other = await aRoutineOwner(); + const upload = (name: string) => + files.upload({ + threadId, + uploaderId: userId, + name, + maxSize: 1024, + maxUnsent: 20, + content: text(name), + }); + const removed = await upload("removed.txt"); + const kept = await upload("kept.txt"); + const sent = await upload("sent.txt"); + await send(threadId, userId, [sent.id]); + const rows = () => + onDatabase((db) => + db.select({ id: threadFile.id }).from(threadFile).where(eq(threadFile.threadId, threadId)), + ); + + await files.removeUnsentUpload({ threadId, uploaderId: other.userId, fileId: kept.id }); + await files.removeUnsentUpload({ threadId, uploaderId: userId, fileId: sent.id }); + await files.removeUnsentUpload({ threadId, uploaderId: userId, fileId: removed.id }); + // Again, once it is gone. + await files.removeUnsentUpload({ threadId, uploaderId: userId, fileId: removed.id }); + + expect((await rows()).map(({ id }) => id).sort()).toEqual([kept.id, sent.id].sort()); + }); + it("removes uploads left unsent, with their blobs, and nothing else", async () => { const { threadId, userId } = await aThread(); const upload = (name: string) => diff --git a/packages/core/src/conversations/thread-files/thread-files.ts b/packages/core/src/conversations/thread-files/thread-files.ts index 86b64919..408decb1 100644 --- a/packages/core/src/conversations/thread-files/thread-files.ts +++ b/packages/core/src/conversations/thread-files/thread-files.ts @@ -1,8 +1,19 @@ export * as ThreadFiles from "./thread-files.ts"; -import { userText } from "@sugabots/errors"; +import { type DomainError, userText } from "@sugabots/errors"; import { and, count, eq, inArray, isNotNull, lt, not, sql } from "drizzle-orm"; -import { Config, Context, Data, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; +import { + Config, + Context, + Data, + Duration, + Effect, + ErrorReporter, + Layer, + Option, + Schema, + Stream, +} from "effect"; import { BlobStore } from "../../blob-store/blob-store.ts"; import { query, serviceOperations, transaction } from "../../database/database.ts"; import { threadFile } from "../../database/schema.ts"; @@ -72,6 +83,16 @@ export interface Interface { }) => Effect.Effect; /** The files in the thread kept as tool calls' results: each call's file id, by the call's id. */ readonly resultFiles: (threadId: string) => Effect.Effect>; + /** + * Removes `fileId` when it is one of `uploaderId`'s unsent uploads in the + * thread, such as one taken off a message before it was sent. Anything + * else, a file already gone included, is left as it is. + */ + readonly removeUnsentUpload: (file: { + threadId: string; + uploaderId: string; + fileId: string; + }) => Effect.Effect; /** * Removes every upload still unsent `unsentFor` after it was uploaded, and * returns how many. A blob that cannot be removed is left to go with its @@ -124,6 +145,16 @@ export const make = Effect.gen(function* () { return { id, contentType, size }; }); + /** Removes an unsent upload's blob, once its row is gone; one that cannot be removed goes with the thread's folder. */ + const removeUploadBlob = (threadId: string, fileId: string) => + blobs + .delete(fileKey(threadId, fileId)) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Removing an unsent upload's blob failed", cause), + ), + ); + return Service.of({ write: ({ threadId, toolCallId, content }) => operation("write", keep(threadId, content, { kind: "tool_result", toolCallId })), @@ -237,6 +268,29 @@ export const make = Effect.gen(function* () { ); }), ), + removeUnsentUpload: ({ threadId, uploaderId, fileId }) => + operation( + "removeUnsentUpload", + Effect.gen(function* () { + // The id comes from the uploader's client, and Postgres rejects a + // uuid comparison against anything that is not one. + if (!isUuid(fileId)) return; + const removed = yield* query((db) => + db + .delete(threadFile) + .where( + and( + eq(threadFile.id, fileId), + eq(threadFile.threadId, threadId), + eq(threadFile.uploaderId, uploaderId), + unsentUpload, + ), + ) + .returning({ id: threadFile.id }), + ); + if (removed.length > 0) yield* removeUploadBlob(threadId, fileId); + }), + ), removeUnsentUploads: (unsentFor) => operation( "removeUnsentUploads", @@ -256,18 +310,9 @@ export const make = Effect.gen(function* () { ) .returning({ id: threadFile.id, threadId: threadFile.threadId }), ); - yield* Effect.forEach( - removed, - ({ id, threadId }) => - blobs - .delete(fileKey(threadId, id)) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("Removing an unsent upload's blob failed", cause), - ), - ), - { discard: true }, - ); + yield* Effect.forEach(removed, ({ id, threadId }) => removeUploadBlob(threadId, id), { + discard: true, + }); return removed.length; }), ), @@ -323,7 +368,20 @@ export class FileNotFound } } -export class UploadsNotFound extends Data.TaggedError("UploadsNotFound") implements UserFacing { +/** + * A file to send is not one of the sender's unsent uploads, such as one cleared + * for waiting too long. A domain error, so a client can tell it apart by its + * tag rather than its wording. + */ +export class UploadsNotFound + extends Schema.TaggedError()("UploadsNotFound", {}) + implements DomainError +{ + readonly isRetryable = false; + override readonly [ErrorReporter.severity] = "Info" as const; + override get message() { + return "A file to send is not one of the sender's unsent uploads"; + } get userMessage() { return userText`A file to send is no longer there. Remove it and attach it again`; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 83460194..cdcb3e96 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -20,7 +20,7 @@ import { defaultTokenStore, type TokenStore } from "./tokens.ts"; export type { AuthApi } from "./auth.ts"; export { isEmailUnverified, isResetLinkInvalid } from "./auth.ts"; -export { failureForStatus, isApiFailure } from "./errors.ts"; +export { failureForStatus, failureFromResponse, isApiFailure } from "./errors.ts"; export type { EventStream, EventStreamOptions, EventsApi } from "./events.ts"; export { defaultTokenStore, diff --git a/packages/server/src/routes/chats/files.test.ts b/packages/server/src/routes/chats/files.test.ts index 62b5bdda..a7f237df 100644 --- a/packages/server/src/routes/chats/files.test.ts +++ b/packages/server/src/routes/chats/files.test.ts @@ -123,6 +123,21 @@ describe("uploading a chat file", () => { }); }); +describe("removing an unsent upload", () => { + it("removes it for the person asking, answering with no content", async () => { + let removed: unknown; + const response = await app({ + removeUpload: (input) => { + removed = input; + return Effect.void; + }, + }).request(`/chats/${CHAT}/files/${FILE}`, { method: "DELETE", headers: signedIn }); + + expect(response.status).toBe(204); + expect(removed).toEqual({ chatId: CHAT, fileId: FILE }); + }); +}); + describe("serving a chat file", () => { it("shows an image in place, as its own type and never sniffed for another", async () => { const response = await serving({ diff --git a/packages/server/src/routes/chats/routes.ts b/packages/server/src/routes/chats/routes.ts index 2e1574e2..29557d6e 100644 --- a/packages/server/src/routes/chats/routes.ts +++ b/packages/server/src/routes/chats/routes.ts @@ -78,6 +78,11 @@ export const chatRoutes = HttpApiBuilder.group(ServerApi, "chats", (handlers) => }); }).pipe(Effect.catchTags(storeOutagesAsDefects), asSessionUser, asHttpError(fileErrors)), ) + .handle("removeFile", ({ params }) => + files + .removeUpload({ chatId: params.chatId, fileId: params.fileId }) + .pipe(asSessionUser, asHttpError(refusals)), + ) .handle("file", ({ params }) => files .read({ chatId: params.chatId, fileId: params.fileId }) diff --git a/packages/web/src/app.css b/packages/web/src/app.css index 87f731ee..ea752c40 100644 --- a/packages/web/src/app.css +++ b/packages/web/src/app.css @@ -114,6 +114,19 @@ /* Green text for something that worked. Both pass AA on a panel. */ --success-text: light-dark(#1a7f37, #4ade80); + /* + * A file attached in the composer: the page drawn for a document and the badge + * naming its type. One that failed uses the destructive colours. + */ + --file-page: light-dark(#ffffff, #e7e8ec); + --file-page-line: light-dark(#d4d6dc, #b9bcc4); + --file-page-heading: light-dark(#9a9ea8, #8b8f99); + --file-badge-sheet: #1a7a43; + --file-badge-other: light-dark(#5f646e, #4b5060); + /* Over the chat while files are dragged onto it, and the wash inside its dashed edge. */ + --drop-veil: light-dark(rgba(255, 255, 255, 0.8), rgba(11, 12, 15, 0.8)); + --drop-target: light-dark(oklch(0.96 0.03 268 / 0.7), oklch(0.2 0.03 268 / 0.55)); + /* A person with no photo: initials on a grey disc. */ --person-avatar: light-dark(#dcdee3, #3a3d45); --person-avatar-foreground: light-dark(#3d4048, #dfe1e6); @@ -183,6 +196,13 @@ --color-success-text: var(--success-text); --color-success: var(--success); --color-destructive: var(--destructive); + --color-file-page: var(--file-page); + --color-file-page-line: var(--file-page-line); + --color-file-page-heading: var(--file-page-heading); + --color-file-badge-sheet: var(--file-badge-sheet); + --color-file-badge-other: var(--file-badge-other); + --color-drop-veil: var(--drop-veil); + --color-drop-target: var(--drop-target); --color-person-avatar: var(--person-avatar); --color-person-avatar-foreground: var(--person-avatar-foreground); /* Set on an element by `botColorVariables()` from `@sugabots/avatars`. */ diff --git a/packages/web/src/lib/chat-files.ts b/packages/web/src/lib/chat-files.ts new file mode 100644 index 00000000..d4726423 --- /dev/null +++ b/packages/web/src/lib/chat-files.ts @@ -0,0 +1,74 @@ +import { type UploadedAttachment, uploadedAttachmentSchema } from "@sugabots/contracts"; +import { failureFromResponse } from "@sugabots/sdk"; +import { Effect, Schema } from "effect"; +import { client } from "@/api.ts"; + +const decodeUploadedAttachment = Schema.decodeUnknownSync(uploadedAttachmentSchema); + +/** An upload that never got an answer from the API: the connection dropped, or it was stopped. */ +export class UploadInterrupted extends Error { + constructor() { + super("The upload did not finish"); + this.name = "UploadInterrupted"; + } +} + +/** + * Uploads `file` to the chat, ready to attach to a message, reporting how much + * of it has gone as a fraction from 0 to 1. Sent with XMLHttpRequest, because + * fetch cannot report an upload's progress. Fails with the API's failure when + * it refuses the file, or `UploadInterrupted` when there is no answer, + * including when `signal` aborts it. + */ +export function uploadChatFile({ + chatId, + file, + onProgress, + signal, +}: { + chatId: string; + file: File; + onProgress: (fraction: number) => void; + signal: AbortSignal; +}): Promise { + return new Promise((resolve, reject) => { + const query = new URLSearchParams({ name: file.name }); + // Left out when the browser has no type for it, so the API takes one from the name. + if (file.type) query.set("type", file.type); + const request = new XMLHttpRequest(); + request.open("POST", `${client.baseUrl}/chats/${encodeURIComponent(chatId)}/files?${query}`); + request.withCredentials = true; + request.responseType = "json"; + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) onProgress(event.loaded / event.total); + }); + request.addEventListener("load", () => { + if (request.status !== 201) { + reject(failureFromResponse(request.response, request.status)); + return; + } + try { + resolve(decodeUploadedAttachment(request.response)); + } catch (error) { + reject(error); + } + }); + request.addEventListener("error", () => reject(new UploadInterrupted())); + request.addEventListener("abort", () => reject(new UploadInterrupted())); + if (signal.aborted) { + reject(new UploadInterrupted()); + return; + } + signal.addEventListener("abort", () => request.abort(), { once: true }); + request.send(file); + }); +} + +/** + * Removes an upload that will not be sent, so it stops counting toward the + * files a person can have waiting. Nothing waits on it, and a failure is left + * alone: the API clears unsent uploads by itself after a day. + */ +export function removeChatFile(chatId: string, fileId: string): void { + Effect.runPromise(client.api.chats.removeFile({ params: { chatId, fileId } })).catch(() => {}); +} diff --git a/packages/web/src/lib/composer-attachments.test.tsx b/packages/web/src/lib/composer-attachments.test.tsx new file mode 100644 index 00000000..b6789f8b --- /dev/null +++ b/packages/web/src/lib/composer-attachments.test.tsx @@ -0,0 +1,433 @@ +import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENTS_PER_MESSAGE } from "@sugabots/contracts"; +import { BadRequest } from "@sugabots/contracts/http"; +import { userText } from "@sugabots/errors"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sendReadiness, useComposerAttachments } from "@/lib/composer-attachments.ts"; +import { client } from "@/test-client.ts"; + +vi.mock("@/api.ts", () => import("@/test-client.ts")); + +const chatId = "0199a3a0-0000-7000-8000-0000000000e1"; +const fileId = "0199a3a0-0000-7000-8000-0000000000f1"; + +/** + * Stands in for the browser's XMLHttpRequest, the boundary uploads cross, so + * a test says how far each upload has got and what the API answers. + */ +class FakeRequest { + static made: FakeRequest[] = []; + method = ""; + url = ""; + withCredentials = false; + responseType = ""; + status = 0; + response: unknown = null; + body: unknown; + aborted = false; + private readonly listeners = new Map void)[]>(); + private readonly uploadListeners = new Map void)[]>(); + upload = { + addEventListener: (type: string, listener: (event: unknown) => void) => { + this.uploadListeners.set(type, [...(this.uploadListeners.get(type) ?? []), listener]); + }, + }; + + open(method: string, url: string) { + this.method = method; + this.url = url; + } + + addEventListener(type: string, listener: () => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + send(body: unknown) { + this.body = body; + FakeRequest.made.push(this); + } + + abort() { + this.aborted = true; + this.emit("abort"); + } + + progress(loaded: number, total: number) { + for (const listener of this.uploadListeners.get("progress") ?? []) { + listener({ lengthComputable: true, loaded, total }); + } + } + + /** The connection drops before the API answers. */ + fail() { + this.emit("error"); + } + + answer(status: number, response: unknown) { + this.status = status; + this.response = response; + this.emit("load"); + } + + private emit(type: string) { + for (const listener of this.listeners.get(type) ?? []) listener(); + } +} + +function pdf(name = "report.pdf"): File { + return new File(["%PDF"], name, { type: "application/pdf" }); +} + +function uploadedAs(file: File, id = fileId) { + return { id, name: file.name, contentType: file.type, size: file.size }; +} + +/** Attaches `file` and lets the API accept it as `id`. */ +async function attachUploaded( + attachments: { current: ReturnType }, + file: File, + id = fileId, +) { + act(() => attachments.current.attach?.([file])); + act(() => FakeRequest.made.at(-1)?.answer(201, uploadedAs(file, id))); + await settle(); +} + +/** Lets the promise callbacks an answer settles run. */ +async function settle() { + await act(async () => {}); +} + +beforeEach(() => { + FakeRequest.made = []; + vi.stubGlobal("XMLHttpRequest", FakeRequest); + client.api.chats.removeFile.mockReturnValue(Effect.void); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("useComposerAttachments", () => { + it("uploads a file as soon as it is attached, with its progress, ready to send", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const file = pdf(); + act(() => result.current.attach?.([file])); + + const [request] = FakeRequest.made; + expect(request?.method).toBe("POST"); + expect(request?.url).toBe( + `https://api.test/chats/${chatId}/files?name=report.pdf&type=application%2Fpdf`, + ); + expect(request?.withCredentials).toBe(true); + expect(request?.body).toBe(file); + expect(sendReadiness(result.current.items)).toEqual({ ready: false, failed: 0, uploading: 1 }); + + act(() => request?.progress(1, 4)); + expect(result.current.items[0]?.upload).toEqual({ state: "uploading", progress: 0.25 }); + + act(() => request?.answer(201, uploadedAs(file))); + await settle(); + expect(result.current.items[0]?.upload).toEqual({ state: "uploaded", fileId }); + expect(sendReadiness(result.current.items)).toEqual({ ready: true }); + }); + + it("leaves the type for the API to work out when the browser has none", () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + act(() => result.current.attach?.([new File(["x"], "notes.weird")])); + expect(FakeRequest.made[0]?.url).toBe( + `https://api.test/chats/${chatId}/files?name=notes.weird`, + ); + }); + + it("never uploads a file over the size limit, and it holds the message back", () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const recording = new File(["x"], "call.mp4", { type: "video/mp4" }); + Object.defineProperty(recording, "size", { value: MAX_ATTACHMENT_BYTES + 1 }); + act(() => result.current.attach?.([recording])); + + expect(FakeRequest.made).toHaveLength(0); + expect(result.current.items[0]?.upload).toEqual({ state: "tooLarge" }); + expect(result.current.takeForSending("")).toBeUndefined(); + }); + + it("attaches files up to the limit and counts the rest as left out", () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const files = Array.from({ length: MAX_ATTACHMENTS_PER_MESSAGE + 3 }, (_, index) => + pdf(`invoice-${index}.pdf`), + ); + act(() => result.current.attach?.(files)); + + expect(result.current.items.map((item) => item.file)).toEqual( + files.slice(0, MAX_ATTACHMENTS_PER_MESSAGE), + ); + expect(result.current.leftOut).toBe(3); + + act(() => result.current.attach?.([pdf("one-more.pdf")])); + expect(result.current.items).toHaveLength(MAX_ATTACHMENTS_PER_MESSAGE); + expect(result.current.leftOut).toBe(1); + }); + + it("stops an upload when its file is removed, and removes the file if it finished anyway", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const file = pdf(); + act(() => result.current.attach?.([file, pdf("second.pdf")])); + const [first, second] = FakeRequest.made; + + act(() => result.current.remove(result.current.items[0]?.key ?? "")); + expect(first?.aborted).toBe(true); + expect(result.current.items.map((item) => item.file.name)).toEqual(["second.pdf"]); + + // The answer arrives, but the chip goes before it is read. + act(() => { + second?.answer(201, uploadedAs(file, "0199a3a0-0000-7000-8000-0000000000f2")); + result.current.remove(result.current.items[0]?.key ?? ""); + }); + await settle(); + expect(result.current.items).toEqual([]); + expect(client.api.chats.removeFile).toHaveBeenCalledWith({ + params: { chatId, fileId: "0199a3a0-0000-7000-8000-0000000000f2" }, + }); + }); + + it("removes an uploaded file from the API when its chip is removed", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const file = pdf(); + act(() => result.current.attach?.([file])); + act(() => FakeRequest.made[0]?.answer(201, uploadedAs(file))); + await settle(); + + act(() => result.current.remove(result.current.items[0]?.key ?? "")); + expect(result.current.items).toEqual([]); + expect(client.api.chats.removeFile).toHaveBeenCalledWith({ params: { chatId, fileId } }); + }); + + it("keeps a refused upload on its chip with the API's reason, and retries it there", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const file = pdf(); + act(() => result.current.attach?.([file])); + const reason = "You can have 20 files waiting to be sent at most. Send or remove some first"; + act(() => FakeRequest.made[0]?.answer(409, { _tag: "Conflict", message: reason })); + await settle(); + const key = result.current.items[0]?.key; + expect(result.current.items[0]?.upload).toEqual({ + state: "failed", + reason: `${reason}.`, + retryable: true, + }); + expect(sendReadiness(result.current.items)).toEqual({ ready: false, failed: 1, uploading: 0 }); + + act(() => result.current.retry(key ?? "")); + act(() => FakeRequest.made[1]?.answer(201, uploadedAs(file))); + await settle(); + expect(result.current.items).toEqual([{ key, file, upload: { state: "uploaded", fileId } }]); + }); + + it("takes the uploaded files to send, and puts them back when the message is not sent", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const file = pdf(); + act(() => result.current.attach?.([file])); + act(() => FakeRequest.made[0]?.answer(201, uploadedAs(file))); + await settle(); + const attached = result.current.items; + + let taken: ReturnType; + act(() => { + taken = result.current.takeForSending(""); + }); + expect(taken?.fileIds).toEqual([fileId]); + expect(result.current.items).toEqual([]); + + act(() => taken?.putBack(new Error("The connection dropped"))); + expect(result.current.items).toEqual(attached); + }); + + it("stops uploads and removes the unsent files when the chat is left", async () => { + const { result, unmount } = renderHook(() => useComposerAttachments(chatId)); + const file = pdf(); + act(() => result.current.attach?.([file, pdf("second.pdf")])); + const [uploaded, inFlight] = FakeRequest.made; + act(() => uploaded?.answer(201, uploadedAs(file))); + await settle(); + + unmount(); + expect(inFlight?.aborted).toBe(true); + expect(client.api.chats.removeFile).toHaveBeenCalledWith({ params: { chatId, fileId } }); + }); + + it("has nothing to attach to until the chat exists", () => { + const { result } = renderHook(() => useComposerAttachments(undefined)); + expect(result.current.attach).toBeUndefined(); + }); + + it("sends text, files or both, but nothing empty and nothing still uploading", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + expect(result.current.takeForSending(" ")).toBeUndefined(); + expect(result.current.takeForSending("Hello")?.fileIds).toEqual([]); + + const file = pdf(); + act(() => result.current.attach?.([file])); + expect(result.current.takeForSending("Hello")).toBeUndefined(); + act(() => FakeRequest.made[0]?.answer(201, uploadedAs(file))); + await settle(); + let taken: ReturnType; + act(() => { + taken = result.current.takeForSending(""); + }); + expect(taken?.fileIds).toEqual([fileId]); + }); + + it.each([ + ["the connection drops", (request: FakeRequest) => request.fail()], + [ + "the API has trouble of its own", + (request: FakeRequest) => + request.answer(500, { _tag: "InternalServerError", message: "Something went wrong" }), + ], + ["a proxy fails in front of the API", (request: FakeRequest) => request.answer(502, "")], + ])("offers to retry an upload when %s", async (_, failUpload) => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + act(() => result.current.attach?.([pdf()])); + act(() => failUpload(FakeRequest.made[0] as FakeRequest)); + await settle(); + expect(result.current.items[0]?.upload).toMatchObject({ state: "failed", retryable: true }); + + act(() => result.current.retry(result.current.items[0]?.key ?? "")); + expect(FakeRequest.made).toHaveLength(2); + }); + + it.each([ + ["refuses its type", 400, { _tag: "BadRequest", message: "That isn't a media type" }], + ["finds it too large", 413, { _tag: "PayloadTooLarge", message: "The file is too large" }], + ])("does not retry an upload the API %s", async (_, status, body) => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + act(() => result.current.attach?.([pdf()])); + act(() => FakeRequest.made[0]?.answer(status, body)); + await settle(); + expect(result.current.items[0]?.upload).toEqual({ + state: "failed", + reason: `${body.message}.`, + retryable: false, + }); + + act(() => result.current.retry(result.current.items[0]?.key ?? "")); + expect(FakeRequest.made).toHaveLength(1); + }); + + it.each([ + ["is over 255 characters", `${"a".repeat(252)}.pdf`], + ["has a control character", "report\u0007.pdf"], + ["is only spaces", " "], + ])("never uploads a file whose name %s, and cannot retry it", (_, name) => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + act(() => result.current.attach?.([pdf(name)])); + + expect(FakeRequest.made).toHaveLength(0); + expect(result.current.items[0]?.upload).toMatchObject({ state: "failed", retryable: false }); + act(() => result.current.retry(result.current.items[0]?.key ?? "")); + expect(FakeRequest.made).toHaveLength(0); + }); + + it("uploads a file whose name is 255 characters", () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + act(() => result.current.attach?.([pdf(`${"a".repeat(251)}.pdf`)])); + expect(FakeRequest.made).toHaveLength(1); + }); + + it("puts files the API no longer had back as failed, and uploads each again on retry", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + const file = pdf(); + await attachUploaded(result, file); + + let taken: ReturnType; + act(() => { + taken = result.current.takeForSending(""); + }); + const refusal = new BadRequest({ + message: userText`A file to send is no longer there. Remove it and attach it again`, + error: { + _tag: "UploadsNotFound", + userMessage: userText`A file to send is no longer there. Remove it and attach it again`, + isRetryable: false, + }, + }); + act(() => taken?.putBack(refusal)); + + const [item] = result.current.items; + expect(item?.upload).toMatchObject({ state: "failed", retryable: true }); + expect(client.api.chats.removeFile).toHaveBeenCalledWith({ params: { chatId, fileId } }); + + const freshId = "0199a3a0-0000-7000-8000-0000000000f3"; + act(() => result.current.retry(item?.key ?? "")); + act(() => FakeRequest.made[1]?.answer(201, uploadedAs(file, freshId))); + await settle(); + expect(FakeRequest.made[1]?.body).toBe(file); + expect(result.current.items[0]?.upload).toEqual({ state: "uploaded", fileId: freshId }); + }); + + it("puts files back only once", async () => { + const { result } = renderHook(() => useComposerAttachments(chatId)); + await attachUploaded(result, pdf()); + + let taken: ReturnType; + act(() => { + taken = result.current.takeForSending(""); + }); + act(() => { + taken?.putBack(new Error("The connection dropped")); + taken?.putBack(new Error("The connection dropped")); + }); + expect(result.current.items).toHaveLength(1); + }); + + it("removes the files of a message that fails after the chat is left", async () => { + const { result, unmount } = renderHook(() => useComposerAttachments(chatId)); + await attachUploaded(result, pdf()); + let taken: ReturnType; + act(() => { + taken = result.current.takeForSending(""); + }); + + unmount(); + expect(client.api.chats.removeFile).not.toHaveBeenCalled(); + taken?.putBack(new Error("The connection dropped")); + expect(client.api.chats.removeFile).toHaveBeenCalledWith({ params: { chatId, fileId } }); + }); + + it("empties the composer for the next chat, and keeps the last chat's files out of it", async () => { + const nextChatId = "0199a3a0-0000-7000-8000-0000000000e2"; + const { result, rerender } = renderHook((id: string) => useComposerAttachments(id), { + initialProps: chatId, + }); + const sending = pdf("sending.pdf"); + await attachUploaded(result, sending); + let taken: ReturnType; + act(() => { + taken = result.current.takeForSending(""); + }); + const waiting = pdf("waiting.pdf"); + const waitingId = "0199a3a0-0000-7000-8000-0000000000f4"; + await attachUploaded(result, waiting, waitingId); + const files = Array.from({ length: MAX_ATTACHMENTS_PER_MESSAGE }, (_, index) => + pdf(`invoice-${index}.pdf`), + ); + act(() => result.current.attach?.(files)); + const stillUploading = FakeRequest.made.at(-1); + expect(result.current.leftOut).toBe(1); + + rerender(nextChatId); + expect(result.current.items).toEqual([]); + expect(result.current.leftOut).toBe(0); + expect(stillUploading?.aborted).toBe(true); + expect(client.api.chats.removeFile).toHaveBeenCalledWith({ + params: { chatId, fileId: waitingId }, + }); + + act(() => taken?.putBack(new Error("The connection dropped"))); + expect(result.current.items).toEqual([]); + expect(client.api.chats.removeFile).toHaveBeenCalledWith({ params: { chatId, fileId } }); + }); +}); diff --git a/packages/web/src/lib/composer-attachments.ts b/packages/web/src/lib/composer-attachments.ts new file mode 100644 index 00000000..46cb01aa --- /dev/null +++ b/packages/web/src/lib/composer-attachments.ts @@ -0,0 +1,293 @@ +import { + attachmentNameSchema, + MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENTS_PER_MESSAGE, +} from "@sugabots/contracts"; +import { isApiFailure } from "@sugabots/sdk"; +import { Option, Schema } from "effect"; +import { useEffect, useRef, useState } from "react"; +import { removeChatFile, UploadInterrupted, uploadChatFile } from "@/lib/chat-files.ts"; + +/** Where a file attached in the composer has got to. */ +export type AttachmentUpload = + /** `progress` runs from 0 to 1. */ + | { state: "uploading"; progress: number } + | { state: "uploaded"; fileId: string } + /** + * Not uploaded, or no longer there to send, for `reason`. Only a + * `retryable` failure can go differently when the same file is tried again. + */ + | { state: "failed"; reason: string; retryable: boolean } + /** Over `MAX_ATTACHMENT_BYTES`, so it is never uploaded. */ + | { state: "tooLarge" }; + +/** A file attached in the composer: one chip, which keeps its place in the order attached. */ +export type ComposerAttachment = { + key: string; + file: File; + upload: AttachmentUpload; +}; + +/** The files attached to the message being written, and what can be done with them. */ +export type ComposerAttachments = { + items: readonly ComposerAttachment[]; + /** How many files the latest attach left out, for going over `MAX_ATTACHMENTS_PER_MESSAGE`. */ + leftOut: number; + /** + * Adds files in order, each uploading straight away, up to the limit. + * Absent until the chat exists, since that is where they upload to. + */ + attach: ((files: readonly File[]) => void) | undefined; + /** Drops a chip, stopping its upload or removing the file it uploaded. */ + remove: (key: string) => void; + /** Uploads a file whose failure is retryable again, on the same chip. */ + retry: (key: string) => void; + /** + * Takes the uploaded files out of the composer to send with `draftText`, + * or nothing when {@link canSend} says the message cannot go. + */ + takeForSending: (draftText: string) => FilesForSending | undefined; +}; + +/** The files taken to send with a message. */ +export type FilesForSending = { + fileIds: string[]; + /** + * Returns the files to the composer, in front of any attached since, when + * the message failed with `sendFailure`. Files the API no longer had come + * back failed, to upload again. After the chat is left they are removed + * instead. Only the first call does anything. + */ + putBack: (sendFailure: unknown) => void; +}; + +/** Whether the attached files can go with a message now, and if not, how many hold it up. */ +export type SendReadiness = { ready: true } | { ready: false; failed: number; uploading: number }; + +export function sendReadiness(items: readonly ComposerAttachment[]): SendReadiness { + const failed = items.filter( + ({ upload }) => upload.state === "failed" || upload.state === "tooLarge", + ).length; + const uploading = items.filter(({ upload }) => upload.state === "uploading").length; + return failed === 0 && uploading === 0 ? { ready: true } : { ready: false, failed, uploading }; +} + +/** + * Whether a message can be sent: it has text or a file, and every file + * attached has uploaded, none still going or failed. + */ +export function canSend(draftText: string, items: readonly ComposerAttachment[]): boolean { + return sendReadiness(items).ready && (draftText.trim() !== "" || items.length > 0); +} + +/** + * The composer's attachments for the chat `chatId`. Each file uploads as soon + * as it is attached. They are kept in memory only, so leaving the chat stops + * the uploads still going, removes the files uploaded and not sent, which + * would otherwise count toward the files a person can have waiting, and + * empties the composer for the next chat. + */ +export function useComposerAttachments(chatId: string | undefined): ComposerAttachments { + const [items, setItems] = useState([]); + const [leftOut, setLeftOut] = useState(0); + // The source of truth, so a handler reads what the last one wrote without waiting for a render. + const current = useRef([]); + const inFlight = useRef(new Map()); + const nextKey = useRef(0); + /** The chat the composer is open on: files taken from any other cannot come back. */ + const openChatId = useRef(undefined); + + function commit( + change: (items: readonly ComposerAttachment[]) => readonly ComposerAttachment[], + ): void { + current.current = change(current.current); + setItems(current.current); + } + + function setUpload(key: string, upload: AttachmentUpload): void { + commit((items) => items.map((item) => (item.key === key ? { ...item, upload } : item))); + } + + function startUpload(key: string, file: File): void { + if (!chatId) return; + const controller = new AbortController(); + inFlight.current.set(key, controller); + setUpload(key, { state: "uploading", progress: 0 }); + const stillWanted = () => inFlight.current.get(key) === controller; + uploadChatFile({ + chatId, + file, + signal: controller.signal, + onProgress: (progress) => { + if (stillWanted()) setUpload(key, { state: "uploading", progress }); + }, + }).then( + (uploaded) => { + // Removed while it was finishing: it has to go from the API too. + if (!stillWanted()) { + removeChatFile(chatId, uploaded.id); + return; + } + inFlight.current.delete(key); + setUpload(key, { state: "uploaded", fileId: uploaded.id }); + }, + (failure: unknown) => { + if (!stillWanted()) return; + inFlight.current.delete(key); + setUpload(key, uploadFailure(failure)); + }, + ); + } + + function attach(files: readonly File[]): void { + const room = Math.max(0, MAX_ATTACHMENTS_PER_MESSAGE - current.current.length); + const added = files.slice(0, room).map( + (file): ComposerAttachment => ({ + key: `attachment-${nextKey.current++}`, + file, + upload: uploadToStart(file), + }), + ); + setLeftOut(files.length - added.length); + commit((items) => [...items, ...added]); + for (const item of added) { + if (item.upload.state === "uploading") startUpload(item.key, item.file); + } + } + + function remove(key: string): void { + const item = current.current.find((candidate) => candidate.key === key); + if (!item) return; + inFlight.current.get(key)?.abort(); + inFlight.current.delete(key); + if (chatId && item.upload.state === "uploaded") removeChatFile(chatId, item.upload.fileId); + setLeftOut(0); + commit((items) => items.filter((candidate) => candidate.key !== key)); + } + + function retry(key: string): void { + const item = current.current.find((candidate) => candidate.key === key); + if (item?.upload.state !== "failed" || !item.upload.retryable) return; + startUpload(key, item.file); + } + + function takeForSending(draftText: string): FilesForSending | undefined { + const taken = current.current; + if (!chatId || !canSend(draftText, taken)) return undefined; + const takenFrom = chatId; + const fileIds = uploadedFileIds(taken); + setLeftOut(0); + commit(() => []); + let returned = false; + return { + fileIds, + putBack: (sendFailure) => { + if (returned) return; + returned = true; + if (openChatId.current !== takenFrom) { + for (const fileId of fileIds) removeChatFile(takenFrom, fileId); + return; + } + if (!isFileGoneRefusal(sendFailure)) { + commit((items) => [...taken, ...items]); + return; + } + // The API attaches all of a message's files or none, without saying + // which was missing, so each uploads again. The ones still there go, + // so they stop counting toward the files a person can have waiting. + for (const fileId of fileIds) removeChatFile(takenFrom, fileId); + commit((items) => [ + ...taken.map((item): ComposerAttachment => ({ ...item, upload: FILE_GONE })), + ...items, + ]); + }, + }; + } + + useEffect(() => { + openChatId.current = chatId; + const uploads = inFlight.current; + return () => { + openChatId.current = undefined; + for (const controller of uploads.values()) controller.abort(); + uploads.clear(); + if (chatId) { + for (const fileId of uploadedFileIds(current.current)) removeChatFile(chatId, fileId); + } + current.current = []; + setItems([]); + setLeftOut(0); + }; + }, [chatId]); + + return { + items, + leftOut, + attach: chatId === undefined ? undefined : attach, + remove, + retry, + takeForSending, + }; +} + +const decodeAttachmentName = Schema.decodeUnknownOption(attachmentNameSchema); + +/** Where a newly attached file starts: uploading, or refused before it goes. */ +function uploadToStart(file: File): AttachmentUpload { + if (file.size > MAX_ATTACHMENT_BYTES) return { state: "tooLarge" }; + if (Option.isNone(decodeAttachmentName(file.name))) { + return { + state: "failed", + reason: + "Its name is too long or has characters that can't be used. Rename it and attach it again.", + retryable: false, + }; + } + return { state: "uploading", progress: 0 }; +} + +/** + * Why an upload failed, and whether trying again could work: it can after the + * connection drops, the API has trouble of its own, or a person has too many + * files waiting, but not when the API refuses the file itself. + */ +function uploadFailure(failure: unknown): AttachmentUpload { + if (failure instanceof UploadInterrupted) { + return { + state: "failed", + reason: "The upload didn't finish. Check your connection and retry.", + retryable: true, + }; + } + if (!isApiFailure(failure)) { + return { state: "failed", reason: "The upload failed.", retryable: false }; + } + return { + state: "failed", + reason: asSentence(failure.message), + retryable: failure._tag === "InternalServerError" || failure._tag === "Conflict", + }; +} + +const FILE_GONE: AttachmentUpload = { + state: "failed", + reason: "It was no longer there when the message was sent. Retry to upload it again.", + retryable: true, +}; + +/** + * Whether a send was refused because a file it carried was gone, such as one + * the API cleared for waiting too long. + */ +export function isFileGoneRefusal(failure: unknown): boolean { + return isApiFailure(failure) && failure.error?._tag === "UploadsNotFound"; +} + +function uploadedFileIds(items: readonly ComposerAttachment[]): string[] { + return items.flatMap(({ upload }) => (upload.state === "uploaded" ? [upload.fileId] : [])); +} + +/** The API's messages leave off the full stop a sentence beside others needs. */ +function asSentence(text: string): string { + return /[.!?]$/.test(text) ? text : `${text}.`; +} diff --git a/packages/web/src/screens/AgentChat.tsx b/packages/web/src/screens/AgentChat.tsx index 62899acb..1fd24169 100644 --- a/packages/web/src/screens/AgentChat.tsx +++ b/packages/web/src/screens/AgentChat.tsx @@ -8,6 +8,7 @@ import { type SessionUser, type ThreadParticipant, } from "@sugabots/contracts"; +import { isApiFailure } from "@sugabots/sdk"; import { Link } from "@tanstack/react-router"; import { cn } from "cn"; import { @@ -31,6 +32,7 @@ import { useOptimisticChatItems, useSendChatMessage, } from "@/lib/chats.ts"; +import { isFileGoneRefusal, useComposerAttachments } from "@/lib/composer-attachments.ts"; import { keepFootInView, useFollowContentGrowth } from "@/lib/follow-latest.ts"; import { agentSettingsLink } from "@/lib/links.ts"; import { listNames } from "@/lib/name-list.ts"; @@ -54,6 +56,7 @@ import { ChatHeader, type ChatPlace } from "./ChatHeader.tsx"; import { ChatThreadPanel } from "./ChatThreadPanel.tsx"; import { DesktopStage } from "./DesktopViewer.tsx"; import { DetailsSidebar } from "./DetailsSidebar.tsx"; +import { FileDropTarget } from "./FileDropTarget.tsx"; import { mentionableIn } from "./mentions.tsx"; import { queuedBehindReply } from "./queued-messages.ts"; import { @@ -120,6 +123,7 @@ export function AgentChat({ const reacting = { userId: user.id, onToggle: reactions.toggle, onAdd: reactions.add }; const [draft, setDraft] = useChatDraft(user.id, pod.id, agent.id); const [replyingTo, setReplyingTo] = useChatReplyDraft(user.id, pod.id, agent.id); + const attachments = useComposerAttachments(chat.data?.id); const [peopleOnly, setPeopleOnly] = useState(false); useTypingSignal(chat.data?.mainThreadId, draft); const peopleTyping = usePeopleTyping(chat.data?.mainThreadId, user.id); @@ -202,8 +206,10 @@ export function AgentChat({ }, []); async function submit() { + if (send.isPending) return; + const files = attachments.takeForSending(draft); + if (!files) return; const message = draft.trim(); - if (!message || send.isPending) return; const submitted = draft; const answering = replyingTo; setDraft(""); @@ -215,13 +221,15 @@ export function AgentChat({ message, peopleOnly: writingToPeople, replyTo: answering, + ...(files.fileIds.length > 0 && { attachments: files.fileIds }), }); scrollToLatest(); await sent; scrollToLatest(); - } catch { + } catch (failure) { setDraft((current) => (current === "" ? submitted : current)); setReplyingTo((current) => current ?? answering); + files.putBack(failure); } } @@ -310,112 +318,121 @@ export function AgentChat({ )} > {header} -
{ - const element = event.currentTarget; - const atFoot = - element.scrollHeight - element.scrollTop - element.clientHeight < - LATEST_IN_VIEW_TOLERANCE_PX; - followingLatest.current = atFoot; - setLatestInView(atFoot); - }} - className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto pt-3 pb-4" + - {/* Anchored to the bottom, so a short chat sits just above the composer. */} -
- {messages.isError && ( -
- Messages could not be loaded. Reload this page to try again. -
- )} - {messages.hasNextPage && ( - - )} - {messages.isSuccess && !messages.hasNextPage && } - {groups.map((group) => ( - - {group.separated && } - {group.kind === "messages" ? ( - - ) : ( - entry.threadId === group.item.threadId)} - onOpen={() => openThread(group.item.threadId)} - /> - )} - - ))} - {jump.notFound && ( -

- Could not find the message you opened in this chat. -

- )} - +
{ + const element = event.currentTarget; + const atFoot = + element.scrollHeight - element.scrollTop - element.clientHeight < + LATEST_IN_VIEW_TOLERANCE_PX; + followingLatest.current = atFoot; + setLatestInView(atFoot); + }} + className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto pt-3 pb-4" + > + {/* Anchored to the bottom, so a short chat sits just above the composer. */} +
+ {messages.isError && ( +
+ Messages could not be loaded. Reload this page to try again. +
+ )} + {messages.hasNextPage && ( + + )} + {messages.isSuccess && !messages.hasNextPage && } + {groups.map((group) => ( + + {group.separated && } + {group.kind === "messages" ? ( + + ) : ( + entry.threadId === group.item.threadId)} + onOpen={() => openThread(group.item.threadId)} + /> + )} + + ))} + {jump.notFound && ( +

+ Could not find the message you opened in this chat. +

+ )} + +
-
-
- {agent.model === null ? ( - - ) : ( - 0 - ? { on: writingToPeople, onChange: setPeopleOnly, agent: host } - : undefined - } - replyingTo={ - replyingTo && { message: replyingTo, onCancel: () => setReplyingTo(undefined) } - } - /> - )} - {/* Always its height, so nothing moves as somebody starts or stops typing. */} -
- {anyoneTyping(typers) && } +
+ {agent.model === null ? ( + + ) : ( + 0 + ? { on: writingToPeople, onChange: setPeopleOnly, agent: host } + : undefined + } + replyingTo={ + replyingTo && { message: replyingTo, onCancel: () => setReplyingTo(undefined) } + } + attachments={attachments} + /> + )} + {/* Always its height, so nothing moves as somebody starts or stops typing. */} +
+ {anyoneTyping(typers) && } +
-
+
{/* One beside the chat at a time: what is staged, else a thread, else Details. */} {staged?.kind === "artifact" ? ( @@ -460,6 +477,20 @@ export function AgentChat({ ); } +/** + * Under the composer when a message is not sent. A file that was gone comes + * back as a failed chip to retry; any other refusal says what to change. + */ +function sendFailureMessage(failure: unknown): string { + if (isFileGoneRefusal(failure)) { + return "Message not sent. Some files were no longer there. Retry them to send."; + } + if (isApiFailure(failure) && failure._tag === "BadRequest") { + return `Message not sent. ${failure.message}.`; + } + return "Message not sent. Your draft is still here."; +} + type ChatActivityItem = Extract; /** A routine run, or another bot's collaboration with this one, as a row among the messages. */ diff --git a/packages/web/src/screens/AgentPage.stories.tsx b/packages/web/src/screens/AgentPage.stories.tsx index 20a081f8..7cb0f43e 100644 --- a/packages/web/src/screens/AgentPage.stories.tsx +++ b/packages/web/src/screens/AgentPage.stories.tsx @@ -673,3 +673,127 @@ export const ArtifactOnAPhone = meta.story({ await expect(await within(stage).findByRole("columnheader", { name: "Annual" })).toBeVisible(); }, }); + +const uploadedFileId = "0199a3a0-0000-7000-8000-000000000a51"; +/** What the last message sent asked for, as the API received it. */ +let lastSent: { message: string; attachments?: string[] } | undefined; + +const attachmentHandlers = [ + http.post(`${API}/chats/:chatId/files`, ({ request }) => { + const query = new URL(request.url).searchParams; + return HttpResponse.json( + { + id: uploadedFileId, + name: query.get("name"), + contentType: query.get("type") ?? "application/octet-stream", + size: 4, + }, + { status: 201 }, + ); + }), + http.post(`${API}/chats/:chatId/messages`, async ({ request }) => { + lastSent = (await request.json()) as typeof lastSent; + return HttpResponse.json( + said("61", "you", lastSent?.message ?? "", "2026-09-18T06:10:00.000Z"), + { + status: 201, + }, + ); + }), + ...appHandlers({ messages: { [growthDesk.id]: conversation } }), +]; + +/** + * A file chosen with the paperclip uploads straight away. Once it has, a message with + * no text can carry it, and sending clears it from the composer. + */ +export const AttachAndSend = meta.story({ + beforeEach({ msw }) { + lastSent = undefined; + msw.use(...attachmentHandlers); + }, + play: async ({ canvas, canvasElement }) => { + await canvas.findByRole("textbox", { name: `Message ${growthDesk.name}` }, { timeout: 10_000 }); + const picker = canvasElement.querySelector('input[type="file"]'); + if (!picker) throw new Error("The composer has no file picker"); + await userEvent.upload( + picker, + new File(["%PDF"], "acme-retail-request.pdf", { type: "application/pdf" }), + ); + const files = canvas.getByRole("list", { name: "Attached files" }); + await expect(within(files).getByRole("listitem")).toHaveAttribute( + "title", + expect.stringContaining("acme-retail-request.pdf"), + ); + const send = canvas.getByRole("button", { name: "Send message" }); + await waitFor(() => expect(send).toBeEnabled()); + await userEvent.click(send); + await waitFor(() => expect(lastSent?.attachments).toEqual([uploadedFileId])); + await expect(lastSent?.message).toBe(""); + await expect(canvas.queryByRole("list", { name: "Attached files" })).not.toBeInTheDocument(); + }, +}); + +/** + * The API refuses a message when a file it carries is gone, such as one + * cleared for waiting too long. Its files come back failed, saying why, and + * Retry uploads each again from the computer. + */ +export const SendRefusedFileGone = meta.story({ + beforeEach({ msw }) { + msw.use( + http.post(`${API}/chats/:chatId/messages`, () => + HttpResponse.json( + { + _tag: "BadRequest", + message: "A file to send is no longer there. Remove it and attach it again", + error: { + _tag: "UploadsNotFound", + userMessage: "A file to send is no longer there. Remove it and attach it again", + isRetryable: false, + }, + }, + { status: 400 }, + ), + ), + ...attachmentHandlers, + ); + }, + play: async ({ canvas, canvasElement }) => { + await canvas.findByRole("textbox", { name: `Message ${growthDesk.name}` }, { timeout: 10_000 }); + const picker = canvasElement.querySelector('input[type="file"]'); + if (!picker) throw new Error("The composer has no file picker"); + await userEvent.upload( + picker, + new File(["%PDF"], "acme-retail-request.pdf", { type: "application/pdf" }), + ); + const send = canvas.getByRole("button", { name: "Send message" }); + await waitFor(() => expect(send).toBeEnabled()); + await userEvent.click(send); + + const retry = await canvas.findByRole("button", { name: "Retry acme-retail-request.pdf" }); + await expect(retry).toHaveAccessibleDescription( + /It was no longer there when the message was sent/, + ); + await expect( + canvas.getByText(/It was no longer there when the message was sent/), + ).toBeVisible(); + await expect(send).toBeDisabled(); + + await userEvent.click(retry); + await waitFor(() => expect(send).toBeEnabled()); + }, +}); + +/** Files dragged anywhere over the chat below its header can be dropped to attach them. */ +export const DraggingFiles = meta.story({ + play: async ({ canvas }) => { + const log = await canvas.findByRole("log", { name: "Chat messages" }, { timeout: 10_000 }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File(["%PDF"], "acme-retail-request.pdf")); + log.dispatchEvent( + new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }), + ); + await expect(await canvas.findByText("Drop files to attach")).toBeVisible(); + }, +}); diff --git a/packages/web/src/screens/ChatComposer.stories.tsx b/packages/web/src/screens/ChatComposer.stories.tsx index fec1f100..97c75b9d 100644 --- a/packages/web/src/screens/ChatComposer.stories.tsx +++ b/packages/web/src/screens/ChatComposer.stories.tsx @@ -1,10 +1,25 @@ import type { ThreadParticipant } from "@sugabots/contracts"; import { testPerson } from "@sugabots/contracts/testing"; import { useEffect, useState } from "react"; -import { expect, fn } from "storybook/test"; +import { expect, fn, spyOn } from "storybook/test"; import preview from "#storybook/preview"; +import type { ComposerAttachment } from "@/lib/composer-attachments.ts"; +import { + acmeRequest, + boardPackFailed, + callRecordingTooLarge, + installerRefused, + invoicesAtLimit, + pricingPageReady, + pricingPageUploading, +} from "./attachment-story-fixtures.ts"; import { ChatComposer } from "./ChatComposer.tsx"; +/** Files attached in a fixed state, with spies for what the composer asks of them. */ +function withFiles(items: readonly ComposerAttachment[], leftOut = 0) { + return { items, leftOut, attach: fn(), remove: fn(), retry: fn() }; +} + const mentionable: ThreadParticipant[] = [ { kind: "agent", @@ -35,8 +50,8 @@ const meta = preview.meta({ onValueChange: fn(), onSubmit: fn(), submitLabel: "Send message", - submitDisabled: false, mentionable, + attachments: withFiles([]), }, decorators: [ (Story) => ( @@ -70,7 +85,6 @@ const meta = preview.meta({ setValue(next); args.onValueChange(next); }} - submitDisabled={args.submitDisabled || !value.trim()} /> ); }, @@ -220,9 +234,12 @@ export const SendWithEnter = meta.story({ export const Sending = meta.story({ args: { value: "Please summarise the feedback.", - submitDisabled: true, + sending: true, submitLabel: "Sending message", }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("button", { name: "Sending message" })).toBeDisabled(); + }, }); /** SendFailed preserves the draft and exposes the error beside the retry action. */ @@ -307,3 +324,259 @@ export const ReplyingTo = meta.story({ await expect(args.onValueChange).not.toHaveBeenCalled(); }, }); + +/** The paperclip sits left of the @ and opens the system's file picker straight away. */ +export const AttachFiles = meta.story({ + play: async ({ canvas, canvasElement, userEvent }) => { + const picker = canvasElement.querySelector('input[type="file"]'); + if (!picker) throw new Error("The composer has no file picker"); + // The real picker is the system's own window, which a test cannot close. + const openPicker = spyOn(picker, "click").mockImplementation(() => {}); + await userEvent.click(canvas.getByRole("button", { name: "Attach files" })); + await expect(openPicker).toHaveBeenCalledOnce(); + }, +}); + +/** + * Before the chat exists there is nowhere to upload to: the paperclip is off, + * and a pasted file is left to the browser rather than lost. + */ +export const BeforeTheChatExists = meta.story({ + args: { attachments: { ...withFiles([]), attach: undefined } }, + play: async ({ canvas, canvasElement, userEvent }) => { + const attachButton = canvas.getByRole("button", { name: "Attach files" }); + await expect(attachButton).toHaveAttribute("aria-disabled", "true"); + const picker = canvasElement.querySelector('input[type="file"]'); + if (!picker) throw new Error("The composer has no file picker"); + const openPicker = spyOn(picker, "click").mockImplementation(() => {}); + await userEvent.click(attachButton); + await expect(openPicker).not.toHaveBeenCalled(); + + const input = canvas.getByRole("textbox", { name: "Message Growth Desk" }); + const pasted = new DataTransfer(); + pasted.items.add(new File(["png"], "image.png", { type: "image/png" })); + const paste = new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData: pasted, + }); + input.dispatchEvent(paste); + await expect(paste.defaultPrevented).toBe(false); + }, +}); + +/** Files chosen in the system's picker are attached in the order chosen. */ +export const ChooseFiles = meta.story({ + play: async ({ args, canvasElement, userEvent }) => { + const picker = canvasElement.querySelector('input[type="file"]'); + if (!picker) throw new Error("The composer has no file picker"); + const files = [ + new File(["%PDF"], "acme-retail-request.pdf", { type: "application/pdf" }), + new File(["a,b"], "q4-pipeline.csv", { type: "text/csv" }), + ]; + await userEvent.upload(picker, files); + await expect(args.attachments.attach).toHaveBeenCalledWith(files); + }, +}); + +/** Pasting a file, such as a screenshot, attaches it rather than writing anything into the draft. */ +export const PasteFiles = meta.story({ + play: async ({ args, canvas, userEvent }) => { + const input = canvas.getByRole("textbox", { name: "Message Growth Desk" }); + const screenshot = new File(["png"], "image.png", { type: "image/png" }); + const clipboard = new DataTransfer(); + clipboard.items.add(screenshot); + await userEvent.click(input); + await userEvent.paste(clipboard); + await expect(args.attachments.attach).toHaveBeenCalledWith([screenshot]); + await expect(input).toHaveValue(""); + }, +}); + +/** + * Cells copied from a spreadsheet, or text from a document, come with a + * picture of them beside the text: the text is what is pasted. + */ +export const PasteFromOffice = meta.story({ + play: async ({ args, canvas, userEvent }) => { + const input = canvas.getByRole("textbox", { name: "Message Growth Desk" }); + const clipboard = new DataTransfer(); + clipboard.setData("text/plain", "Q3 revenue\t1.2M"); + clipboard.items.add(new File(["png"], "image.png", { type: "image/png" })); + await userEvent.click(input); + await userEvent.paste(clipboard); + await expect(input).toHaveValue("Q3 revenue\t1.2M"); + await expect(args.attachments.attach).not.toHaveBeenCalled(); + }, +}); + +/** + * Attaching: an image uploading, a PDF done and a video too large to send. + * Send stays off, and says why, until the upload finishes and the failed file + * is removed. + */ +export const Attaching = meta.story({ + args: { attachments: withFiles([pricingPageUploading, acmeRequest, callRecordingTooLarge]) }, + play: async ({ args, canvas, userEvent }) => { + await expect(canvas.getByRole("button", { name: "Send message" })).toBeDisabled(); + await expect(canvas.getByText("Remove the file that failed")).toBeInTheDocument(); + await expect( + canvas.getByRole("progressbar", { name: "Uploading pricing-page-new.svg" }), + ).toHaveAttribute("aria-valuenow", "64"); + // Too large to upload, so there is nothing to retry. + await expect( + canvas.queryByRole("button", { name: "Retry sales-call-recording.mp4" }), + ).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Remove sales-call-recording.mp4" })); + await expect(args.attachments.remove).toHaveBeenCalledWith(callRecordingTooLarge.key); + }, +}); + +/** Files still uploading hold Send back, and the hint counts them. */ +export const Uploading = meta.story({ + args: { + value: "Here's the pricing page and Acme's request.", + attachments: withFiles([ + pricingPageUploading, + { ...acmeRequest, upload: { state: "uploading", progress: 0.3 } }, + ]), + }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("button", { name: "Send message" })).toBeDisabled(); + await expect(canvas.getByText("2 files uploading")).toBeInTheDocument(); + }, +}); + +/** + * A failed upload blocks Send until it is retried or removed. Why it failed + * is written beneath the files, and describes its buttons. Retry uploads it + * again on the same chip. + */ +export const FailedBlocksSend = meta.story({ + args: { + value: "Here's Acme's request, the new pricing page and the board pack.", + attachments: withFiles([pricingPageReady, acmeRequest, boardPackFailed]), + }, + play: async ({ args, canvas, userEvent }) => { + await expect(canvas.getByRole("button", { name: "Send message" })).toBeDisabled(); + await expect(canvas.getByText("Remove or retry the file that failed")).toBeInTheDocument(); + await expect(canvas.getByText(/The upload didn't finish/)).toBeVisible(); + const retry = canvas.getByRole("button", { name: "Retry q3-board-pack-v2.pdf" }); + await expect(retry).toHaveAccessibleDescription( + "q3-board-pack-v2.pdf: The upload didn't finish. Check your connection and retry.", + ); + await expect( + canvas.getByRole("button", { name: "Remove q3-board-pack-v2.pdf" }), + ).toHaveAccessibleDescription(/The upload didn't finish/); + await userEvent.click(retry); + await expect(args.attachments.retry).toHaveBeenCalledWith(boardPackFailed.key); + }, +}); + +/** A file the API refused for what it is offers no Retry, since it would only be refused again. */ +export const RefusedFile = meta.story({ + args: { attachments: withFiles([acmeRequest, installerRefused]) }, + play: async ({ canvas }) => { + await expect(canvas.getByText(/This kind of file can't be attached/)).toBeVisible(); + await expect(canvas.getByText("Remove the file that failed")).toBeInTheDocument(); + await expect( + canvas.queryByRole("button", { name: "Retry setup-helper.exe" }), + ).not.toBeInTheDocument(); + await expect( + canvas.getByRole("button", { name: "Remove setup-helper.exe" }), + ).toHaveAccessibleDescription(/This kind of file can't be attached/); + }, +}); + +/** + * Removing a file from the keyboard keeps the focus among the files: on the + * next one's remove button, or the one before when it was the last. + */ +export const RemoveFromKeyboard = meta.story({ + args: { attachments: withFiles([pricingPageReady, acmeRequest]) }, + render: function Removable(args) { + const [items, setItems] = useState(args.attachments.items); + return ( + { + setItems((current) => current.filter((item) => item.key !== key)); + args.attachments.remove(key); + }, + }} + /> + ); + }, + play: async ({ canvas, userEvent }) => { + await userEvent.tab(); + await expect(canvas.getByRole("button", { name: "Remove pricing-page-new.svg" })).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect( + canvas.getByRole("button", { name: "Remove acme-retail-request.pdf" }), + ).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect(canvas.getByRole("textbox", { name: "Message Growth Desk" })).toHaveFocus(); + await expect(canvas.queryByRole("list", { name: "Attached files" })).not.toBeInTheDocument(); + }, +}); + +/** With two failed, the hint counts them. */ +export const TwoFailed = meta.story({ + args: { attachments: withFiles([boardPackFailed, callRecordingTooLarge]) }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Remove or retry the 2 files that failed")).toBeInTheDocument(); + }, +}); + +/** Text and two files, all uploaded, so Send turns on. */ +export const ReadyToSend = meta.story({ + args: { + value: "Here's Acme's request and the new pricing page. Can you check the numbers?", + attachments: withFiles([pricingPageReady, acmeRequest]), + }, + play: async ({ args, canvas, userEvent }) => { + await userEvent.click(canvas.getByRole("button", { name: "Send message" })); + await expect(args.onSubmit).toHaveBeenCalledOnce(); + }, +}); + +/** A message can be files only: with nothing typed, uploaded files still turn Send on. */ +export const FilesOnly = meta.story({ + args: { attachments: withFiles([pricingPageReady, acmeRequest]) }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("button", { name: "Send message" })).toBeEnabled(); + }, +}); + +/** + * FileLimit: 23 files dropped and the first 20 attached. The note says what + * was left out, the tray scrolls past two rows, and the paperclip turns off. + */ +export const FileLimit = meta.story({ + args: { attachments: withFiles(invoicesAtLimit, 3) }, + play: async ({ canvas }) => { + await expect( + canvas.getByText("3 files weren't added. A message can have 20 at most."), + ).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Attach files" })).toHaveAttribute( + "aria-disabled", + "true", + ); + await expect(canvas.getByText("20 of 20 files")).toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Send message" })).toBeEnabled(); + }, +}); + +/** On a phone the chips are a step smaller, in one row that scrolls sideways. */ +export const AttachingOnPhone = meta.story({ + globals: { viewport: { value: "iphone12", isRotated: false } }, + args: { attachments: withFiles([pricingPageUploading, acmeRequest, callRecordingTooLarge]) }, + play: async ({ canvas }) => { + await expect( + canvas.getByRole("button", { name: "Remove acme-retail-request.pdf" }), + ).toBeInTheDocument(); + }, +}); diff --git a/packages/web/src/screens/ChatComposer.tsx b/packages/web/src/screens/ChatComposer.tsx index a04b7ade..a102b889 100644 --- a/packages/web/src/screens/ChatComposer.tsx +++ b/packages/web/src/screens/ChatComposer.tsx @@ -1,26 +1,41 @@ import { type AgentParticipant, canStartMention, + MAX_ATTACHMENTS_PER_MESSAGE, type MessageReplyTo, messageAuthorName, type ThreadParticipant, } from "@sugabots/contracts"; import { cn } from "cn"; -import { AtSign, Send, UsersRound, X } from "lucide-react"; +import { AtSign, Paperclip, Send, UsersRound, X } from "lucide-react"; import { type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from "react"; +import { + type ComposerAttachment, + type ComposerAttachments, + canSend, + sendReadiness, +} from "@/lib/composer-attachments.ts"; import { AgentAvatar } from "@/shell/Agent.tsx"; import { PersonAvatar } from "@/ui/avatar.tsx"; import { IconButton } from "@/ui/icon-button.tsx"; import { Tooltip } from "@/ui/tooltip.tsx"; +import { AttachmentTray, LeftOutNote } from "./ComposerAttachments.tsx"; + +/** A 30px icon button on the row beneath the draft, such as the paperclip and the @. */ +const toolButtonClassName = + "focus-ring grid size-[30px] place-items-center rounded-tail text-muted-foreground transition-colors hover:bg-chip"; /** * Where a message is written: a box that grows with its text and sends on - * Enter (Shift+Enter for a new line), with the @ and the send button on a row - * beneath the draft. Typing `@`, or pressing the @, offers everyone in - * `mentionable` whose name or handle matches what follows it; choosing one - * writes their handle into the draft. With `peopleOnly`, a button beside the @ - * says whether the bot will answer, and it or Tab moves between writing to the bot, - * the default, and writing to the other people only. With `replyingTo`, a strip + * Enter (Shift+Enter for a new line), with the paperclip, the @ and the send + * button on a row beneath the draft. The paperclip attaches files, as dropping + * or pasting them does; they sit above the text as they upload. Send is off + * until there is text or a file and every file has uploaded, and while + * `sending`. Typing `@`, or pressing the @, offers everyone in `mentionable` + * whose name or handle matches what follows it; choosing one writes their + * handle into the draft. With `peopleOnly`, a button beside the @ says whether + * the bot will answer, and it or Tab moves between writing to the bot, the + * default, and writing to the other people only. With `replyingTo`, a strip * over the draft names the message it answers; its × or Escape stops replying. */ export function ChatComposer({ @@ -29,12 +44,13 @@ export function ChatComposer({ onValueChange, onSubmit, submitLabel, - submitDisabled, + sending = false, error, className, mentionable, peopleOnly, replyingTo, + attachments, }: { /** Names the box for assistive technology, and is its placeholder. */ label: string; @@ -42,7 +58,8 @@ export function ChatComposer({ onValueChange: (value: string) => void; onSubmit: () => void | Promise; submitLabel: string; - submitDisabled: boolean; + /** A message is on its way, so another cannot be sent yet. */ + sending?: boolean; error?: ReactNode; className?: string; /** Who the draft can mention. */ @@ -56,6 +73,8 @@ export function ChatComposer({ }; /** The message the draft answers, quoted when it is sent. */ replyingTo?: { message: MessageReplyTo; onCancel: () => void }; + /** The files attached to the message, and what attaches, removes and retries them. */ + attachments: Pick; }) { const id = useId(); const mentionListId = `${id}-mentions`; @@ -175,6 +194,13 @@ export function ChatComposer({ {replyingTo && ( )} + textarea.current?.focus()} + /> +
+ = MAX_ATTACHMENTS_PER_MESSAGE} + onAttach={attachments.attach} + /> @@ -241,12 +278,12 @@ export function ChatComposer({ )} - Enter to send + {sendHint(attachments.items)} + + + ); +} + +/** Beside Send: what holds the message back, else how full it is of files, else how to send it. */ +function sendHint(items: readonly ComposerAttachment[]): string { + const readiness = sendReadiness(items); + if (!readiness.ready && readiness.failed > 0) { + const action = items.some(({ upload }) => upload.state === "failed" && upload.retryable) + ? "Remove or retry" + : "Remove"; + return readiness.failed === 1 + ? `${action} the file that failed` + : `${action} the ${readiness.failed} files that failed`; + } + if (!readiness.ready) { + return readiness.uploading === 1 + ? "1 file uploading" + : `${readiness.uploading} files uploading`; + } + if (items.length >= MAX_ATTACHMENTS_PER_MESSAGE) { + return `${MAX_ATTACHMENTS_PER_MESSAGE} of ${MAX_ATTACHMENTS_PER_MESSAGE} files`; + } + return "Enter to send"; +} + /** * The people and bots matching the mention being typed, floating over the * messages above the composer. The focus stays in the draft: arrow keys move diff --git a/packages/web/src/screens/ComposerAttachments.stories.tsx b/packages/web/src/screens/ComposerAttachments.stories.tsx new file mode 100644 index 00000000..f65569bf --- /dev/null +++ b/packages/web/src/screens/ComposerAttachments.stories.tsx @@ -0,0 +1,98 @@ +import { expect, fn } from "storybook/test"; +import preview from "#storybook/preview"; +import { + acmeRequest, + boardPackFailed, + brandGuideUploading, + callRecordingTooLarge, + installerRefused, + pipelineSheet, + pricingPageReady, + pricingPageUploading, + signedOrderForm, +} from "./attachment-story-fixtures.ts"; +import { AttachmentChip } from "./ComposerAttachments.tsx"; + +/* + * A file attached in the composer, at size, on the message box's surface. + * Pointing at one shows its name and how its upload stands; every chip keeps + * its remove button. + */ +const meta = preview.meta({ + title: "Product/AttachmentChip", + component: AttachmentChip, + tags: ["ai-generated"], + args: { attachment: acmeRequest, onRemove: fn(), onRetry: fn() }, + decorators: [ + (Story) => ( +
    + +
+ ), + ], +}); + +/** An image uploading: its thumbnail faded until done, with a bar filling along the foot. */ +export const ImageUploading = meta.story({ + args: { attachment: pricingPageUploading }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "64"); + }, +}); + +/** Another kind of file uploading: a file icon, with its extension as the badge. */ +export const FileUploading = meta.story({ args: { attachment: brandGuideUploading } }); + +/** An image uploaded: the thumbnail is the image, cropped to fill. */ +export const ImageReady = meta.story({ args: { attachment: pricingPageReady } }); + +/** A PDF uploaded: a page, badged in grey, since red is kept for errors. */ +export const PdfReady = meta.story({}); + +/** A sheet uploaded: a ruled page, badged in green. */ +export const SheetReady = meta.story({ args: { attachment: pipelineSheet } }); + +/** Too large to upload, which is checked first, so nothing was sent. Remove is all it offers. */ +export const TooLarge = meta.story({ + args: { attachment: callRecordingTooLarge }, + play: async ({ canvas }) => { + await expect(canvas.queryByRole("button", { name: /Retry/ })).not.toBeInTheDocument(); + }, +}); + +/** An upload that failed but could work if tried again: Retry starts it again on the same chip. */ +export const UploadFailed = meta.story({ + args: { attachment: boardPackFailed }, + play: async ({ args, canvas, userEvent }) => { + await userEvent.click(canvas.getByRole("button", { name: "Retry q3-board-pack-v2.pdf" })); + await expect(args.onRetry).toHaveBeenCalledOnce(); + }, +}); + +/** A file the API refused for what it is: trying again would fail the same way, so only remove is offered. */ +export const Refused = meta.story({ + args: { attachment: installerRefused }, + play: async ({ canvas }) => { + await expect(canvas.queryByRole("button", { name: /Retry/ })).not.toBeInTheDocument(); + await expect( + canvas.getByRole("button", { name: "Remove setup-helper.exe" }), + ).toBeInTheDocument(); + }, +}); + +/** + * A long name, cut from the middle so its end and extension stay readable. + * Focusing the remove button shows the name and the button, as pointing does. + */ +export const LongName = meta.story({ + args: { attachment: signedOrderForm }, + play: async ({ args, canvas, userEvent }) => { + await userEvent.tab(); + const remove = canvas.getByRole("button", { + name: "Remove northwind-labs-signed-order-form.pdf", + }); + await expect(remove).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect(args.onRemove).toHaveBeenCalledOnce(); + }, +}); diff --git a/packages/web/src/screens/ComposerAttachments.tsx b/packages/web/src/screens/ComposerAttachments.tsx new file mode 100644 index 00000000..edcac389 --- /dev/null +++ b/packages/web/src/screens/ComposerAttachments.tsx @@ -0,0 +1,420 @@ +import { MAX_ATTACHMENT_MEGABYTES, MAX_ATTACHMENTS_PER_MESSAGE } from "@sugabots/contracts"; +import { cn } from "cn"; +import { CircleAlert, FileIcon, Info, RotateCcw, X } from "lucide-react"; +import { + type CSSProperties, + type RefObject, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { AttachmentUpload, ComposerAttachment } from "@/lib/composer-attachments.ts"; +import { listNames } from "@/lib/name-list.ts"; +import { Tooltip } from "@/ui/tooltip.tsx"; + +/** + * The files attached to the message being written, as thumbnails above its + * text, in the order attached. It wraps, and past two rows scrolls with the + * third peeking in; on a phone it is one row that scrolls sideways. Beneath + * it, why any file failed, announced as it fails. Removing a chip from the + * keyboard moves the focus to the next chip's remove button, else the one + * before, else to the draft through `focusDraft`. + */ +export function AttachmentTray({ + items, + onRemove, + onRetry, + focusDraft, +}: { + items: readonly ComposerAttachment[]; + onRemove: (key: string) => void; + onRetry: (key: string) => void; + focusDraft: () => void; +}) { + const tray = useRef(null); + const overflowing = useOverflowsVertically(tray, items.length); + const failureIdPrefix = useId(); + const failures = failureNotesOf(items); + const failureIdOf = (key: string) => { + const index = failures.findIndex((note) => note.keys.includes(key)); + return index === -1 ? undefined : `${failureIdPrefix}-${index}`; + }; + + function removeAt(index: number, key: string): void { + const removeButtons = [ + ...(tray.current?.querySelectorAll("[data-remove-chip]") ?? []), + ]; + const button = removeButtons[index]; + // Only from the keyboard: a tap on a phone would otherwise raise its keyboard for the draft. + if (button && button === document.activeElement && button.matches(":focus-visible")) { + const neighbour = removeButtons[index + 1] ?? removeButtons[index - 1]; + if (neighbour) neighbour.focus(); + else focusDraft(); + } + onRemove(key); + } + + return ( + <> + {items.length > 0 && ( +
    + {items.map((attachment, index) => ( + removeAt(index, attachment.key)} + onRetry={() => onRetry(attachment.key)} + /> + ))} +
+ )} +
+ {failures.map((note, index) => ( +

+ + + {note.names}: {note.reason} + +

+ ))} +
+ + ); +} + +/** The failed files, one note for each reason, naming the files it applies to. */ +function failureNotesOf( + items: readonly ComposerAttachment[], +): { reason: string; names: string; keys: string[] }[] { + const byReason = new Map(); + for (const item of items) { + const reason = failureReasonOf(item.upload); + if (reason) byReason.set(reason, [...(byReason.get(reason) ?? []), item]); + } + return [...byReason].map(([reason, failed]) => ({ + reason, + names: listNames(failed.map(({ file }) => file.name)), + keys: failed.map(({ key }) => key), + })); +} + +const TOO_LARGE_REASON = `Too large. ${MAX_ATTACHMENT_MEGABYTES} MB at most.`; + +function failureReasonOf(upload: AttachmentUpload): string | undefined { + if (upload.state === "tooLarge") return TOO_LARGE_REASON; + if (upload.state === "failed") return upload.reason; + return undefined; +} + +/** Says how many files of the latest batch were left out for going over the limit. */ +export function LeftOutNote({ count }: { count: number }) { + return ( +
+ {count > 0 && ( + <> + + + {count === 1 ? "1 file wasn't added." : `${count} files weren't added.`} A message can + have {MAX_ATTACHMENTS_PER_MESSAGE} at most. + + + )} +
+ ); +} + +/** + * One attached file: a thumbnail of an image, or a page or icon for its type + * with a badge naming it. Pointing at it, or focusing its buttons, shows its + * name and how its upload stands over the thumbnail, and its remove button. + * A failure that could go differently offers Retry; any other offers only + * remove. `failureId` names the element saying why it failed, which both + * buttons point to. + */ +export function AttachmentChip({ + attachment: { file, upload }, + failureId, + onRemove, + onRetry, +}: { + attachment: ComposerAttachment; + failureId?: string; + onRemove: () => void; + onRetry: () => void; +}) { + const look = fileLookOf(file); + const status = uploadStatusOf(upload); + const failed = upload.state === "failed" || upload.state === "tooLarge"; + const retryable = upload.state === "failed" && upload.retryable; + return ( +
  • + {failed ? ( + // Retry takes the alert's place. + !retryable && ( + + + + ) + ) : ( + + )} + {failed ? ( + + {upload.state === "tooLarge" ? "Too large" : "Failed"} + + ) : ( + look !== "image" && + )} + {retryable && ( + + + + )} + + + {status && ( + + {status} + + )} + + + + + {upload.state === "uploading" && ( + + )} +
  • + ); +} + +type FileLook = "image" | "pdf" | "sheet" | "other"; + +const SHEET_EXTENSIONS = new Set(["csv", "tsv", "xls", "xlsx", "ods", "numbers"]); + +function fileLookOf(file: File): FileLook { + if (file.type.startsWith("image/")) return "image"; + const extension = extensionOf(file.name); + if (file.type === "application/pdf" || extension === "pdf") return "pdf"; + if (file.type === "text/csv" || SHEET_EXTENSIONS.has(extension)) return "sheet"; + return "other"; +} + +/** The part of `name` after its last dot, lowercased, or "" when it has none. */ +function extensionOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +function uploadStatusOf(upload: AttachmentUpload): string | undefined { + switch (upload.state) { + case "uploading": + return `Uploading ${Math.round(upload.progress * 100)}%`; + case "tooLarge": + return TOO_LARGE_REASON; + case "failed": + return upload.retryable ? "Upload failed" : "Can't upload"; + case "uploaded": + return undefined; + } +} + +/** What fills the chip: the image itself, a page drawn for a document or sheet, or a file icon. */ +function FilePreview({ file, look, dimmed }: { file: File; look: FileLook; dimmed: boolean }) { + const fade = cn("transition-opacity", dimmed && "opacity-45"); + if (look === "image") return ; + if (look === "pdf") { + return ( + + + + + + + + + ); + } + if (look === "sheet") { + return ( + + ); + } + return ( + + + + ); +} + +/** Ruled lines and columns, as a spreadsheet's page is drawn. */ +const SHEET_GRID: CSSProperties = { + backgroundImage: + "linear-gradient(var(--file-page-line) 1px, transparent 1px), linear-gradient(90deg, var(--file-page-line) 1px, transparent 1px)", + backgroundSize: "100% 9px, 18px 100%", + backgroundPosition: "0 8px, -1px 0", +}; + +/** + * The image, cropped to fill the chip. The address it is read from lasts + * only while it is shown. One the browser cannot draw shows a file icon. + */ +function ImageThumbnail({ file, className }: { file: File; className: string }) { + const [address, setAddress] = useState(); + const [broken, setBroken] = useState(false); + useEffect(() => { + const created = URL.createObjectURL(file); + setAddress(created); + return () => URL.revokeObjectURL(created); + }, [file]); + if (broken) { + return ( + + + + ); + } + return ( + address && ( + setBroken(true)} + className={cn("absolute inset-0 size-full object-cover", className)} + /> + ) + ); +} + +/** The file's type, from its extension, at the chip's foot: green for a sheet, grey for any other. */ +function TypeBadge({ file, look }: { file: File; look: Exclude }) { + return ( + + {extensionOf(file.name).toUpperCase() || "FILE"} + + ); +} + +function UploadProgress({ name, fraction }: { name: string; fraction: number }) { + const percent = Math.round(fraction * 100); + return ( + + + + ); +} + +/** Shows `text` on one line, cutting from its middle when it is too long, so its end and extension stay. */ +function MiddleTruncated({ text, className }: { text: string; className: string }) { + const extensionLength = extensionOf(text).length; + const tailLength = Math.min(Math.ceil(text.length / 2), extensionLength + MIDDLE_TRUNCATION_KEPT); + return ( + + {text.slice(0, text.length - tailLength)} + {text.slice(text.length - tailLength)} + + ); +} + +/** How many characters before the extension stay when a name is cut short. */ +const MIDDLE_TRUNCATION_KEPT = 3; + +/** Whether `element` is taller than it shows, following its size and `contentKey`. */ +function useOverflowsVertically( + element: RefObject, + contentKey: unknown, +): boolean { + const [overflows, setOverflows] = useState(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: `contentKey` stands for the content, which the observer does not see change. + useLayoutEffect(() => { + const target = element.current; + if (!target) return; + const measure = () => setOverflows(target.scrollHeight > target.clientHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(target); + return () => observer.disconnect(); + }, [element, contentKey]); + return overflows; +} diff --git a/packages/web/src/screens/FileDropTarget.stories.tsx b/packages/web/src/screens/FileDropTarget.stories.tsx new file mode 100644 index 00000000..35455093 --- /dev/null +++ b/packages/web/src/screens/FileDropTarget.stories.tsx @@ -0,0 +1,51 @@ +import { expect, fn } from "storybook/test"; +import preview from "#storybook/preview"; +import { FileDropTarget } from "./FileDropTarget.tsx"; + +/* + * The chat below its header takes files dragged onto it. Stories drag a file + * in by dispatching the browser's own drag events. + */ +const meta = preview.meta({ + title: "Product/FileDropTarget", + component: FileDropTarget, + tags: ["ai-generated"], + args: { + onDropFiles: fn(), + className: "flex h-[420px] flex-col rounded-xl border bg-background", + children: ( +
    + The chat's messages and composer sit here. +
    + ), + }, +}); + +const report = new File(["%PDF"], "acme-retail-request.pdf", { type: "application/pdf" }); + +function drag(type: "dragenter" | "dragover" | "drop", target: Element): void { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(report); + target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer })); +} + +/** Files dragged over the area: a veil across all of it says where they go and the limits. */ +export const FilesOver = meta.story({ + play: async ({ canvas }) => { + drag("dragenter", canvas.getByText("The chat's messages and composer sit here.")); + await expect(await canvas.findByText("Drop files to attach")).toBeVisible(); + await expect(canvas.getByText("Up to 20 files, 30 MB each")).toBeVisible(); + }, +}); + +/** Dropped, the files are attached and the veil goes. */ +export const Dropped = meta.story({ + play: async ({ args, canvas }) => { + const content = canvas.getByText("The chat's messages and composer sit here."); + drag("dragenter", content); + await canvas.findByText("Drop files to attach"); + drag("drop", content); + await expect(args.onDropFiles).toHaveBeenCalledWith([report]); + await expect(canvas.queryByText("Drop files to attach")).not.toBeInTheDocument(); + }, +}); diff --git a/packages/web/src/screens/FileDropTarget.tsx b/packages/web/src/screens/FileDropTarget.tsx new file mode 100644 index 00000000..da46dccf --- /dev/null +++ b/packages/web/src/screens/FileDropTarget.tsx @@ -0,0 +1,75 @@ +import { MAX_ATTACHMENT_MEGABYTES, MAX_ATTACHMENTS_PER_MESSAGE } from "@sugabots/contracts"; +import { cn } from "cn"; +import { Upload } from "lucide-react"; +import { type DragEvent, type ReactNode, useRef, useState } from "react"; + +/** + * An area that takes files dragged onto it from the computer, such as the + * whole chat below its header, so a drop that misses the composer still lands. + * While files are over it, a veil across it says they can be dropped. Without + * `onDropFiles` it takes nothing, and drags pass over it as usual. + */ +export function FileDropTarget({ + onDropFiles, + className, + children, +}: { + onDropFiles?: (files: File[]) => void; + className?: string; + children: ReactNode; +}) { + const [filesOver, setFilesOver] = useState(false); + // Entering a child leaves its parent, so the area is left only once every enter has its leave. + const enters = useRef(0); + const takesFiles = (event: DragEvent) => + onDropFiles !== undefined && event.dataTransfer.types.includes("Files"); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: dropping is a pointer shortcut; the composer's + and paste attach the same files from a keyboard. +
    { + if (!takesFiles(event)) return; + event.preventDefault(); + enters.current += 1; + setFilesOver(true); + }} + onDragOver={(event) => { + if (!takesFiles(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={(event) => { + if (!takesFiles(event)) return; + enters.current = Math.max(0, enters.current - 1); + if (enters.current === 0) setFilesOver(false); + }} + onDrop={(event) => { + if (!takesFiles(event)) return; + event.preventDefault(); + enters.current = 0; + setFilesOver(false); + const files = [...event.dataTransfer.files]; + if (files.length > 0) onDropFiles?.(files); + }} + > + {children} + {filesOver && ( +
    +
    + + + + Drop files to attach + + Up to {MAX_ATTACHMENTS_PER_MESSAGE} files, {MAX_ATTACHMENT_MEGABYTES} MB each + +
    +
    + )} +
    + ); +} diff --git a/packages/web/src/screens/attachment-story-fixtures.ts b/packages/web/src/screens/attachment-story-fixtures.ts new file mode 100644 index 00000000..9a227608 --- /dev/null +++ b/packages/web/src/screens/attachment-story-fixtures.ts @@ -0,0 +1,74 @@ +import { MAX_ATTACHMENTS_PER_MESSAGE } from "@sugabots/contracts"; +import type { AttachmentUpload, ComposerAttachment } from "@/lib/composer-attachments.ts"; + +/* + * Files attached in the composer, for stories: each in a fixed state, so + * every run draws the same chips. + */ + +/** A small drawing of a pricing page, so an image's thumbnail is the same in every run. */ +const PRICING_PAGE_SVG = ` + + + + + + + + + +`; + +let nextKey = 0; + +function attached(file: File, upload: AttachmentUpload): ComposerAttachment { + nextKey += 1; + return { key: `story-attachment-${nextKey}`, file, upload }; +} + +function storyFile(name: string, type: string, content = "Story fixture"): File { + return new File([content], name, { type, lastModified: 0 }); +} + +const pricingPage = storyFile("pricing-page-new.svg", "image/svg+xml", PRICING_PAGE_SVG); +const done = (fileId: string): AttachmentUpload => ({ state: "uploaded", fileId }); + +export const pricingPageUploading = attached(pricingPage, { state: "uploading", progress: 0.64 }); +export const pricingPageReady = attached(pricingPage, done("0199a3a0-0000-7000-8000-000000000a01")); +export const acmeRequest = attached( + storyFile("acme-retail-request.pdf", "application/pdf"), + done("0199a3a0-0000-7000-8000-000000000a02"), +); +export const callRecordingTooLarge = attached(storyFile("sales-call-recording.mp4", "video/mp4"), { + state: "tooLarge", +}); +export const boardPackFailed = attached(storyFile("q3-board-pack-v2.pdf", "application/pdf"), { + state: "failed", + reason: "The upload didn't finish. Check your connection and retry.", + retryable: true, +}); +/** Refused by the API for its type, so trying it again would only be refused again. */ +export const installerRefused = attached( + storyFile("setup-helper.exe", "application/x-msdownload"), + { state: "failed", reason: "This kind of file can't be attached.", retryable: false }, +); +export const brandGuideUploading = attached(storyFile("brand-guide.key", ""), { + state: "uploading", + progress: 0.12, +}); +export const pipelineSheet = attached( + storyFile("q4-pipeline.csv", "text/csv"), + done("0199a3a0-0000-7000-8000-000000000a03"), +); +export const signedOrderForm = attached( + storyFile("northwind-labs-signed-order-form.pdf", "application/pdf"), + done("0199a3a0-0000-7000-8000-000000000a04"), +); + +/** As many invoices as one message can carry. */ +export const invoicesAtLimit = Array.from({ length: MAX_ATTACHMENTS_PER_MESSAGE }, (_, index) => + attached( + storyFile(`invoice-${1041 + index}.pdf`, "application/pdf"), + done(`0199a3a0-0000-7000-8000-000000000b${String(index).padStart(2, "0")}`), + ), +); diff --git a/packages/web/src/test-client.ts b/packages/web/src/test-client.ts index 81e90e7e..ec56b973 100644 --- a/packages/web/src/test-client.ts +++ b/packages/web/src/test-client.ts @@ -16,6 +16,7 @@ import { vi } from "vitest"; * for a refusal. */ export const client = { + baseUrl: "https://api.test", api: { health: vi.fn(), me: vi.fn(), @@ -112,6 +113,7 @@ export const client = { messages: vi.fn(), history: vi.fn(), send: vi.fn(), + removeFile: vi.fn(), }, events: { workspace: vi.fn(),