diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 2245e90a..eb3181e1 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -5,7 +5,7 @@ import { PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata } from '@/lib/toolHelpers'; -import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2'; +import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2'; import { toolDisplayStyles } from '@/lib/typography'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; @@ -59,7 +59,7 @@ const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!lead const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS); -type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } }; +type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number }; attachments?: Array }; interface ToolPartProps { part: ToolPartType; @@ -1512,6 +1512,16 @@ const ToolExpandedContent: React.FC = React.memo(({ const rawOutput = stateWithData.output; 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) @@ -1574,6 +1584,33 @@ 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 } @@ -1926,6 +1963,62 @@ 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} +
+ ) : null} ); }); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 83036ff8..1e9d90ac 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2069,6 +2069,7 @@ 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 a888528a..95c49d38 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2035,6 +2035,7 @@ 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 f8f6ccdb..551f43bf 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1838,6 +1838,7 @@ 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 11e921e5..ca46f897 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2068,6 +2068,7 @@ 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 e238234b..e9a8da13 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2069,6 +2069,7 @@ 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 86bbbeff..78c9c06f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1338,6 +1338,7 @@ 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 eedfb214..dd792117 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2035,6 +2035,7 @@ 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 74aaf9c5..f9abc82e 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2035,6 +2035,7 @@ 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 0926edc8..281f5ba8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2035,6 +2035,7 @@ 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 10d19522..cfff72c3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2039,6 +2039,7 @@ 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 421a6594..3440e52f 100644 --- a/packages/ui/src/sync/__tests__/materialization.test.ts +++ b/packages/ui/src/sync/__tests__/materialization.test.ts @@ -165,6 +165,154 @@ describe("materializeSessionSnapshots", () => { expect(mergedPart.state?.time?.start).toBe(1000) expect(mergedPart.state?.time?.end).toBe(2000) }) + + test("preserves state.attachments from existing part when completed snapshot lacks them", () => { + 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-1", 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 } }, + } 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).toHaveLength(1) + expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-1") + }) + + test("preserves state.attachments during streaming merge when snapshot has no end time", () => { + const livePart = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + state: { + status: "running", + time: { start: 100 }, + attachments: [{ id: "att-1", 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 } }, + } 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).toHaveLength(1) + expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-1") + }) + + test("preserves both state.attachments and state.time.start during streaming merge when snapshot lacks both", () => { + const livePart = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + state: { + status: "running", + time: { start: 100 }, + attachments: [{ id: "att-1", 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" }, + } 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; time?: { start?: number; end?: number } } } + expect(mergedPart.state?.attachments).toHaveLength(1) + expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-1") + expect(mergedPart.state?.time?.start).toBe(100) + }) + + test("does not merge existing state.attachments when snapshot has its own", () => { + 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: [{ id: "att-new", type: "file", mime: "image/jpeg", url: "data:image/jpeg,..." }], + }, + } 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).toHaveLength(1) + expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-new") + }) }) describe("getSessionMaterializationStatus", () => { diff --git a/packages/ui/src/sync/materialization.ts b/packages/ui/src/sync/materialization.ts index c02d19be..8d23e072 100644 --- a/packages/ui/src/sync/materialization.ts +++ b/packages/ui/src/sync/materialization.ts @@ -108,6 +108,13 @@ function getStringField(part: Part, field: "text" | "output"): string | undefine return typeof value === "string" ? value : undefined } +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 +} + function hasLiveStreamingField(part: Part): boolean { if (getPartEndTime(part) !== undefined) return false return STREAMING_PART_FIELDS.some((field) => { @@ -126,7 +133,18 @@ function getPartStateTime(part: Part): { start?: number; end?: number } | undefi } function mergeMaterializedPart(existing: Part | undefined, next: Part): Part { - if (!existing || getPartEndTime(next) !== undefined) return next + if (!existing) return next + + if (getPartEndTime(next) !== undefined) { + const existingAttachments = getPartStateAttachments(existing) + if (existingAttachments && !getPartStateAttachments(next)) { + const nextRecord = { ...next } + const nextState = { ...((next as Record).state as Record ?? {}), attachments: existingAttachments } + ;(nextRecord as Record).state = nextState + return nextRecord + } + return next + } let merged: Part = next for (const field of STREAMING_PART_FIELDS) { @@ -142,6 +160,15 @@ function mergeMaterializedPart(existing: Part | undefined, next: Part): Part { mergedRecord[field] = existingValue } + const existingAttachments = getPartStateAttachments(existing) + if (existingAttachments && !getPartStateAttachments(next)) { + if (merged === next) merged = { ...next } + const mergedRecord = merged as Record + const nextState = (next as Record).state as Record | undefined + const newState = { ...(nextState ?? {}), attachments: existingAttachments } + mergedRecord.state = newState + } + const existingTime = getPartStateTime(existing) if (existingTime) { const nextTime = getPartStateTime(next) @@ -150,8 +177,8 @@ function mergeMaterializedPart(existing: Part | undefined, next: Part): Part { if (preservedStart !== nextTime?.start || preservedEnd !== nextTime?.end) { if (merged === next) merged = { ...next } const mergedRecord = merged as Record - const nextState = (next as Record).state as Record | undefined - const newState = { ...(nextState ?? {}), time: { start: preservedStart, end: preservedEnd } } + const currentState = (mergedRecord.state as Record | undefined) ?? (next as Record).state as Record | undefined + const newState = { ...(currentState ?? {}), time: { start: preservedStart, end: preservedEnd } } mergedRecord.state = newState } }