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:
@@ -32,7 +32,7 @@
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -94,7 +94,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@openchamber/desktop",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.4",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^24.3.1",
|
||||
@@ -103,7 +103,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.4",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
@@ -133,7 +133,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "^1.3.17",
|
||||
"@opencode-ai/sdk": "^1.4.3",
|
||||
"@pierre/diffs": "1.1.0-beta.13",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -208,10 +208,10 @@
|
||||
},
|
||||
"packages/vscode": {
|
||||
"name": "openchamber",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.4",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.3.17",
|
||||
"@opencode-ai/sdk": "^1.4.3",
|
||||
"adm-zip": "^0.5.16",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
@@ -231,7 +231,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.4",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js",
|
||||
},
|
||||
@@ -243,7 +243,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -275,6 +275,7 @@
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"simple-git": "^3.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -826,7 +827,7 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "^1.3.17",
|
||||
"@opencode-ai/sdk": "^1.4.3",
|
||||
"@pierre/diffs": "1.1.0-beta.13",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import React from 'react';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
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 { 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 { toolDisplayStyles } from '@/lib/typography';
|
||||
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 => {
|
||||
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 removed = 0;
|
||||
|
||||
@@ -254,22 +256,67 @@ const extractFirstChangedLineFromDiff = (diffText: string): number | undefined =
|
||||
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 => {
|
||||
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof metadata.diff === 'string') {
|
||||
const line = extractFirstChangedLineFromDiff(metadata.diff);
|
||||
const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
|
||||
if (topLevelPatch) {
|
||||
const line = extractFirstChangedLineFromDiff(topLevelPatch);
|
||||
if (Number.isFinite(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
||||
const firstFile = files[0] as { diff?: unknown } | undefined;
|
||||
if (typeof firstFile?.diff === 'string') {
|
||||
const line = extractFirstChangedLineFromDiff(firstFile.diff);
|
||||
const firstFile = files[0] as { patch?: unknown; diff?: unknown } | undefined;
|
||||
const filePatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff);
|
||||
if (filePatch) {
|
||||
const line = extractFirstChangedLineFromDiff(filePatch);
|
||||
if (Number.isFinite(line)) {
|
||||
return line;
|
||||
}
|
||||
@@ -303,15 +350,17 @@ const getPrimaryDiffFromMetadata = (
|
||||
: files[0];
|
||||
|
||||
if (matched && typeof matched === 'object') {
|
||||
const patch = (matched as { diff?: unknown }).diff;
|
||||
if (typeof patch === 'string' && patch.trim().length > 0) {
|
||||
const patch = getPatchText((matched as { patch?: unknown; diff?: unknown }).patch)
|
||||
?? getPatchText((matched as { patch?: unknown; diff?: unknown }).diff);
|
||||
if (patch) {
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof metadata.diff === 'string' && metadata.diff.trim().length > 0) {
|
||||
return metadata.diff;
|
||||
const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
|
||||
if (topLevelPatch) {
|
||||
return topLevelPatch;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -1280,8 +1329,8 @@ const getDiffPatchEntries = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = file as { relativePath?: unknown; filePath?: unknown; diff?: unknown };
|
||||
const patch = typeof record.diff === 'string' ? record.diff.trim() : '';
|
||||
const record = file as { relativePath?: unknown; filePath?: unknown; patch?: unknown; diff?: unknown };
|
||||
const patch = getPatchText(record.patch) ?? getPatchText(record.diff) ?? '';
|
||||
if (!patch) {
|
||||
return null;
|
||||
}
|
||||
@@ -1344,97 +1393,6 @@ const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme,
|
||||
|
||||
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 {
|
||||
part: ToolPartType;
|
||||
state: ToolStateUnion;
|
||||
@@ -1459,32 +1417,16 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
|
||||
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(
|
||||
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []),
|
||||
[currentDirectory, diffContent, metadata]
|
||||
);
|
||||
const writeFilePath = part.tool === 'write'
|
||||
? typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: 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 hideToolInputPreview = part.tool === 'apply_patch'
|
||||
|| part.tool === 'edit'
|
||||
|| part.tool === 'multiedit';
|
||||
const diagnosticSection = React.useMemo(
|
||||
() => getToolDiagnosticSection(part.tool, input, metadata, currentDirectory),
|
||||
[currentDirectory, input, metadata, part.tool],
|
||||
@@ -1505,7 +1447,21 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
|
||||
return formatInputForDisplay(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(() => {
|
||||
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(
|
||||
<div className="space-y-3">
|
||||
{diffEntries.map((entry) => (
|
||||
@@ -1648,6 +1604,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
);
|
||||
}
|
||||
|
||||
if (isWriteLikeTool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasStringOutput && outputString.trim()) {
|
||||
return renderScrollableBlock(
|
||||
<ToolScrollableTextOutput
|
||||
@@ -1680,35 +1640,20 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
renderResultContent()
|
||||
) : (
|
||||
<>
|
||||
{shouldShowWriteInputPreview && isWriteImageFile ? (
|
||||
<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 ? (
|
||||
{hasInputText ? (
|
||||
<div className="my-1">
|
||||
{renderScrollableBlock(
|
||||
part.tool === 'bash' ? (
|
||||
<pre className="tool-input-text whitespace-pre-wrap break-words typography-code text-muted-foreground/90 m-0 p-0">
|
||||
{inputTextContent}
|
||||
</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">
|
||||
{inputTextContent}
|
||||
@@ -1722,9 +1667,9 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
|
||||
{state.status === 'completed' && 'output' in state && (
|
||||
<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">
|
||||
<DiffViewToggle
|
||||
mode={diffViewMode}
|
||||
|
||||
@@ -36,6 +36,47 @@ const formatInputForDisplay = (input: Record<string, unknown>, toolName?: string
|
||||
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 } => {
|
||||
if (!output || typeof output !== 'string') {
|
||||
return { data: null, isJson: false };
|
||||
@@ -73,10 +114,11 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
|
||||
cleaned = stripLspDiagnostics(cleaned);
|
||||
}
|
||||
|
||||
if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') && cleaned.trim().length === 0 && metadata?.diff) {
|
||||
|
||||
const diff = metadata.diff;
|
||||
return typeof diff === 'string' ? diff : String(diff);
|
||||
if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') && cleaned.trim().length === 0) {
|
||||
const diff = getToolMetadataPatch(metadata);
|
||||
if (diff) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
Event,
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
@@ -11,7 +10,7 @@ import type {
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { Binary } from "./binary"
|
||||
import type { GlobalState, State } from "./types"
|
||||
import type { FileDiff, GlobalState, State } from "./types"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { syncDebug } from "./debug"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload sanitization — strip oversized diff snapshot fields client-side.
|
||||
//
|
||||
// OpenCode session objects carry summary.diffs[].before/after with full file
|
||||
// contents. The UI never uses these fields but they waste browser memory and
|
||||
// OpenCode session/message snapshots may carry large full-content diff fields
|
||||
// (legacy before/after or from/to). The UI never uses these fields but they
|
||||
// waste browser memory and
|
||||
// can crash tabs for large sessions.
|
||||
//
|
||||
// Applied at two points:
|
||||
@@ -19,6 +20,8 @@ type DiffEntry = {
|
||||
deletions?: number
|
||||
before?: string
|
||||
after?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
type SessionSummary = {
|
||||
@@ -26,16 +29,24 @@ type SessionSummary = {
|
||||
[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 {
|
||||
const summary = (session as { summary?: SessionSummary }).summary
|
||||
if (!summary?.diffs || !Array.isArray(summary.diffs)) return session
|
||||
|
||||
let changed = false
|
||||
const stripped = summary.diffs.map((d) => {
|
||||
if (d && (typeof d.before === "string" || typeof d.after === "string")) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { before: _before, after: _after, ...rest } = d
|
||||
if (d && (
|
||||
typeof d.before === "string"
|
||||
|| 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
|
||||
return rest
|
||||
}
|
||||
@@ -46,16 +57,24 @@ export function stripSessionDiffSnapshots(session: Session): 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 {
|
||||
const summary = (message as { summary?: SessionSummary }).summary
|
||||
if (!summary?.diffs || !Array.isArray(summary.diffs)) return message
|
||||
|
||||
let changed = false
|
||||
const stripped = summary.diffs.map((d) => {
|
||||
if (d && (typeof d.before === "string" || typeof d.after === "string")) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { before: _before, after: _after, ...rest } = d
|
||||
if (d && (
|
||||
typeof d.before === "string"
|
||||
|| 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
|
||||
return rest
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
@@ -7,6 +6,7 @@ import type {
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiff } from "./types"
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
|
||||
@@ -1099,7 +1099,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const messages = getSyncMessages(sessionId, directory)
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i] as Message & {
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
model?: { providerID?: string; modelID?: string; variant?: string }
|
||||
variant?: 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
|
||||
? message.agent
|
||||
: (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined)
|
||||
const variant = typeof message.variant === "string" && message.variant.trim().length > 0
|
||||
? message.variant
|
||||
const variantCandidate = message.model?.variant ?? message.variant
|
||||
const variant = typeof variantCandidate === "string" && variantCandidate.trim().length > 0
|
||||
? variantCandidate
|
||||
: undefined
|
||||
|
||||
return { agent, providerID, modelID, variant }
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
FileDiff,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
Message,
|
||||
@@ -19,6 +18,15 @@ import type {
|
||||
VcsInfo,
|
||||
} 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 = {
|
||||
name?: string
|
||||
icon?: {
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.3.17",
|
||||
"@opencode-ai/sdk": "^1.4.3",
|
||||
"adm-zip": "^0.5.16",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -61,6 +61,7 @@
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"simple-git": "^3.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'reflect-metadata';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user