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)}
+
+ );
+}