fix: restore desktop startup and align tool previews with SDK updates

Add reflect-metadata bootstrap so desktop sidecar no longer crashes on startup.
Update SDK v1.4 compatibility for model variant and diff payload handling.
Unify write/edit/apply patch expanded previews and hide write success output noise.
This commit is contained in:
Bohdan Triapitsyn
2026-04-12 00:40:49 +03:00
parent e6ac6badde
commit 844052e599
14 changed files with 222 additions and 189 deletions
+10 -9
View File
@@ -32,7 +32,7 @@
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -94,7 +94,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@openchamber/desktop", "name": "@openchamber/desktop",
"version": "1.9.3", "version": "1.9.4",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
@@ -103,7 +103,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.9.3", "version": "1.9.4",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.0", "@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.10.1",
@@ -133,7 +133,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"@pierre/diffs": "1.1.0-beta.13", "@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -208,10 +208,10 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.9.3", "version": "1.9.4",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
@@ -231,7 +231,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.9.3", "version": "1.9.4",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
@@ -243,7 +243,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -275,6 +275,7 @@
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -826,7 +827,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"], "@openchamber/web": ["@openchamber/web@workspace:packages/web"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.17", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.3", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A=="],
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="], "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
+1 -1
View File
@@ -89,7 +89,7 @@
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -1
View File
@@ -39,7 +39,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"@pierre/diffs": "1.1.0-beta.13", "@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -2,10 +2,10 @@
import React from 'react'; import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiArrowDownSLine, RiArrowRightSLine, RiExternalLinkLine } from '@remixicon/react'; import { RiArrowDownSLine, RiArrowRightSLine, RiExternalLinkLine } from '@remixicon/react';
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react'; 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, getLanguageFromExtension, isImageFile, getImageMimeType } 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 } from '@opencode-ai/sdk/v2';
import { toolDisplayStyles } from '@/lib/typography'; import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@@ -185,9 +185,11 @@ const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> =
}; };
const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => { const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => {
if (!metadata?.diff || typeof metadata.diff !== 'string') return null; const diffText = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
?? getPatchText(metadata?.diff);
if (!diffText) return null;
const lines = metadata.diff.split('\n'); const lines = diffText.split('\n');
let added = 0; let added = 0;
let removed = 0; let removed = 0;
@@ -254,22 +256,67 @@ const extractFirstChangedLineFromDiff = (diffText: string): number | undefined =
return firstHunkStart; return firstHunkStart;
}; };
const getPatchText = (value: unknown): string | undefined => {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (value && typeof value === 'object') {
const patch = (value as { patch?: unknown }).patch;
if (typeof patch === 'string') {
const trimmed = patch.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
}
return undefined;
};
const buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => {
const normalizedContent = content.replace(/\r\n/g, '\n');
if (!normalizedContent.trim()) {
return undefined;
}
const normalizedPath = (() => {
const candidate = (filePath ?? '').trim();
if (!candidate) {
return 'new-file';
}
return candidate.startsWith('/') ? candidate.slice(1) : candidate;
})();
const lines = normalizedContent.split('\n');
const hunkSize = lines.length;
const body = lines.map((line) => `+${line}`).join('\n');
return [
'--- /dev/null',
`+++ b/${normalizedPath}`,
`@@ -0,0 +1,${hunkSize} @@`,
body,
].join('\n');
};
const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record<string, unknown>): number | undefined => { const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record<string, unknown>): number | undefined => {
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) { if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
return undefined; return undefined;
} }
if (typeof metadata.diff === 'string') { const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
const line = extractFirstChangedLineFromDiff(metadata.diff); if (topLevelPatch) {
const line = extractFirstChangedLineFromDiff(topLevelPatch);
if (Number.isFinite(line)) { if (Number.isFinite(line)) {
return line; return line;
} }
} }
const files = Array.isArray(metadata.files) ? metadata.files : []; const files = Array.isArray(metadata.files) ? metadata.files : [];
const firstFile = files[0] as { diff?: unknown } | undefined; const firstFile = files[0] as { patch?: unknown; diff?: unknown } | undefined;
if (typeof firstFile?.diff === 'string') { const filePatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff);
const line = extractFirstChangedLineFromDiff(firstFile.diff); if (filePatch) {
const line = extractFirstChangedLineFromDiff(filePatch);
if (Number.isFinite(line)) { if (Number.isFinite(line)) {
return line; return line;
} }
@@ -303,15 +350,17 @@ const getPrimaryDiffFromMetadata = (
: files[0]; : files[0];
if (matched && typeof matched === 'object') { if (matched && typeof matched === 'object') {
const patch = (matched as { diff?: unknown }).diff; const patch = getPatchText((matched as { patch?: unknown; diff?: unknown }).patch)
if (typeof patch === 'string' && patch.trim().length > 0) { ?? getPatchText((matched as { patch?: unknown; diff?: unknown }).diff);
if (patch) {
return patch; return patch;
} }
} }
} }
if (typeof metadata.diff === 'string' && metadata.diff.trim().length > 0) { const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
return metadata.diff; if (topLevelPatch) {
return topLevelPatch;
} }
return undefined; return undefined;
@@ -1280,8 +1329,8 @@ const getDiffPatchEntries = (
return null; return null;
} }
const record = file as { relativePath?: unknown; filePath?: unknown; diff?: unknown }; const record = file as { relativePath?: unknown; filePath?: unknown; patch?: unknown; diff?: unknown };
const patch = typeof record.diff === 'string' ? record.diff.trim() : ''; const patch = getPatchText(record.patch) ?? getPatchText(record.diff) ?? '';
if (!patch) { if (!patch) {
return null; return null;
} }
@@ -1344,97 +1393,6 @@ const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme,
DiffPreview.displayName = 'DiffPreview'; DiffPreview.displayName = 'DiffPreview';
interface WriteInputPreviewProps {
content: string;
filePath?: string;
displayPath: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
}
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({
content,
filePath,
displayPath,
pierreTheme,
pierreThemeType,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
[content, filePath]
);
const lineCount = Math.max(content.split('\n').length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-1 flex items-center gap-2 min-w-0">
{renderPathLikeGitChanges(displayPath)}
<span className="typography-meta text-muted-foreground/80 flex-shrink-0">({headerLineLabel})</span>
</div>
<PierreFile
file={{
name: displayPath,
contents: content,
lang: language || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>
</div>
);
});
WriteInputPreview.displayName = 'WriteInputPreview';
interface ImagePreviewProps {
content: string;
filePath: string;
displayPath: string;
}
const ImagePreview: React.FC<ImagePreviewProps> = React.memo(({ content, filePath, displayPath }) => {
const mimeType = getImageMimeType(filePath);
const isSvg = filePath.toLowerCase().endsWith('.svg');
// For SVG, content might be raw XML, otherwise assume base64
const imageSrc = React.useMemo(() => {
if (isSvg && !content.startsWith('data:')) {
// Raw SVG content
return `data:image/svg+xml;base64,${btoa(content)}`;
}
if (content.startsWith('data:')) {
return content;
}
// Assume base64 encoded
return `data:${mimeType};base64,${content}`;
}, [content, mimeType, isSvg]);
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-2 flex items-center min-w-0">
{renderPathLikeGitChanges(displayPath)}
</div>
<div className="flex justify-center p-4 bg-muted/10 rounded-lg">
<img
src={imageSrc}
alt={displayPath}
className="max-w-full max-h-96 object-contain rounded"
style={{ imageRendering: 'auto' }}
/>
</div>
</div>
);
});
ImagePreview.displayName = 'ImagePreview';
interface ToolExpandedContentProps { interface ToolExpandedContentProps {
part: ToolPartType; part: ToolPartType;
state: ToolStateUnion; state: ToolStateUnion;
@@ -1459,32 +1417,16 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
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 diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null; const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
?? getPatchText(metadata?.diff)
?? null;
const diffEntries = React.useMemo( const diffEntries = React.useMemo(
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []), () => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []),
[currentDirectory, diffContent, metadata] [currentDirectory, diffContent, metadata]
); );
const writeFilePath = part.tool === 'write' const hideToolInputPreview = part.tool === 'apply_patch'
? typeof input?.filePath === 'string' || part.tool === 'edit'
? input.filePath || part.tool === 'multiedit';
: typeof input?.file_path === 'string'
? input.file_path
: typeof input?.path === 'string'
? input.path
: undefined
: undefined;
const writeInputContent = part.tool === 'write'
? typeof (input as { content?: unknown })?.content === 'string'
? (input as { content?: string }).content
: typeof (input as { text?: unknown })?.text === 'string'
? (input as { text?: string }).text
: null
: null;
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false;
const writeDisplayPath = shouldShowWriteInputPreview
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file')
: null;
const diagnosticSection = React.useMemo( const diagnosticSection = React.useMemo(
() => getToolDiagnosticSection(part.tool, input, metadata, currentDirectory), () => getToolDiagnosticSection(part.tool, input, metadata, currentDirectory),
[currentDirectory, input, metadata, part.tool], [currentDirectory, input, metadata, part.tool],
@@ -1505,7 +1447,21 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
return formatInputForDisplay(input, part.tool); return formatInputForDisplay(input, part.tool);
}, [input, part.tool]); }, [input, part.tool]);
const hasInputText = part.tool !== 'apply_patch' && inputTextContent.trim().length > 0; const hasInputText = !hideToolInputPreview && inputTextContent.trim().length > 0;
const isWriteLikeTool = part.tool === 'write' || part.tool === 'create' || part.tool === 'file_write';
const writeLikeInputPatch = React.useMemo(() => {
if (!isWriteLikeTool || !hasInputText) {
return undefined;
}
const filePath = typeof input?.filePath === 'string'
? input.filePath
: typeof input?.file_path === 'string'
? input.file_path
: typeof input?.path === 'string'
? input.path
: undefined;
return buildWritePreviewPatch(filePath, inputTextContent);
}, [hasInputText, input?.filePath, input?.file_path, input?.path, inputTextContent, isWriteLikeTool]);
React.useEffect(() => { React.useEffect(() => {
setDiffViewMode('unified'); setDiffViewMode('unified');
@@ -1615,7 +1571,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
); );
} }
if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && (diffEntries.length > 0 || !!diagnosticSection)) { if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && (diffEntries.length > 0 || !!diagnosticSection)) {
return renderScrollableBlock( return renderScrollableBlock(
<div className="space-y-3"> <div className="space-y-3">
{diffEntries.map((entry) => ( {diffEntries.map((entry) => (
@@ -1648,6 +1604,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
); );
} }
if (isWriteLikeTool) {
return null;
}
if (hasStringOutput && outputString.trim()) { if (hasStringOutput && outputString.trim()) {
return renderScrollableBlock( return renderScrollableBlock(
<ToolScrollableTextOutput <ToolScrollableTextOutput
@@ -1680,35 +1640,20 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
renderResultContent() renderResultContent()
) : ( ) : (
<> <>
{shouldShowWriteInputPreview && isWriteImageFile ? ( {hasInputText ? (
<div className="my-1">
{renderScrollableBlock(
<ImagePreview
content={writeInputContent as string}
filePath={writeFilePath as string}
displayPath={writeDisplayPath ?? 'New file'}
/>
)}
</div>
) : shouldShowWriteInputPreview ? (
<div className="my-1">
{renderScrollableBlock(
<WriteInputPreview
content={writeInputContent as string}
filePath={writeFilePath}
displayPath={writeDisplayPath ?? 'New file'}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
/>
)}
</div>
) : hasInputText ? (
<div className="my-1"> <div className="my-1">
{renderScrollableBlock( {renderScrollableBlock(
part.tool === 'bash' ? ( part.tool === 'bash' ? (
<pre className="tool-input-text whitespace-pre-wrap break-words typography-code text-muted-foreground/90 m-0 p-0"> <pre className="tool-input-text whitespace-pre-wrap break-words typography-code text-muted-foreground/90 m-0 p-0">
{inputTextContent} {inputTextContent}
</pre> </pre>
) : isWriteLikeTool && writeLikeInputPatch ? (
<DiffPreview
diff={writeLikeInputPatch}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
diffViewMode={diffViewMode}
/>
) : ( ) : (
<blockquote className="tool-input-text whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70"> <blockquote className="tool-input-text whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
{inputTextContent} {inputTextContent}
@@ -1722,9 +1667,9 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
</div> </div>
) : null} ) : null}
{part.tool !== 'write' && state.status === 'completed' && 'output' in state && ( {state.status === 'completed' && 'output' in state && (
<div> <div>
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent ? ( {(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && diffContent ? (
<div className="mb-1 flex items-center justify-end gap-2"> <div className="mb-1 flex items-center justify-end gap-2">
<DiffViewToggle <DiffViewToggle
mode={diffViewMode} mode={diffViewMode}
@@ -36,6 +36,47 @@ const formatInputForDisplay = (input: Record<string, unknown>, toolName?: string
return formatToolInput(input, toolName || ''); return formatToolInput(input, toolName || '');
}; };
const getPatchText = (value: unknown): string | undefined => {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (value && typeof value === 'object') {
const patch = (value as { patch?: unknown }).patch;
if (typeof patch === 'string') {
const trimmed = patch.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
}
return undefined;
};
const getToolMetadataPatch = (metadata?: Record<string, unknown>): string | undefined => {
if (!metadata || typeof metadata !== 'object') {
return undefined;
}
const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
if (topLevelPatch) {
return topLevelPatch;
}
const files = Array.isArray((metadata as { files?: unknown }).files) ? (metadata as { files: unknown[] }).files : [];
for (const file of files) {
if (!file || typeof file !== 'object') {
continue;
}
const patch = getPatchText((file as { patch?: unknown }).patch) ?? getPatchText((file as { diff?: unknown }).diff);
if (patch) {
return patch;
}
}
return undefined;
};
export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boolean } => { export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boolean } => {
if (!output || typeof output !== 'string') { if (!output || typeof output !== 'string') {
return { data: null, isJson: false }; return { data: null, isJson: false };
@@ -73,10 +114,11 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
cleaned = stripLspDiagnostics(cleaned); cleaned = stripLspDiagnostics(cleaned);
} }
if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') && cleaned.trim().length === 0 && metadata?.diff) { if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') && cleaned.trim().length === 0) {
const diff = getToolMetadataPatch(metadata);
const diff = metadata.diff; if (diff) {
return typeof diff === 'string' ? diff : String(diff); return diff;
}
} }
return cleaned; return cleaned;
+1 -2
View File
@@ -1,6 +1,5 @@
import type { import type {
Event, Event,
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
@@ -11,7 +10,7 @@ import type {
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary" import { Binary } from "./binary"
import type { GlobalState, State } from "./types" import type { FileDiff, GlobalState, State } from "./types"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { stripSessionDiffSnapshots } from "./sanitize" import { stripSessionDiffSnapshots } from "./sanitize"
import { syncDebug } from "./debug" import { syncDebug } from "./debug"
+29 -10
View File
@@ -1,8 +1,9 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Payload sanitization — strip oversized diff snapshot fields client-side. // Payload sanitization — strip oversized diff snapshot fields client-side.
// //
// OpenCode session objects carry summary.diffs[].before/after with full file // OpenCode session/message snapshots may carry large full-content diff fields
// contents. The UI never uses these fields but they waste browser memory and // (legacy before/after or from/to). The UI never uses these fields but they
// waste browser memory and
// can crash tabs for large sessions. // can crash tabs for large sessions.
// //
// Applied at two points: // Applied at two points:
@@ -19,6 +20,8 @@ type DiffEntry = {
deletions?: number deletions?: number
before?: string before?: string
after?: string after?: string
from?: string
to?: string
} }
type SessionSummary = { type SessionSummary = {
@@ -26,16 +29,24 @@ type SessionSummary = {
[key: string]: unknown [key: string]: unknown
} }
/** Strip before/after from summary.diffs on a session object */ /** Strip oversized snapshot fields from summary.diffs on a session object */
export function stripSessionDiffSnapshots(session: Session): Session { export function stripSessionDiffSnapshots(session: Session): Session {
const summary = (session as { summary?: SessionSummary }).summary const summary = (session as { summary?: SessionSummary }).summary
if (!summary?.diffs || !Array.isArray(summary.diffs)) return session if (!summary?.diffs || !Array.isArray(summary.diffs)) return session
let changed = false let changed = false
const stripped = summary.diffs.map((d) => { const stripped = summary.diffs.map((d) => {
if (d && (typeof d.before === "string" || typeof d.after === "string")) { if (d && (
// eslint-disable-next-line @typescript-eslint/no-unused-vars typeof d.before === "string"
const { before: _before, after: _after, ...rest } = d || typeof d.after === "string"
|| typeof d.from === "string"
|| typeof d.to === "string"
)) {
const rest = { ...d }
delete rest.before
delete rest.after
delete rest.from
delete rest.to
changed = true changed = true
return rest return rest
} }
@@ -46,16 +57,24 @@ export function stripSessionDiffSnapshots(session: Session): Session {
return { ...session, summary: { ...summary, diffs: stripped } } as Session return { ...session, summary: { ...summary, diffs: stripped } } as Session
} }
/** Strip before/after from summary.diffs on a message object */ /** Strip oversized snapshot fields from summary.diffs on a message object */
export function stripMessageDiffSnapshots(message: Message): Message { export function stripMessageDiffSnapshots(message: Message): Message {
const summary = (message as { summary?: SessionSummary }).summary const summary = (message as { summary?: SessionSummary }).summary
if (!summary?.diffs || !Array.isArray(summary.diffs)) return message if (!summary?.diffs || !Array.isArray(summary.diffs)) return message
let changed = false let changed = false
const stripped = summary.diffs.map((d) => { const stripped = summary.diffs.map((d) => {
if (d && (typeof d.before === "string" || typeof d.after === "string")) { if (d && (
// eslint-disable-next-line @typescript-eslint/no-unused-vars typeof d.before === "string"
const { before: _before, after: _after, ...rest } = d || typeof d.after === "string"
|| typeof d.from === "string"
|| typeof d.to === "string"
)) {
const rest = { ...d }
delete rest.before
delete rest.after
delete rest.from
delete rest.to
changed = true changed = true
return rest return rest
} }
+1 -1
View File
@@ -1,5 +1,4 @@
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
@@ -7,6 +6,7 @@ import type {
SessionStatus, SessionStatus,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import type { FileDiff } from "./types"
type SessionCache = { type SessionCache = {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
+4 -3
View File
@@ -1099,7 +1099,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const messages = getSyncMessages(sessionId, directory) const messages = getSyncMessages(sessionId, directory)
for (let i = messages.length - 1; i >= 0; i -= 1) { for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i] as Message & { const message = messages[i] as Message & {
model?: { providerID?: string; modelID?: string } model?: { providerID?: string; modelID?: string; variant?: string }
variant?: string variant?: string
mode?: string mode?: string
} }
@@ -1116,8 +1116,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const agent = typeof message.agent === "string" && message.agent.trim().length > 0 const agent = typeof message.agent === "string" && message.agent.trim().length > 0
? message.agent ? message.agent
: (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined) : (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined)
const variant = typeof message.variant === "string" && message.variant.trim().length > 0 const variantCandidate = message.model?.variant ?? message.variant
? message.variant const variant = typeof variantCandidate === "string" && variantCandidate.trim().length > 0
? variantCandidate
: undefined : undefined
return { agent, providerID, modelID, variant } return { agent, providerID, modelID, variant }
+9 -1
View File
@@ -2,7 +2,6 @@ import type {
Agent, Agent,
Command, Command,
Config, Config,
FileDiff,
LspStatus, LspStatus,
McpStatus, McpStatus,
Message, Message,
@@ -19,6 +18,15 @@ import type {
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
export type FileDiff = {
file?: string
status?: string
additions?: number
deletions?: number
patch?: string
[key: string]: unknown
}
export type ProjectMeta = { export type ProjectMeta = {
name?: string name?: string
icon?: { icon?: {
+1 -1
View File
@@ -243,7 +243,7 @@
}, },
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
+2 -1
View File
@@ -29,7 +29,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.3.17", "@opencode-ai/sdk": "^1.4.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -61,6 +61,7 @@
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
+1
View File
@@ -1,3 +1,4 @@
import 'reflect-metadata';
import express from 'express'; import express from 'express';
import path from 'path'; import path from 'path';
import { spawn, spawnSync } from 'child_process'; import { spawn, spawnSync } from 'child_process';
+16
View File
@@ -0,0 +1,16 @@
// Test file for UI verification
// Created: 2026-04-12
const testConfig = {
name: "OpenChamber UI Verification",
version: "2.0.0-beta",
features: {
chat: true,
terminal: true,
settings: true
},
debug: true,
environment: "development"
};
export default testConfig;