diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index eb3181e1..f8bc3441 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -4,6 +4,7 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; +import { MessageFilesDisplay } from '../../FileAttachment'; import { getToolMetadata } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2'; import { toolDisplayStyles } from '@/lib/typography'; @@ -1513,15 +1514,6 @@ const ToolExpandedContent: React.FC = React.memo(({ const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0; const outputString = typeof rawOutput === 'string' ? rawOutput : ''; const attachments = stateWithData.attachments; - const imageAttachments = React.useMemo(() => { - if (!Array.isArray(attachments)) return []; - return attachments.filter((f): f is FilePart & { url: string } => f.type === 'file' && typeof f.mime === 'string' && f.mime.startsWith('image/') && typeof f.url === 'string'); - }, [attachments]); - - const otherAttachments = React.useMemo(() => { - if (!Array.isArray(attachments)) return []; - return attachments.filter((f): f is FilePart => f.type === 'file' && !(typeof f.mime === 'string' && f.mime.startsWith('image/'))); - }, [attachments]); const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch) @@ -1584,33 +1576,6 @@ const ToolExpandedContent: React.FC = React.memo(({ setDiffViewMode('unified'); }, [part.id]); - const imageGallery = React.useMemo(() => { - return imageAttachments.flatMap((f) => { - if (!f.url) return []; - return [{ url: f.url, mimeType: f.mime, filename: f.filename }]; - }); - }, [imageAttachments]); - - const handleAttachmentClick = React.useCallback((index: number) => { - if (!onShowPopup || index >= imageGallery.length) return; - const file = imageGallery[index]; - if (!file?.url) return; - const filename = file.filename || t('filesView.editor.imageAltFallback'); - onShowPopup({ - open: true, - title: filename, - content: '', - metadata: { tool: 'image-preview', filename: file.filename, mime: file.mimeType }, - image: { - url: file.url, - mimeType: file.mimeType, - filename: file.filename, - gallery: imageGallery, - index, - }, - }); - }, [imageGallery, onShowPopup, t]); - const renderScrollableBlock = ( content: React.ReactNode, options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string } @@ -1964,60 +1929,8 @@ const ToolExpandedContent: React.FC = React.memo(({ )} - {attachments && Array.isArray(attachments) && attachments.length > 0 && state.status === 'completed' ? ( -
- {imageAttachments.length > 0 ? ( -
- {imageAttachments.map((file, index) => { - const filename = file.filename || t('chat.toolPart.attachmentFallback'); - return ( - - ); - })} -
- ) : null} - {otherAttachments.length > 0 ? ( -
- {otherAttachments.map((file, index) => { - const fileName = file.filename || t('chat.fileAttachment.fileFallback'); - const ext = fileName.split('.').pop() || ''; - return ( -
- - {fileName} -
- ); - })} -
- ) : null} -
+ {Array.isArray(attachments) && attachments.length > 0 && state.status === 'completed' ? ( + ) : null} ); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 1e9d90ac..83036ff8 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2069,7 +2069,6 @@ export const dict = { 'chat.toolPart.copiedOutput': 'Copied output', 'chat.toolPart.copyOutputFailed': 'Failed to copy output', 'chat.toolPart.openSubtask': 'Open {type} subtask', - 'chat.toolPart.attachmentFallback': 'Attachment', 'chat.todo.total': 'Total', 'chat.todo.inProgress': 'In Progress', 'chat.todo.pending': 'Pending', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 95c49d38..a888528a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2035,7 +2035,6 @@ export const dict: Record = { "chat.toolPart.copiedOutput": "Salida copiada", "chat.toolPart.copyOutputFailed": "No se pudo copiar la salida", "chat.toolPart.openSubtask": "Abrir subtarea {type}", - "chat.toolPart.attachmentFallback": "Archivo adjunto", "chat.todo.total": "Total", "chat.todo.inProgress": "En progreso", "chat.todo.pending": "Pendiente", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 551f43bf..f8f6ccdb 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1838,7 +1838,6 @@ export const dict = { 'chat.toolPart.noOutputProduced': 'Aucune sortie produite', 'chat.toolPart.output': 'Sortie', 'chat.toolPart.openSubtask': 'Ouvrir la sous-tâche {type}', - 'chat.toolPart.attachmentFallback': 'Pièce jointe', 'chat.todo.total': 'Total', 'chat.todo.inProgress': 'En cours', 'chat.todo.pending': 'En attente', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index ca46f897..11e921e5 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2068,7 +2068,6 @@ export const dict: Record = { 'chat.toolPart.copiedOutput': '出力をコピーしました', 'chat.toolPart.copyOutputFailed': '出力のコピーに失敗しました', 'chat.toolPart.openSubtask': '{type}サブタスクを開く', - 'chat.toolPart.attachmentFallback': '添付ファイル', 'chat.todo.total': '合計', 'chat.todo.inProgress': '進行中', 'chat.todo.pending': '保留中', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index e9a8da13..e238234b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2069,7 +2069,6 @@ export const dict: Record = { 'chat.toolPart.copiedOutput': '출력 복사됨', 'chat.toolPart.copyOutputFailed': '출력을 복사하지 못했습니다', 'chat.toolPart.openSubtask': '{type} 하위 작업 열기', - 'chat.toolPart.attachmentFallback': '첨부 파일', 'chat.todo.total': '전체', 'chat.todo.inProgress': '진행 중', 'chat.todo.pending': '대기 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 78c9c06f..86bbbeff 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1338,7 +1338,6 @@ export const dict: Record = { 'chat.toolPart.moreErrors': '+{count} kolejnych błędów', 'chat.toolPart.noOutputProduced': 'Brak wygenerowanego wyniku', 'chat.toolPart.openSubtask': 'Otwórz podzadanie typu {type}', - 'chat.toolPart.attachmentFallback': 'Załącznik', 'chat.toolPart.output': 'Wyjście', 'chat.toolPart.showRawJson': 'Pokaż surowy JSON', 'chat.toolPart.showFormattedJson': 'Pokaż sformatowany JSON', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index dd792117..eedfb214 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2035,7 +2035,6 @@ export const dict: Record = { "chat.toolPart.copiedOutput": "Saída copiada", "chat.toolPart.copyOutputFailed": "Falha ao copiar saída", "chat.toolPart.openSubtask": "Abrir subtarefa {type}", - "chat.toolPart.attachmentFallback": "Anexo", "chat.todo.total": "Total", "chat.todo.inProgress": "Em andamento", "chat.todo.pending": "Pendente", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index f9abc82e..74aaf9c5 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2035,7 +2035,6 @@ export const dict: Record = { "chat.toolPart.copiedOutput": "Вивід скопійовано", "chat.toolPart.copyOutputFailed": "Не вдалося скопіювати вивід", "chat.toolPart.openSubtask": "Відкрити підзавдання {type}", - "chat.toolPart.attachmentFallback": "Вкладення", "chat.todo.total": "Усього", "chat.todo.inProgress": "В роботі", "chat.todo.pending": "В очікуванні", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 281f5ba8..0926edc8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2035,7 +2035,6 @@ export const dict: Record = { 'chat.toolPart.copiedOutput': '已复制输出', 'chat.toolPart.copyOutputFailed': '复制输出失败', 'chat.toolPart.openSubtask': '打开{type}子任务', - 'chat.toolPart.attachmentFallback': '附件', 'chat.todo.total': '总计', 'chat.todo.inProgress': '进行中', 'chat.todo.pending': '待处理', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index cfff72c3..10d19522 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2039,7 +2039,6 @@ export const dict: Record = { 'chat.toolPart.copiedOutput': '已複製輸出', 'chat.toolPart.copyOutputFailed': '複製輸出失敗', 'chat.toolPart.openSubtask': '開啟{type}子任務', - 'chat.toolPart.attachmentFallback': '附件', 'chat.todo.total': '總計', 'chat.todo.inProgress': '進行中', 'chat.todo.pending': '待處理', diff --git a/packages/ui/src/sync/__tests__/materialization.test.ts b/packages/ui/src/sync/__tests__/materialization.test.ts index 3440e52f..d830c791 100644 --- a/packages/ui/src/sync/__tests__/materialization.test.ts +++ b/packages/ui/src/sync/__tests__/materialization.test.ts @@ -313,6 +313,80 @@ describe("materializeSessionSnapshots", () => { expect(mergedPart.state?.attachments).toHaveLength(1) expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-new") }) + + test("treats empty state.attachments in completed snapshot as authoritative", () => { + const livePart = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + state: { + status: "completed", + output: "done", + time: { start: 100, end: 200 }, + attachments: [{ id: "att-old", type: "file", mime: "image/png", url: "data:image/png,..." }], + }, + } as unknown as Part + const snapshotPart = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + state: { + status: "completed", + output: "done", + time: { start: 100, end: 200 }, + attachments: [], + }, + } as unknown as Part + const state = { + message: { ses_1: [message("msg_1")] }, + part: { msg_1: [livePart] }, + } + + const result = materializeSessionSnapshots( + state, + "ses_1", + [{ info: message("msg_1"), parts: [snapshotPart] }], + ) + + const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array } } + expect(mergedPart.state?.attachments).toEqual([]) + }) + + test("treats empty state.attachments in streaming snapshot as authoritative", () => { + const livePart = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + state: { + status: "running", + time: { start: 100 }, + attachments: [{ id: "att-old", type: "file", mime: "image/png", url: "data:image/png,..." }], + }, + } as unknown as Part + const snapshotPart = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + state: { status: "running", time: { start: 100 }, attachments: [] }, + } as unknown as Part + const state = { + message: { ses_1: [message("msg_1")] }, + part: { msg_1: [livePart] }, + } + + const result = materializeSessionSnapshots( + state, + "ses_1", + [{ info: message("msg_1"), parts: [snapshotPart] }], + ) + + const mergedPart = result.part.msg_1[0] as { state?: { attachments?: Array } } + expect(mergedPart.state?.attachments).toEqual([]) + }) }) describe("getSessionMaterializationStatus", () => { diff --git a/packages/ui/src/sync/materialization.ts b/packages/ui/src/sync/materialization.ts index 8d23e072..122418b9 100644 --- a/packages/ui/src/sync/materialization.ts +++ b/packages/ui/src/sync/materialization.ts @@ -112,7 +112,7 @@ function getPartStateAttachments(part: Part): Array | undefined { const state = (part as Record).state as Record | undefined if (!state) return undefined const attachments = state.attachments - return Array.isArray(attachments) && attachments.length > 0 ? attachments : undefined + return Array.isArray(attachments) ? attachments : undefined } function hasLiveStreamingField(part: Part): boolean { @@ -137,7 +137,7 @@ function mergeMaterializedPart(existing: Part | undefined, next: Part): Part { if (getPartEndTime(next) !== undefined) { const existingAttachments = getPartStateAttachments(existing) - if (existingAttachments && !getPartStateAttachments(next)) { + if (existingAttachments?.length && getPartStateAttachments(next) === undefined) { const nextRecord = { ...next } const nextState = { ...((next as Record).state as Record ?? {}), attachments: existingAttachments } ;(nextRecord as Record).state = nextState @@ -161,7 +161,7 @@ function mergeMaterializedPart(existing: Part | undefined, next: Part): Part { } const existingAttachments = getPartStateAttachments(existing) - if (existingAttachments && !getPartStateAttachments(next)) { + if (existingAttachments?.length && getPartStateAttachments(next) === undefined) { if (merged === next) merged = { ...next } const mergedRecord = merged as Record const nextState = (next as Record).state as Record | undefined