diff options
| author | Adam <[email protected]> | 2026-03-13 06:58:24 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-13 06:58:24 -0500 |
| commit | 843f188aaafd3a19272f3867a686644d6a31c325 (patch) | |
| tree | ef2834837ff44a86f7222ed6518328d0ab46b640 /packages/ui/src/components | |
| parent | 05cb3c87ca387be41aceb5ccad978c6848a56f70 (diff) | |
| download | opencode-843f188aaafd3a19272f3867a686644d6a31c325.tar.gz opencode-843f188aaafd3a19272f3867a686644d6a31c325.zip | |
fix(app): support text attachments (#17335)
Diffstat (limited to 'packages/ui/src/components')
| -rw-r--r-- | packages/ui/src/components/message-file.test.ts | 55 | ||||
| -rw-r--r-- | packages/ui/src/components/message-file.ts | 14 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 33 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 67 |
4 files changed, 131 insertions, 38 deletions
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> = {}): 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 <Show when={attachments().length > 0}> <div data-slot="user-message-attachments"> <For each={attachments()}> - {(file) => ( - <div - data-slot="user-message-attachment" - data-type={file.mime.startsWith("image/") ? "image" : "file"} - onClick={() => { - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - <Show - when={file.mime.startsWith("image/") && file.url} - fallback={ - <div data-slot="user-message-attachment-icon"> - <Icon name="folder" /> - </div> - } + {(file) => { + const type = kind(file) + const name = file.filename ?? i18n.t("ui.message.attachment.alt") + + return ( + <div + data-slot="user-message-attachment" + data-type={type} + data-clickable={type === "image" ? "true" : undefined} + title={type === "file" ? name : undefined} + onClick={() => { + if (type === "image") openImagePreview(file.url, name) + }} > - <img - data-slot="user-message-attachment-image" - src={file.url} - alt={file.filename ?? i18n.t("ui.message.attachment.alt")} - /> - </Show> - </div> - )} + <Show + when={type === "image"} + fallback={ + <div data-slot="user-message-attachment-file"> + <FileIcon node={{ path: name, type: "file" }} /> + <span data-slot="user-message-attachment-name">{name}</span> + </div> + } + > + <img data-slot="user-message-attachment-image" src={file.url} alt={name} /> + </Show> + </div> + ) + }} </For> </div> </Show> |
