From 843f188aaafd3a19272f3867a686644d6a31c325 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:58:24 -0500 Subject: fix(app): support text attachments (#17335) --- packages/ui/src/components/message-file.test.ts | 55 ++++++++++++++++++++ packages/ui/src/components/message-file.ts | 14 ++++++ packages/ui/src/components/message-part.css | 33 +++++++++++- packages/ui/src/components/message-part.tsx | 67 +++++++++++-------------- 4 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/components/message-file.test.ts create mode 100644 packages/ui/src/components/message-file.ts (limited to 'packages/ui/src') diff --git a/packages/ui/src/components/message-file.test.ts b/packages/ui/src/components/message-file.test.ts new file mode 100644 index 000000000..7bdf00763 --- /dev/null +++ b/packages/ui/src/components/message-file.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import type { FilePart } from "@opencode-ai/sdk/v2" +import { attached, inline, kind } from "./message-file" + +function file(part: Partial = {}): FilePart { + return { + id: "part_1", + sessionID: "ses_1", + messageID: "msg_1", + type: "file", + mime: "text/plain", + url: "file:///repo/README.txt", + filename: "README.txt", + ...part, + } +} + +describe("message-file", () => { + test("treats data URLs as attachments", () => { + expect(attached(file({ url: "data:text/plain;base64,SGVsbG8=" }))).toBe(true) + expect(attached(file())).toBe(false) + }) + + test("treats only non-attachment source ranges as inline references", () => { + expect( + inline( + file({ + source: { + type: "file", + path: "/repo/README.txt", + text: { value: "@README.txt", start: 0, end: 11 }, + }, + }), + ), + ).toBe(true) + + expect( + inline( + file({ + url: "data:text/plain;base64,SGVsbG8=", + source: { + type: "file", + path: "/repo/README.txt", + text: { value: "@README.txt", start: 0, end: 11 }, + }, + }), + ), + ).toBe(false) + }) + + test("separates image and file attachment kinds", () => { + expect(kind(file({ mime: "image/png" }))).toBe("image") + expect(kind(file({ mime: "application/pdf" }))).toBe("file") + }) +}) diff --git a/packages/ui/src/components/message-file.ts b/packages/ui/src/components/message-file.ts new file mode 100644 index 000000000..ecc745690 --- /dev/null +++ b/packages/ui/src/components/message-file.ts @@ -0,0 +1,14 @@ +import type { FilePart } from "@opencode-ai/sdk/v2" + +export function attached(part: FilePart) { + return part.url.startsWith("data:") +} + +export function inline(part: FilePart) { + if (attached(part)) return false + return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined +} + +export function kind(part: FilePart) { + return part.mime.startsWith("image/") ? "image" : "file" +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index f01408a38..5a325693b 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -38,10 +38,12 @@ flex-direction: column; align-items: center; justify-content: center; + min-width: 0; border-radius: 6px; overflow: hidden; background: var(--surface-weak); border: 1px solid var(--border-weak-base); + cursor: default; transition: border-color 0.15s ease, opacity 0.3s ease; @@ -50,14 +52,19 @@ border-color: var(--border-strong-base); } + &[data-clickable] { + cursor: pointer; + } + &[data-type="image"] { width: 48px; height: 48px; } &[data-type="file"] { - width: 48px; + width: min(220px, 100%); height: 48px; + padding: 0 10px; } } @@ -81,6 +88,30 @@ } } + [data-slot="user-message-attachment-file"] { + width: 100%; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + + [data-component="file-icon"] { + width: 20px; + height: 20px; + flex: none; + } + } + + [data-slot="user-message-attachment-name"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-base); + font-size: var(--font-size-small); + line-height: var(--line-height-large); + } + [data-slot="user-message-body"] { width: fit-content; max-width: min(82%, 64ch); diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b580998b6..e8c9dcf95 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -54,6 +54,7 @@ import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { animate } from "motion" import { useLocation } from "@solidjs/router" +import { attached, inline, kind } from "./message-file" function ShellSubmessage(props: { text: string; animate?: boolean }) { let widthRef: HTMLSpanElement | undefined @@ -901,19 +902,9 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) - const attachments = createMemo(() => - files()?.filter((f) => { - const mime = f.mime - return mime.startsWith("image/") || mime === "application/pdf" - }), - ) + const attachments = createMemo(() => files().filter(attached)) - const inlineFiles = createMemo(() => - files().filter((f) => { - const mime = f.mime - return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined - }), - ) + const inlineFiles = createMemo(() => files().filter(inline)) const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) @@ -973,32 +964,34 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp 0}>
- {(file) => ( -
{ - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - - -
- } + {(file) => { + const type = kind(file) + const name = file.filename ?? i18n.t("ui.message.attachment.alt") + + return ( +
{ + if (type === "image") openImagePreview(file.url, name) + }} > - {file.filename - -
- )} + + + {name} +
+ } + > + {name} +
+ + ) + }} -- cgit v1.2.3