fix(app): support text attachments (#17335)

This commit is contained in:
Adam
2026-03-13 06:58:24 -05:00
committed by GitHub
parent 05cb3c87ca
commit 843f188aaa
28 changed files with 422 additions and 136 deletions

View File

@@ -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")
})
})

View File

@@ -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"
}

View File

@@ -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);

View File

@@ -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>