summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-13 06:58:24 -0500
committerGitHub <[email protected]>2026-03-13 06:58:24 -0500
commit843f188aaafd3a19272f3867a686644d6a31c325 (patch)
treeef2834837ff44a86f7222ed6518328d0ab46b640 /packages/ui/src/components
parent05cb3c87ca387be41aceb5ccad978c6848a56f70 (diff)
downloadopencode-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.ts55
-rw-r--r--packages/ui/src/components/message-file.ts14
-rw-r--r--packages/ui/src/components/message-part.css33
-rw-r--r--packages/ui/src/components/message-part.tsx67
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>