From 578cd05a890960bcde34a52e9677180e1c5c8439 Mon Sep 17 00:00:00 2001 From: David Moore Date: Wed, 14 Oct 2026 19:02:51 +1100 Subject: [PATCH] feat(web): show the files attached to messages --- .../src/conversations/threads/threads.test.ts | 69 ++++++++ .../tools/search-history/tool.ts | 33 +++- packages/web/src/lib/chat-files.ts | 5 + packages/web/src/lib/chats.ts | 30 ++-- .../web/src/lib/composer-attachments.test.tsx | 23 ++- packages/web/src/lib/composer-attachments.ts | 20 ++- packages/web/src/lib/file-looks.ts | 35 ++++ packages/web/src/screens/AgentChat.tsx | 4 +- packages/web/src/screens/ChatThreadPanel.tsx | 2 + .../web/src/screens/ComposerAttachments.tsx | 35 +--- .../web/src/screens/MessageFiles.stories.tsx | 112 +++++++++++++ packages/web/src/screens/MessageFiles.tsx | 154 ++++++++++++++++++ .../screens/ThreadConversation.stories.tsx | 99 +++++++++++ .../web/src/screens/ThreadConversation.tsx | 19 +++ .../src/screens/attachment-story-fixtures.ts | 27 +-- .../src/screens/thread-conversation.test.tsx | 1 + packages/web/src/ui/middle-truncated.tsx | 17 ++ 17 files changed, 611 insertions(+), 74 deletions(-) create mode 100644 packages/web/src/lib/file-looks.ts create mode 100644 packages/web/src/screens/MessageFiles.stories.tsx create mode 100644 packages/web/src/screens/MessageFiles.tsx create mode 100644 packages/web/src/ui/middle-truncated.tsx diff --git a/packages/core/src/conversations/threads/threads.test.ts b/packages/core/src/conversations/threads/threads.test.ts index 1a30a554..0b26c14e 100644 --- a/packages/core/src/conversations/threads/threads.test.ts +++ b/packages/core/src/conversations/threads/threads.test.ts @@ -16,6 +16,7 @@ import { podMember, providerModel, thread, + threadFile, turn, user, workspace, @@ -1187,6 +1188,74 @@ describe.skipIf(!process.env.DATABASE_URL)("threads, against Postgres", async () expect(await found("river")).toEqual([said[0], said[4]]); }); + it("finds the files a message carried, even one with nothing else in it", async () => { + const details = await createThread({ + workspaceId, + podId, + hostAgentId: agentId, + initiatorUserId: memberId, + message: "Contracts", + }); + const started = new Date(Date.now() - 10 * 60_000); + const sent = await onDatabase((db) => + db + .insert(message) + .values( + ["Here is the signed contract", ""].map((content, index) => ({ + threadId: details.thread.id, + authorUserId: memberId, + kind: "text" as const, + status: "complete" as const, + parts: content ? [{ type: "text" as const, text: content }] : [], + content, + createdAt: new Date(started.getTime() + index * 60_000), + })), + ) + .returning({ id: message.id }), + ); + const [withText, filesOnly] = sent; + if (!withText || !filesOnly) throw new Error("Could not create the messages"); + const attached = await onDatabase((db) => + db + .insert(threadFile) + .values( + [ + { messageId: withText.id, name: "contract.pdf", contentType: "application/pdf" }, + { messageId: filesOnly.id, name: "chart.png", contentType: "image/png" }, + ].map((file) => ({ + ...file, + threadId: details.thread.id, + kind: "upload" as const, + uploaderId: memberId, + position: 0, + size: 2048, + })), + ) + .returning({ id: threadFile.id, name: threadFile.name }), + ); + const fileIdOf = (name: string) => attached.find((file) => file.name === name)?.id; + const search = searchHistoryTool({ + threadId: details.thread.id, + before: new Date(Date.now() + 60_000), + run: runOnPostgres, + }); + const found = async (input: { query?: string; after?: string }) => { + const result = await search.execute?.(input, { toolCallId: "search", messages: [] } as never); + return result && "messages" in result ? result.messages.map(({ text }) => text) : []; + }; + + expect(await found({ query: "signed contract" })).toEqual([ + `Here is the signed contract\n[Attached contract.pdf (application/pdf, 2.0 KB) as file ${fileIdOf("contract.pdf")}, which read_thread_file reads]`, + ]); + // By its name, for a message that says nothing else. + expect(await found({ query: "chart.png" })).toEqual([ + `[Attached chart.png (image/png, 2.0 KB) as file ${fileIdOf("chart.png")}, which read_thread_file reads]`, + ]); + expect(await found({ after: started.toISOString() })).toContain( + `[Attached chart.png (image/png, 2.0 KB) as file ${fileIdOf("chart.png")}, which read_thread_file reads]`, + ); + }); + /** * A thread of twenty long messages, about 10K tokens each, before its * first, with the bot's model given a 128K window and the system agents' diff --git a/packages/core/src/conversations/tools/search-history/tool.ts b/packages/core/src/conversations/tools/search-history/tool.ts index 5b7f4e99..5a766aad 100644 --- a/packages/core/src/conversations/tools/search-history/tool.ts +++ b/packages/core/src/conversations/tools/search-history/tool.ts @@ -3,8 +3,9 @@ import { tool } from "ai"; import { and, asc, desc, eq, gte, ilike, lt, or, type SQL, sql } from "drizzle-orm"; import { DateTime, Effect, Schema } from "effect"; import { type Executor, query, type RunEffect } from "../../../database/database.ts"; -import { agent, message, user } from "../../../database/schema.ts"; -import { formatHistoryTime } from "../../threads/message-text.ts"; +import { agent, message, threadFile, user } from "../../../database/schema.ts"; +import { loadFileParts } from "../../threads/file-parts.ts"; +import { describeAttachment, formatHistoryTime } from "../../threads/message-text.ts"; import { participantColumns, toMessage } from "../../threads/participants.ts"; const MAX_MATCHES = 10; @@ -114,17 +115,26 @@ const searchHistory = Effect.fn("SearchHistory.searchHistory")(function* ( ) // One more than is returned, to tell whether there are more. .limit(MAX_MATCHES + 1); + const returned = rows.slice(0, MAX_MATCHES); + // Each message lists the files it carried, so a file found this way can be read again. + const files = yield* loadFileParts( + db, + returned.map(({ message: row }) => row.id), + ); return { - messages: rows.slice(0, MAX_MATCHES).map(({ message: row, ...author }): HistoryMatch => { + messages: returned.map(({ message: row, ...author }): HistoryMatch => { const found = toMessage(row, author); + const said = + row.content.length <= MATCH_CHARACTERS + ? row.content + : `${row.content.slice(0, MATCH_CHARACTERS)}…`; return { author: found.author.kind === "routine_trigger" ? found.author.routineName : found.author.name, at: formatHistoryTime(row.createdAt), - text: - row.content.length <= MATCH_CHARACTERS - ? row.content - : `${row.content.slice(0, MATCH_CHARACTERS)}…`, + text: [said, ...(files.get(row.id) ?? []).map(describeAttachment)] + .filter((line) => line !== "") + .join("\n"), }; }), more: rows.length > MAX_MATCHES, @@ -153,12 +163,19 @@ function wordMatch(words: string): { where: SQL; best: SQL[] } { terms.length > 0 ? and(...terms.map((term) => ilike(message.content, `%${escapeLike(term)}%`))) : undefined; + // A file is found by its name too, as a message with only files says nothing else. + const carriesNamed = + terms.length > 0 + ? sql`exists (select 1 from ${threadFile} where ${threadFile.messageId} = ${message.id} and ${and( + ...terms.map((term) => ilike(threadFile.name, `%${escapeLike(term)}%`)), + )})` + : undefined; const rank = sql`greatest( ts_rank(to_tsvector('english', ${message.content}), websearch_to_tsquery('english', ${words})), ts_rank(to_tsvector('simple', ${message.content}), websearch_to_tsquery('simple', ${words})) )`; return { - where: or(english, simple, substring) as SQL, + where: or(english, simple, substring, carriesNamed) as SQL, best: [desc(sql`(${english}) or (${simple})`), desc(rank)], }; } diff --git a/packages/web/src/lib/chat-files.ts b/packages/web/src/lib/chat-files.ts index d4726423..a53b03d5 100644 --- a/packages/web/src/lib/chat-files.ts +++ b/packages/web/src/lib/chat-files.ts @@ -64,6 +64,11 @@ export function uploadChatFile({ }); } +/** Where a file attached to a message in the chat is served, shown in place or downloaded. */ +export function chatFileUrl(chatId: string, fileId: string): string { + return `${client.baseUrl}/chats/${encodeURIComponent(chatId)}/files/${encodeURIComponent(fileId)}`; +} + /** * 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 diff --git a/packages/web/src/lib/chats.ts b/packages/web/src/lib/chats.ts index 54948467..468cfab9 100644 --- a/packages/web/src/lib/chats.ts +++ b/packages/web/src/lib/chats.ts @@ -2,6 +2,7 @@ import { type Chat, type ChatList, type ChatMessageItem, + type FilePart, handleFromName, type MessageReplyTo, type NewMessage, @@ -360,20 +361,26 @@ export function useChatHistory(chatId: string | undefined) { }; } -/** A message to send, with the whole quote it answers, so it shows quoting it before the API answers. */ -type SentChatMessage = Omit & { replyTo?: MessageReplyTo }; +/** + * A message to send, with the whole quote it answers and the files it + * attaches, so it shows both before the API answers. + */ +export type MessageToSend = Omit & { + replyTo?: MessageReplyTo; + files?: readonly FilePart[]; +}; export function useSendChatMessage(chat: Chat | undefined, user: SessionUser) { const queries = useQueryClient(); return useMutation({ - mutationFn: async ({ replyTo, ...input }: SentChatMessage) => { + mutationFn: async ({ replyTo, files = [], ...message }: MessageToSend) => { if (!chat) throw new NotReadyError(); - return Effect.runPromise( - client.api.chats.send({ - params: { chatId: chat.id }, - payload: { ...input, replyTo: replyTo?.id }, - }), - ); + const payload: NewMessage = { + ...message, + ...(replyTo && { replyTo: replyTo.id }), + ...(files.length > 0 && { attachments: files.map((file) => file.id) }), + }; + return Effect.runPromise(client.api.chats.send({ params: { chatId: chat.id }, payload })); }, onMutate: async (input) => { if (!chat) return; @@ -393,7 +400,10 @@ export function useSendChatMessage(chat: Chat | undefined, user: SessionUser) { }, kind: "text", status: "complete", - parts: [{ type: "text", text: input.message }], + parts: [ + ...(input.message === "" ? [] : [{ type: "text" as const, text: input.message }]), + ...(input.files ?? []), + ], content: input.message, ...(input.replyTo ? { replyTo: input.replyTo } : {}), createdAt: timestampAfter( diff --git a/packages/web/src/lib/composer-attachments.test.tsx b/packages/web/src/lib/composer-attachments.test.tsx index b6789f8b..357b9ba1 100644 --- a/packages/web/src/lib/composer-attachments.test.tsx +++ b/packages/web/src/lib/composer-attachments.test.tsx @@ -131,7 +131,10 @@ describe("useComposerAttachments", () => { act(() => request?.answer(201, uploadedAs(file))); await settle(); - expect(result.current.items[0]?.upload).toEqual({ state: "uploaded", fileId }); + expect(result.current.items[0]?.upload).toEqual({ + state: "uploaded", + attachment: uploadedAs(file), + }); expect(sendReadiness(result.current.items)).toEqual({ ready: true }); }); @@ -223,7 +226,9 @@ describe("useComposerAttachments", () => { 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 } }]); + expect(result.current.items).toEqual([ + { key, file, upload: { state: "uploaded", attachment: uploadedAs(file) } }, + ]); }); it("takes the uploaded files to send, and puts them back when the message is not sent", async () => { @@ -238,7 +243,10 @@ describe("useComposerAttachments", () => { act(() => { taken = result.current.takeForSending(""); }); - expect(taken?.fileIds).toEqual([fileId]); + // As the message shows them until the API answers. + expect(taken?.parts).toEqual([ + { type: "file", id: fileId, name: file.name, contentType: file.type, size: file.size }, + ]); expect(result.current.items).toEqual([]); act(() => taken?.putBack(new Error("The connection dropped"))); @@ -266,7 +274,7 @@ describe("useComposerAttachments", () => { 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([]); + expect(result.current.takeForSending("Hello")?.parts).toEqual([]); const file = pdf(); act(() => result.current.attach?.([file])); @@ -277,7 +285,7 @@ describe("useComposerAttachments", () => { act(() => { taken = result.current.takeForSending(""); }); - expect(taken?.fileIds).toEqual([fileId]); + expect(taken?.parts).toEqual([{ type: "file", ...uploadedAs(file) }]); }); it.each([ @@ -365,7 +373,10 @@ describe("useComposerAttachments", () => { 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 }); + expect(result.current.items[0]?.upload).toEqual({ + state: "uploaded", + attachment: uploadedAs(file, freshId), + }); }); it("puts files back only once", async () => { diff --git a/packages/web/src/lib/composer-attachments.ts b/packages/web/src/lib/composer-attachments.ts index 46cb01aa..11d3d61b 100644 --- a/packages/web/src/lib/composer-attachments.ts +++ b/packages/web/src/lib/composer-attachments.ts @@ -1,7 +1,9 @@ import { attachmentNameSchema, + type FilePart, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENTS_PER_MESSAGE, + type UploadedAttachment, } from "@sugabots/contracts"; import { isApiFailure } from "@sugabots/sdk"; import { Option, Schema } from "effect"; @@ -12,7 +14,8 @@ import { removeChatFile, UploadInterrupted, uploadChatFile } from "@/lib/chat-fi export type AttachmentUpload = /** `progress` runs from 0 to 1. */ | { state: "uploading"; progress: number } - | { state: "uploaded"; fileId: string } + /** `attachment` is the file as the API kept it, which may not be as the browser said. */ + | { state: "uploaded"; attachment: UploadedAttachment } /** * Not uploaded, or no longer there to send, for `reason`. Only a * `retryable` failure can go differently when the same file is tried again. @@ -51,7 +54,8 @@ export type ComposerAttachments = { /** The files taken to send with a message. */ export type FilesForSending = { - fileIds: string[]; + /** The files as the message will show them, which it can before the API answers. */ + parts: FilePart[]; /** * 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 @@ -129,7 +133,7 @@ export function useComposerAttachments(chatId: string | undefined): ComposerAtta return; } inFlight.current.delete(key); - setUpload(key, { state: "uploaded", fileId: uploaded.id }); + setUpload(key, { state: "uploaded", attachment: uploaded }); }, (failure: unknown) => { if (!stillWanted()) return; @@ -160,7 +164,9 @@ export function useComposerAttachments(chatId: string | undefined): ComposerAtta if (!item) return; inFlight.current.get(key)?.abort(); inFlight.current.delete(key); - if (chatId && item.upload.state === "uploaded") removeChatFile(chatId, item.upload.fileId); + if (chatId && item.upload.state === "uploaded") { + removeChatFile(chatId, item.upload.attachment.id); + } setLeftOut(0); commit((items) => items.filter((candidate) => candidate.key !== key)); } @@ -180,7 +186,9 @@ export function useComposerAttachments(chatId: string | undefined): ComposerAtta commit(() => []); let returned = false; return { - fileIds, + parts: taken.flatMap(({ upload }): FilePart[] => + upload.state === "uploaded" ? [{ type: "file", ...upload.attachment }] : [], + ), putBack: (sendFailure) => { if (returned) return; returned = true; @@ -284,7 +292,7 @@ export function isFileGoneRefusal(failure: unknown): boolean { } function uploadedFileIds(items: readonly ComposerAttachment[]): string[] { - return items.flatMap(({ upload }) => (upload.state === "uploaded" ? [upload.fileId] : [])); + return items.flatMap(({ upload }) => (upload.state === "uploaded" ? [upload.attachment.id] : [])); } /** The API's messages leave off the full stop a sentence beside others needs. */ diff --git a/packages/web/src/lib/file-looks.ts b/packages/web/src/lib/file-looks.ts new file mode 100644 index 00000000..daca6f10 --- /dev/null +++ b/packages/web/src/lib/file-looks.ts @@ -0,0 +1,35 @@ +import { mediaTypeEssence } from "@sugabots/contracts"; + +/** How a file is drawn: as its picture, as a page, as a sheet, or as a plain file. */ +export type FileLook = "image" | "pdf" | "sheet" | "other"; + +const SHEET_EXTENSIONS = new Set(["csv", "tsv", "xls", "xlsx", "ods", "numbers"]); + +/** + * `file`'s look, from its media type and, when that says little, its name. + * Any image is drawn as its picture, where an image the browser can't draw + * falls back to a file's icon. + */ +export function fileLookOf(file: { name: string; type: string }): FileLook { + const mediaType = mediaTypeEssence(file.type); + if (mediaType.startsWith("image/")) return "image"; + const extension = extensionOf(file.name); + if (mediaType === "application/pdf" || extension === "pdf") return "pdf"; + if (mediaType === "text/csv" || SHEET_EXTENSIONS.has(extension)) return "sheet"; + return "other"; +} + +/** The part of `name` after its last dot, lowercased, or "" when it has none. */ +export function extensionOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +/** A file's size as people read it, such as 240 KB or 2.1 MB. */ +export function fileSizeLabel(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kilobytes = Math.round(bytes / 1024); + // Rounded first, so 1,048,100 bytes reads 1.0 MB rather than 1024 KB. + if (kilobytes < 1024) return `${kilobytes} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/packages/web/src/screens/AgentChat.tsx b/packages/web/src/screens/AgentChat.tsx index 1fd24169..2d7d79df 100644 --- a/packages/web/src/screens/AgentChat.tsx +++ b/packages/web/src/screens/AgentChat.tsx @@ -24,6 +24,7 @@ import { import { v4 as uuidv4 } from "uuid"; import { useAuthorLinks } from "@/lib/author-links.ts"; import { useChatDraft, useChatReplyDraft } from "@/lib/chat-draft.ts"; +import { chatFileUrl } from "@/lib/chat-files.ts"; import { useChat, useChatHistory, @@ -221,7 +222,7 @@ export function AgentChat({ message, peopleOnly: writingToPeople, replyTo: answering, - ...(files.fileIds.length > 0 && { attachments: files.fileIds }), + files: files.parts, }); scrollToLatest(); await sent; @@ -368,6 +369,7 @@ export function AgentChat({ onOpenThread={openThread} onOpenArtifact={openArtifact} onOpenDesktop={openDesktop} + fileUrlOf={(fileId) => chatFileUrl(chat.data.id, fileId)} previews={!staged} podId={pod.id} approvalCapabilities={details.capabilities} diff --git a/packages/web/src/screens/ChatThreadPanel.tsx b/packages/web/src/screens/ChatThreadPanel.tsx index 8adea255..66938e85 100644 --- a/packages/web/src/screens/ChatThreadPanel.tsx +++ b/packages/web/src/screens/ChatThreadPanel.tsx @@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router"; import { ArrowUpRight, Braces, ChevronLeft, CircleAlert } from "lucide-react"; import { type ReactNode, type Ref, useEffect, useRef } from "react"; import { useAuthorLinks } from "@/lib/author-links.ts"; +import { chatFileUrl } from "@/lib/chat-files.ts"; import { routineSettingsLink } from "@/lib/links.ts"; import { useBackToHere } from "@/lib/settings-back.tsx"; import { useThreadEvents, useThreadNotices } from "@/lib/thread-events.ts"; @@ -170,6 +171,7 @@ export function ChatThreadPanel({

)} chatFileUrl(chatId, fileId)} messages={ type === "routine" ? details.messages.filter((message) => message.author.kind !== "routine_trigger") diff --git a/packages/web/src/screens/ComposerAttachments.tsx b/packages/web/src/screens/ComposerAttachments.tsx index edcac389..6f083b57 100644 --- a/packages/web/src/screens/ComposerAttachments.tsx +++ b/packages/web/src/screens/ComposerAttachments.tsx @@ -11,7 +11,9 @@ import { useState, } from "react"; import type { AttachmentUpload, ComposerAttachment } from "@/lib/composer-attachments.ts"; +import { extensionOf, type FileLook, fileLookOf } from "@/lib/file-looks.ts"; import { listNames } from "@/lib/name-list.ts"; +import { MiddleTruncated } from "@/ui/middle-truncated.tsx"; import { Tooltip } from "@/ui/tooltip.tsx"; /** @@ -247,24 +249,6 @@ export function AttachmentChip({ ); } -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": @@ -385,21 +369,6 @@ function UploadProgress({ name, fraction }: { name: string; fraction: number }) ); } -/** 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, diff --git a/packages/web/src/screens/MessageFiles.stories.tsx b/packages/web/src/screens/MessageFiles.stories.tsx new file mode 100644 index 00000000..dacd319e --- /dev/null +++ b/packages/web/src/screens/MessageFiles.stories.tsx @@ -0,0 +1,112 @@ +import type { FilePart } from "@sugabots/contracts"; +import { expect, waitFor } from "storybook/test"; +import preview from "#storybook/preview"; +import { MessageFiles } from "./MessageFiles.tsx"; + +/* + * The files attached to a sent message, under its words: images as pictures + * that open full size in a new tab, and other files as chips to download. + */ + +/** A picture drawn as an SVG, so the stories need no server and look the same every run. */ +function pictureUrl(label: string, hue: number): string { + const svg = `${label}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +function file(id: number, name: string, contentType: string, size: number): FilePart { + return { + type: "file", + id: `0199a3a0-0000-7000-8000-0000000002${String(id).padStart(2, "0")}`, + name, + contentType, + size, + }; +} + +const oldPricing = file(1, "pricing-page-old.png", "image/png", 412_000); +const newPricing = file(2, "pricing-page-new.png", "image/png", 398_000); +const acmeEmail = file(3, "acme-email.png", "image/png", 220_000); +const acmeRequest = file(4, "acme-retail-request.pdf", "application/pdf", 245_760); +const pipeline = file(5, "q4-pipeline.csv", "text/csv", 18_400); +const brandGuide = file( + 6, + "northwind-labs-brand-guide-final-v3.key", + "application/octet-stream", + 8_900_000, +); + +const pictures = new Map( + [oldPricing, newPricing, acmeEmail].map((picture, index) => [ + picture.id, + pictureUrl(picture.name.replace(".png", ""), 200 + index * 50), + ]), +); +// Bytes no browser can draw, so the image falls back without asking any server. +const undrawable = "data:image/heic;base64,AAAAGGZ0eXBoZWljAAAAAA=="; +const urlOf = (fileId: string) => + pictures.get(fileId) ?? (fileId.endsWith("07") ? undrawable : `/files/${fileId}`); + +/** An image the browser can't draw: a HEIC photo, which falls back to its name and an icon. */ +const holidayPhoto = file(7, "holiday.heic", "image/heic", 2_400_000); + +const meta = preview.meta({ + title: "Product/MessageFiles", + component: MessageFiles, + tags: ["ai-generated"], + args: { files: [acmeRequest], urlOf }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}); + +/** One image, fitted to a 4:3 frame, opening full size in a new tab. */ +export const OneImage = meta.story({ + args: { files: [newPricing] }, + play: async ({ canvas }) => { + const link = canvas.getByRole("link", { name: "Open pricing-page-new.png" }); + await expect(link).toHaveAttribute("target", "_blank"); + }, +}); + +/** Two images, side by side. */ +export const TwoImages = meta.story({ args: { files: [oldPricing, newPricing] } }); + +/** Three images and a PDF. */ +export const ImagesAndAFile = meta.story({ + args: { files: [oldPricing, newPricing, acmeEmail, acmeRequest] }, + play: async ({ canvas }) => { + await expect(canvas.getAllByRole("link", { name: /^Open / })).toHaveLength(3); + await expect( + canvas.getByRole("link", { name: "Download acme-retail-request.pdf" }), + ).toHaveAttribute("download", "acme-retail-request.pdf"); + }, +}); + +/** Files only: a sheet, and a file with a long name cut in its middle so its end stays. */ +export const FilesOnly = meta.story({ + args: { files: [pipeline, brandGuide] }, + play: async ({ canvas }) => { + await expect(canvas.getByText("CSV · 18 KB")).toBeVisible(); + await expect(canvas.getByText("KEY · 8.5 MB")).toBeVisible(); + }, +}); + +/** On a phone: images three to a row across the width, and chips as wide as the message. */ +export const OnAPhone = meta.story({ + args: { files: [oldPricing, newPricing, acmeEmail, acmeRequest, pipeline] }, + globals: { viewport: { value: "iphone12", isRotated: false } }, +}); + +/** An image the browser can't draw shows its name and an icon, still opening the file. */ +export const ImageThatWontDraw = meta.story({ + args: { files: [holidayPhoto] }, + play: async ({ canvas }) => { + const link = canvas.getByRole("link", { name: "Open holiday.heic" }); + await waitFor(() => expect(link.querySelector("img")).toBeNull()); + }, +}); diff --git a/packages/web/src/screens/MessageFiles.tsx b/packages/web/src/screens/MessageFiles.tsx new file mode 100644 index 00000000..34282678 --- /dev/null +++ b/packages/web/src/screens/MessageFiles.tsx @@ -0,0 +1,154 @@ +import type { FilePart } from "@sugabots/contracts"; +import { cn } from "cn"; +import { Download, FileIcon, FileSpreadsheet, FileText } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { extensionOf, type FileLook, fileLookOf, fileSizeLabel } from "@/lib/file-looks.ts"; +import { IconButton } from "@/ui/icon-button.tsx"; +import { MiddleTruncated } from "@/ui/middle-truncated.tsx"; + +/** + * Where an attached file is served, or undefined while it can't be yet, as + * for a message still being sent. + */ +export type FileUrlOf = (fileId: string) => string | undefined; + +/** + * The files attached to a sent message, under its words: its images as + * pictures, each opening full size in a new tab, then every other file as a + * chip to download. A file not served yet shows without a link. + */ +export function MessageFiles({ files, urlOf }: { files: readonly FilePart[]; urlOf: FileUrlOf }) { + const images = files.filter((file) => lookOf(file) === "image"); + const others = files.filter((file) => lookOf(file) !== "image"); + return ( +
+ {images.length > 0 && } + {others.length > 0 && ( +
    + {others.map((file) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function lookOf(file: FilePart): FileLook { + return fileLookOf({ name: file.name, type: file.contentType }); +} + +/** + * One image in a 4:3 frame up to 360 by 270, two side by side, or three to a + * row, each tile 4:3 and the grid no wider than the chat it is in. Every image + * is shown: with no viewer to step through them, one hidden behind a count + * could not be opened. Tiles keep their size before their images load, so + * the thread doesn't move as they arrive. + */ +function ImageGrid({ images, urlOf }: { images: readonly FilePart[]; urlOf: FileUrlOf }) { + const [only] = images; + if (images.length === 1 && only) { + return ( +
+ +
+ ); + } + return ( +
    + {images.map((file) => ( +
  • + +
  • + ))} +
+ ); +} + +/** + * An image filling its frame, opening full size in a new tab. One the browser + * can't draw, or not served yet, shows its name and a file icon instead. + */ +function ImageTile({ + file, + url, + fit, +}: { + file: FilePart; + url: string | undefined; + fit: "cover" | "contain"; +}) { + const [undrawable, setUndrawable] = useState(false); + const frame = + "flex size-full items-center justify-center overflow-hidden rounded-lg border border-border bg-tile"; + const standIn = ( + + + + + ); + if (!url) return
{standIn}
; + return ( + + {undrawable ? ( + standIn + ) : ( + setUndrawable(true)} + className={cn("block size-full", fit === "cover" ? "object-cover" : "object-contain")} + /> + )} + + ); +} + +/** A file that isn't a picture: its kind, name and size, and a way to download it once it can be. */ +function FileChip({ file, url }: { file: FilePart; url: string | undefined }) { + const extension = extensionOf(file.name).toUpperCase(); + return ( +
+ + + + + + + {extension ? `${extension} · ` : ""} + {fileSizeLabel(file.size)} + + + {url && ( + } + > + + + )} +
+ ); +} + +function FileLookIcon({ look }: { look: FileLook }): ReactNode { + if (look === "pdf") return ; + if (look === "sheet") return ; + return ; +} diff --git a/packages/web/src/screens/ThreadConversation.stories.tsx b/packages/web/src/screens/ThreadConversation.stories.tsx index bddcb044..417e3186 100644 --- a/packages/web/src/screens/ThreadConversation.stories.tsx +++ b/packages/web/src/screens/ThreadConversation.stories.tsx @@ -1,4 +1,5 @@ import type { + FilePart, Message, MessagePart, MessageReaction, @@ -47,6 +48,9 @@ function message(id: string, author: Message["author"], content: string): Messag const POD = "0199a3a0-0000-7000-8000-00000000000b"; +/** Where a file would be served. Most stories have none, so nothing asks for one. */ +const fileUrlOf = (fileId: string) => `/files/${fileId}`; + function toolCall(id: string, tool: string): ToolCallPart { return { type: "tool_call", @@ -77,6 +81,7 @@ const meta = preview.meta({ participants: [host, person], podId: POD, onOpenThread: fn(), + fileUrlOf, }, decorators: [ function WithQueries(Story) { @@ -98,6 +103,100 @@ const meta = preview.meta({ ], }); +/** A picture drawn as an SVG, so the story needs no server and looks the same every run. */ +function pictureUrl(label: string, hue: number): string { + const svg = `${label}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +const attachedFiles: FilePart[] = [ + ["01", "pricing-page-old.png", "image/png", 412_000], + ["02", "pricing-page-new.png", "image/png", 398_000], + ["03", "acme-email.png", "image/png", 220_000], + ["04", "acme-retail-request.pdf", "application/pdf", 245_760], +].map(([suffix, name, contentType, size]) => ({ + type: "file", + id: `0199a3a0-0000-7000-8000-0000000003${suffix}`, + name: String(name), + contentType: String(contentType), + size: Number(size), +})); +const filesOnly: FilePart = { + type: "file", + id: "0199a3a0-0000-7000-8000-000000000305", + name: "q4-pipeline.csv", + contentType: "text/csv", + size: 18_400, +}; +const pictures = new Map( + attachedFiles + .slice(0, 3) + .map((file, index) => [file.id, pictureUrl(file.name.replace(".png", ""), 200 + index * 50)]), +); + +const askedWithFiles: Message = { + ...message( + "0199a3a0-0000-7000-8000-000000000310", + person, + "Here's Acme's request and both pricing pages. Can you check the numbers?", + ), + parts: [ + { + type: "text", + text: "Here's Acme's request and both pricing pages. Can you check the numbers?", + }, + ...attachedFiles, + ], +}; +const sentFilesOnly: Message = { + ...message("0199a3a0-0000-7000-8000-000000000311", person, ""), + parts: [filesOnly], + createdAt: "2026-09-22T04:32:00.000Z", +}; +/** The pictures where they are drawn, and every other file where it would be served. */ +const servedAt = (fileId: string) => pictures.get(fileId) ?? fileUrlOf(fileId); +const stillSending: Message = { + ...message("0199a3a0-0000-7000-8000-000000000312", person, "Numbers attached"), + parts: [{ type: "text", text: "Numbers attached" }, ...attachedFiles.slice(2)], +}; + +/** + * Files a person attached: under the words of a message with text, and on + * their own in a message that has nothing else. Images open full size; the + * PDF and the sheet download. + */ +export const WithFiles = meta.story({ + args: { + fileUrlOf: servedAt, + messages: [askedWithFiles, sentFilesOnly], + }, + play: async ({ canvas }) => { + await expect(canvas.getByText(/Can you check the numbers/)).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: "Open pricing-page-old.png" }), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: "Download acme-retail-request.pdf" }), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: "Download q4-pipeline.csv" }), + ).toBeInTheDocument(); + }, +}); + +/** A message still being sent: its files show, without links until the API can serve them. */ +export const FilesWhileSending = meta.story({ + args: { + fileUrlOf: servedAt, + messages: [stillSending], + sending: new Set([stillSending.id]), + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("PDF · 240 KB")).toBeInTheDocument(); + await expect(canvas.queryByRole("link", { name: /^Download/ })).toBeNull(); + }, +}); + /** * A routine run's result, posted in the bot's chat when the run completed, * with a link under it to the run and the work behind it. diff --git a/packages/web/src/screens/ThreadConversation.tsx b/packages/web/src/screens/ThreadConversation.tsx index f4330ab3..a2a735c7 100644 --- a/packages/web/src/screens/ThreadConversation.tsx +++ b/packages/web/src/screens/ThreadConversation.tsx @@ -1,6 +1,7 @@ import { botColorVariables } from "@sugabots/avatars"; import { type CollaborationPart, + type FilePart, isNarration, type Message, type MessageReplyTo, @@ -26,6 +27,7 @@ import { IconButton } from "@/ui/icon-button.tsx"; import { Tooltip } from "@/ui/tooltip.tsx"; import { ArtifactCards } from "./ArtifactCards.tsx"; import { LiveDesktopCard } from "./DesktopViewer.tsx"; +import { type FileUrlOf, MessageFiles } from "./MessageFiles.tsx"; import { MessageMarkdown } from "./MessageMarkdown.tsx"; import { AddReactionButton, MessageReactions, type Reacting } from "./MessageReactions.tsx"; import { textWithMentions } from "./mentions.tsx"; @@ -82,6 +84,7 @@ export function ThreadConversation({ onReply, onJumpToMessage, reacting, + fileUrlOf, }: { messages: Message[]; host: AgentParticipant; @@ -151,6 +154,11 @@ export function ThreadConversation({ * a reaction. Without it, reactions are only shown. */ reacting?: Reacting; + /** + * Where a file attached to a message is served. A message still being sent + * shows its files without links, as the API can't serve them until it has it. + */ + fileUrlOf: (fileId: string) => string; }) { const lastMessage = messages.at(-1); // The turn has started but its reply has not been created yet. @@ -196,6 +204,7 @@ export function ThreadConversation({ onReply={onReply} onJumpToMessage={onJumpToMessage} reacting={reacting} + fileUrlOf={fileUrlOf} onOpenThread={onOpenThread} onOpenArtifact={onOpenArtifact} onOpenDesktop={onOpenDesktop} @@ -266,6 +275,9 @@ function useRepliesWatchedBeingWritten(messages: readonly Message[]): ReadonlySe } const NONE_QUEUED: ReadonlySet = new Set(); +/** Where a file is served while its message is still being sent: nowhere yet. */ +const notServedYet: FileUrlOf = () => undefined; + const NONE_SENDING: ReadonlySet = new Set(); /** Screens that cannot hover, which is to say touch screens. */ @@ -385,6 +397,7 @@ function MessageRow({ onReply, onJumpToMessage, reacting, + fileUrlOf, onOpenThread, onOpenArtifact, onOpenDesktop, @@ -414,6 +427,8 @@ function MessageRow({ onReply?: (message: Message) => void; onJumpToMessage?: (messageId: string) => void; reacting?: Reacting; + /** Where a file attached to it is served. */ + fileUrlOf: (fileId: string) => string; onOpenThread: (threadId: string) => void; onOpenArtifact?: (artifactId: string) => void; onOpenDesktop?: () => void; @@ -435,6 +450,7 @@ function MessageRow({ const agent = author.kind === "agent" ? author : undefined; const size = ROW[compact ? "compact" : "regular"]; const calls = message.parts.filter((part): part is ToolCallPart => part.type === "tool_call"); + const files = message.parts.filter((part): part is FilePart => part.type === "file"); const segments = segmentsOf(message); const words = segments .flatMap((segment) => (segment.type === "text" && segment.text ? [segment.text] : [])) @@ -560,6 +576,9 @@ function MessageRow({ ); })} + {files.length > 0 && ( + + )} {agent && previews && onOpenDesktop && diff --git a/packages/web/src/screens/attachment-story-fixtures.ts b/packages/web/src/screens/attachment-story-fixtures.ts index 9a227608..b8ff59d8 100644 --- a/packages/web/src/screens/attachment-story-fixtures.ts +++ b/packages/web/src/screens/attachment-story-fixtures.ts @@ -31,13 +31,20 @@ function storyFile(name: string, type: string, content = "Story fixture"): File } const pricingPage = storyFile("pricing-page-new.svg", "image/svg+xml", PRICING_PAGE_SVG); -const done = (fileId: string): AttachmentUpload => ({ state: "uploaded", fileId }); +/** `file`, uploaded and kept by the API as `id`. */ +function uploaded(file: File, id: string): ComposerAttachment { + const contentType = file.type || "application/octet-stream"; + return attached(file, { + state: "uploaded", + attachment: { id, name: file.name, contentType, size: file.size }, + }); +} 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( +export const pricingPageReady = uploaded(pricingPage, "0199a3a0-0000-7000-8000-000000000a01"); +export const acmeRequest = uploaded( storyFile("acme-retail-request.pdf", "application/pdf"), - done("0199a3a0-0000-7000-8000-000000000a02"), + "0199a3a0-0000-7000-8000-000000000a02", ); export const callRecordingTooLarge = attached(storyFile("sales-call-recording.mp4", "video/mp4"), { state: "tooLarge", @@ -56,19 +63,19 @@ export const brandGuideUploading = attached(storyFile("brand-guide.key", ""), { state: "uploading", progress: 0.12, }); -export const pipelineSheet = attached( +export const pipelineSheet = uploaded( storyFile("q4-pipeline.csv", "text/csv"), - done("0199a3a0-0000-7000-8000-000000000a03"), + "0199a3a0-0000-7000-8000-000000000a03", ); -export const signedOrderForm = attached( +export const signedOrderForm = uploaded( storyFile("northwind-labs-signed-order-form.pdf", "application/pdf"), - done("0199a3a0-0000-7000-8000-000000000a04"), + "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( + uploaded( storyFile(`invoice-${1041 + index}.pdf`, "application/pdf"), - done(`0199a3a0-0000-7000-8000-000000000b${String(index).padStart(2, "0")}`), + `0199a3a0-0000-7000-8000-000000000b${String(index).padStart(2, "0")}`, ), ); diff --git a/packages/web/src/screens/thread-conversation.test.tsx b/packages/web/src/screens/thread-conversation.test.tsx index f85face9..dd9c76a5 100644 --- a/packages/web/src/screens/thread-conversation.test.tsx +++ b/packages/web/src/screens/thread-conversation.test.tsx @@ -101,6 +101,7 @@ function show( const thread = (shown: Message[], participants = over.participants ?? [host]) => ( `/files/${fileId}`} messages={shown} host={host} isRunning={false} diff --git a/packages/web/src/ui/middle-truncated.tsx b/packages/web/src/ui/middle-truncated.tsx new file mode 100644 index 00000000..ec5fe173 --- /dev/null +++ b/packages/web/src/ui/middle-truncated.tsx @@ -0,0 +1,17 @@ +import { cn } from "cn"; +import { extensionOf } from "@/lib/file-looks.ts"; + +/** How many characters before the extension stay when a name is cut short. */ +const MIDDLE_TRUNCATION_KEPT = 3; + +/** Shows `text` on one line, cutting from its middle when it is too long, so its end and extension stay. */ +export 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)} + + ); +}