fix: preserve plugin tool state.attachments in UI and materializer (#2367)

* preserve attachments in UI and materializer

* add support for non-image attachments

* preserve attachments in UI and materializer

* improve attachment rendering and state synchronization

* simplify attachment filtering and key handling

* prevent rendering tool attachments without URLs

* require `f.url` in `imageAttachments` to match gallery indices
This commit is contained in:
FrostiDrinks
2026-07-22 10:46:40 +03:00
committed by GitHub
parent 733bd37c37
commit c29b2d0849
13 changed files with 283 additions and 5 deletions
@@ -5,7 +5,7 @@ import { PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata } from '@/lib/toolHelpers'; 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 { toolDisplayStyles } from '@/lib/typography';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; 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_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS); const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS);
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } }; type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number }; attachments?: Array<FilePart> };
interface ToolPartProps { interface ToolPartProps {
part: ToolPartType; part: ToolPartType;
@@ -1512,6 +1512,16 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const rawOutput = stateWithData.output; const rawOutput = stateWithData.output;
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0; const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
const outputString = typeof rawOutput === 'string' ? rawOutput : ''; 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 fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch) const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
@@ -1574,6 +1584,33 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
setDiffViewMode('unified'); setDiffViewMode('unified');
}, [part.id]); }, [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 = ( const renderScrollableBlock = (
content: React.ReactNode, content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string } options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
@@ -1926,6 +1963,62 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
)} )}
</> </>
)} )}
{attachments && Array.isArray(attachments) && attachments.length > 0 && state.status === 'completed' ? (
<div className="space-y-2">
{imageAttachments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{imageAttachments.map((file, index) => {
const filename = file.filename || t('chat.toolPart.attachmentFallback');
return (
<button
key={file.url || filename || index}
type="button"
onClick={(e) => { e.stopPropagation(); handleAttachmentClick(index); }}
className="relative flex-none border border-border/40 bg-muted/10 overflow-hidden rounded-lg h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
aria-label={filename}
>
{file.url ? (
<img
src={file.url}
alt={filename}
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.visibility = 'hidden';
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
<Icon name="file-image" className="h-6 w-6" />
</div>
)}
<span className="sr-only">{filename}</span>
</button>
);
})}
</div>
) : null}
{otherAttachments.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{otherAttachments.map((file, index) => {
const fileName = file.filename || t('chat.fileAttachment.fileFallback');
const ext = fileName.split('.').pop() || '';
return (
<div
key={file.url || `${fileName}-${index}`}
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg"
>
<FileTypeIcon filePath={fileName} extension={ext} className="text-muted-foreground h-3.5 w-3.5" />
<span className="truncate max-w-[140px] block" title={fileName}>{fileName}</span>
</div>
);
})}
</div>
) : null}
</div>
) : null}
</div> </div>
); );
}); });
+1
View File
@@ -2069,6 +2069,7 @@ export const dict = {
'chat.toolPart.copiedOutput': 'Copied output', 'chat.toolPart.copiedOutput': 'Copied output',
'chat.toolPart.copyOutputFailed': 'Failed to copy output', 'chat.toolPart.copyOutputFailed': 'Failed to copy output',
'chat.toolPart.openSubtask': 'Open {type} subtask', 'chat.toolPart.openSubtask': 'Open {type} subtask',
'chat.toolPart.attachmentFallback': 'Attachment',
'chat.todo.total': 'Total', 'chat.todo.total': 'Total',
'chat.todo.inProgress': 'In Progress', 'chat.todo.inProgress': 'In Progress',
'chat.todo.pending': 'Pending', 'chat.todo.pending': 'Pending',
+1
View File
@@ -2035,6 +2035,7 @@ export const dict: Record<I18nKey, string> = {
"chat.toolPart.copiedOutput": "Salida copiada", "chat.toolPart.copiedOutput": "Salida copiada",
"chat.toolPart.copyOutputFailed": "No se pudo copiar la salida", "chat.toolPart.copyOutputFailed": "No se pudo copiar la salida",
"chat.toolPart.openSubtask": "Abrir subtarea {type}", "chat.toolPart.openSubtask": "Abrir subtarea {type}",
"chat.toolPart.attachmentFallback": "Archivo adjunto",
"chat.todo.total": "Total", "chat.todo.total": "Total",
"chat.todo.inProgress": "En progreso", "chat.todo.inProgress": "En progreso",
"chat.todo.pending": "Pendiente", "chat.todo.pending": "Pendiente",
+1
View File
@@ -1838,6 +1838,7 @@ export const dict = {
'chat.toolPart.noOutputProduced': 'Aucune sortie produite', 'chat.toolPart.noOutputProduced': 'Aucune sortie produite',
'chat.toolPart.output': 'Sortie', 'chat.toolPart.output': 'Sortie',
'chat.toolPart.openSubtask': 'Ouvrir la sous-tâche {type}', 'chat.toolPart.openSubtask': 'Ouvrir la sous-tâche {type}',
'chat.toolPart.attachmentFallback': 'Pièce jointe',
'chat.todo.total': 'Total', 'chat.todo.total': 'Total',
'chat.todo.inProgress': 'En cours', 'chat.todo.inProgress': 'En cours',
'chat.todo.pending': 'En attente', 'chat.todo.pending': 'En attente',
+1
View File
@@ -2068,6 +2068,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.copiedOutput': '出力をコピーしました', 'chat.toolPart.copiedOutput': '出力をコピーしました',
'chat.toolPart.copyOutputFailed': '出力のコピーに失敗しました', 'chat.toolPart.copyOutputFailed': '出力のコピーに失敗しました',
'chat.toolPart.openSubtask': '{type}サブタスクを開く', 'chat.toolPart.openSubtask': '{type}サブタスクを開く',
'chat.toolPart.attachmentFallback': '添付ファイル',
'chat.todo.total': '合計', 'chat.todo.total': '合計',
'chat.todo.inProgress': '進行中', 'chat.todo.inProgress': '進行中',
'chat.todo.pending': '保留中', 'chat.todo.pending': '保留中',
+1
View File
@@ -2069,6 +2069,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.copiedOutput': '출력 복사됨', 'chat.toolPart.copiedOutput': '출력 복사됨',
'chat.toolPart.copyOutputFailed': '출력을 복사하지 못했습니다', 'chat.toolPart.copyOutputFailed': '출력을 복사하지 못했습니다',
'chat.toolPart.openSubtask': '{type} 하위 작업 열기', 'chat.toolPart.openSubtask': '{type} 하위 작업 열기',
'chat.toolPart.attachmentFallback': '첨부 파일',
'chat.todo.total': '전체', 'chat.todo.total': '전체',
'chat.todo.inProgress': '진행 중', 'chat.todo.inProgress': '진행 중',
'chat.todo.pending': '대기 중', 'chat.todo.pending': '대기 중',
+1
View File
@@ -1338,6 +1338,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.moreErrors': '+{count} kolejnych błędów', 'chat.toolPart.moreErrors': '+{count} kolejnych błędów',
'chat.toolPart.noOutputProduced': 'Brak wygenerowanego wyniku', 'chat.toolPart.noOutputProduced': 'Brak wygenerowanego wyniku',
'chat.toolPart.openSubtask': 'Otwórz podzadanie typu {type}', 'chat.toolPart.openSubtask': 'Otwórz podzadanie typu {type}',
'chat.toolPart.attachmentFallback': 'Załącznik',
'chat.toolPart.output': 'Wyjście', 'chat.toolPart.output': 'Wyjście',
'chat.toolPart.showRawJson': 'Pokaż surowy JSON', 'chat.toolPart.showRawJson': 'Pokaż surowy JSON',
'chat.toolPart.showFormattedJson': 'Pokaż sformatowany JSON', 'chat.toolPart.showFormattedJson': 'Pokaż sformatowany JSON',
@@ -2035,6 +2035,7 @@ export const dict: Record<I18nKey, string> = {
"chat.toolPart.copiedOutput": "Saída copiada", "chat.toolPart.copiedOutput": "Saída copiada",
"chat.toolPart.copyOutputFailed": "Falha ao copiar saída", "chat.toolPart.copyOutputFailed": "Falha ao copiar saída",
"chat.toolPart.openSubtask": "Abrir subtarefa {type}", "chat.toolPart.openSubtask": "Abrir subtarefa {type}",
"chat.toolPart.attachmentFallback": "Anexo",
"chat.todo.total": "Total", "chat.todo.total": "Total",
"chat.todo.inProgress": "Em andamento", "chat.todo.inProgress": "Em andamento",
"chat.todo.pending": "Pendente", "chat.todo.pending": "Pendente",
+1
View File
@@ -2035,6 +2035,7 @@ export const dict: Record<I18nKey, string> = {
"chat.toolPart.copiedOutput": "Вивід скопійовано", "chat.toolPart.copiedOutput": "Вивід скопійовано",
"chat.toolPart.copyOutputFailed": "Не вдалося скопіювати вивід", "chat.toolPart.copyOutputFailed": "Не вдалося скопіювати вивід",
"chat.toolPart.openSubtask": "Відкрити підзавдання {type}", "chat.toolPart.openSubtask": "Відкрити підзавдання {type}",
"chat.toolPart.attachmentFallback": "Вкладення",
"chat.todo.total": "Усього", "chat.todo.total": "Усього",
"chat.todo.inProgress": "В роботі", "chat.todo.inProgress": "В роботі",
"chat.todo.pending": "В очікуванні", "chat.todo.pending": "В очікуванні",
@@ -2035,6 +2035,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.copiedOutput': '已复制输出', 'chat.toolPart.copiedOutput': '已复制输出',
'chat.toolPart.copyOutputFailed': '复制输出失败', 'chat.toolPart.copyOutputFailed': '复制输出失败',
'chat.toolPart.openSubtask': '打开{type}子任务', 'chat.toolPart.openSubtask': '打开{type}子任务',
'chat.toolPart.attachmentFallback': '附件',
'chat.todo.total': '总计', 'chat.todo.total': '总计',
'chat.todo.inProgress': '进行中', 'chat.todo.inProgress': '进行中',
'chat.todo.pending': '待处理', 'chat.todo.pending': '待处理',
@@ -2039,6 +2039,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.copiedOutput': '已複製輸出', 'chat.toolPart.copiedOutput': '已複製輸出',
'chat.toolPart.copyOutputFailed': '複製輸出失敗', 'chat.toolPart.copyOutputFailed': '複製輸出失敗',
'chat.toolPart.openSubtask': '開啟{type}子任務', 'chat.toolPart.openSubtask': '開啟{type}子任務',
'chat.toolPart.attachmentFallback': '附件',
'chat.todo.total': '總計', 'chat.todo.total': '總計',
'chat.todo.inProgress': '進行中', 'chat.todo.inProgress': '進行中',
'chat.todo.pending': '待處理', 'chat.todo.pending': '待處理',
@@ -165,6 +165,154 @@ describe("materializeSessionSnapshots", () => {
expect(mergedPart.state?.time?.start).toBe(1000) expect(mergedPart.state?.time?.start).toBe(1000)
expect(mergedPart.state?.time?.end).toBe(2000) 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<unknown> } }
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<unknown> } }
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<unknown>; 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<unknown> } }
expect(mergedPart.state?.attachments).toHaveLength(1)
expect((mergedPart.state?.attachments?.[0] as { id?: string })?.id).toBe("att-new")
})
}) })
describe("getSessionMaterializationStatus", () => { describe("getSessionMaterializationStatus", () => {
+30 -3
View File
@@ -108,6 +108,13 @@ function getStringField(part: Part, field: "text" | "output"): string | undefine
return typeof value === "string" ? value : undefined return typeof value === "string" ? value : undefined
} }
function getPartStateAttachments(part: Part): Array<unknown> | undefined {
const state = (part as Record<string, unknown>).state as Record<string, unknown> | undefined
if (!state) return undefined
const attachments = state.attachments
return Array.isArray(attachments) && attachments.length > 0 ? attachments : undefined
}
function hasLiveStreamingField(part: Part): boolean { function hasLiveStreamingField(part: Part): boolean {
if (getPartEndTime(part) !== undefined) return false if (getPartEndTime(part) !== undefined) return false
return STREAMING_PART_FIELDS.some((field) => { 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 { 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<string, unknown>).state as Record<string, unknown> ?? {}), attachments: existingAttachments }
;(nextRecord as Record<string, unknown>).state = nextState
return nextRecord
}
return next
}
let merged: Part = next let merged: Part = next
for (const field of STREAMING_PART_FIELDS) { for (const field of STREAMING_PART_FIELDS) {
@@ -142,6 +160,15 @@ function mergeMaterializedPart(existing: Part | undefined, next: Part): Part {
mergedRecord[field] = existingValue mergedRecord[field] = existingValue
} }
const existingAttachments = getPartStateAttachments(existing)
if (existingAttachments && !getPartStateAttachments(next)) {
if (merged === next) merged = { ...next }
const mergedRecord = merged as Record<string, unknown>
const nextState = (next as Record<string, unknown>).state as Record<string, unknown> | undefined
const newState = { ...(nextState ?? {}), attachments: existingAttachments }
mergedRecord.state = newState
}
const existingTime = getPartStateTime(existing) const existingTime = getPartStateTime(existing)
if (existingTime) { if (existingTime) {
const nextTime = getPartStateTime(next) const nextTime = getPartStateTime(next)
@@ -150,8 +177,8 @@ function mergeMaterializedPart(existing: Part | undefined, next: Part): Part {
if (preservedStart !== nextTime?.start || preservedEnd !== nextTime?.end) { if (preservedStart !== nextTime?.start || preservedEnd !== nextTime?.end) {
if (merged === next) merged = { ...next } if (merged === next) merged = { ...next }
const mergedRecord = merged as Record<string, unknown> const mergedRecord = merged as Record<string, unknown>
const nextState = (next as Record<string, unknown>).state as Record<string, unknown> | undefined const currentState = (mergedRecord.state as Record<string, unknown> | undefined) ?? (next as Record<string, unknown>).state as Record<string, unknown> | undefined
const newState = { ...(nextState ?? {}), time: { start: preservedStart, end: preservedEnd } } const newState = { ...(currentState ?? {}), time: { start: preservedStart, end: preservedEnd } }
mergedRecord.state = newState mergedRecord.state = newState
} }
} }