feat(chat): remember JSON output view mode (#3072)
* feat(chat): remember JSON output view mode * perf(chat): skip JSON preference reads for text output * fix(chat): persist JSON view in UI settings * fix(chat): round-trip JSON view preference --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
3a96c7ce64
commit
a005215458
@@ -85,6 +85,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
- Selecting a JSON summary, tree, or raw view saves that mode in the persisted UI settings. New and refreshed JSON tool outputs read the saved mode across sessions; missing or invalid preferences use Summary.
|
||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. Patches over 256 KiB or 2,000 lines skip rich parsing and use a bounded plain-text preview; navigation keeps the original patch. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
|
||||
- The `@pierre/diffs` stack is knowingly unprotected against the JS/TS `template-call` backtracking that OOM'd the renderer in openchamber/openchamber#2587. Our own markdown Shiki worker sanitizes every grammar it loads (`@/lib/shiki/sanitizeTemplateCallGrammar`), but the diff worker pool runs `preferredHighlighter: 'shiki-wasm'` (`DiffWorkerProvider.tsx`) and resolves its languages by id through `@pierre/diffs`' own registry — `langs` accepts `SupportedLanguages` strings only, so there is no seam to hand it a pre-sanitized `LanguageRegistration`. A pathological template literal inside a rendered diff can therefore still hang that pool's Oniguruma engine. The available levers are upstream (a `langs` overload accepting grammar objects) or switching that pool to the JS regex engine; neither is done.
|
||||
|
||||
@@ -72,6 +72,8 @@ import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
import { ApplyPatchFileButtons } from './ApplyPatchFileButtons';
|
||||
import { openApplyPatchFileInEditor } from './applyPatchEditorAction';
|
||||
|
||||
type ToolJsonViewMode = 'summary' | 'formatted' | 'raw';
|
||||
|
||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
||||
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);
|
||||
@@ -651,28 +653,23 @@ const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ToolScrollableTextOutput: React.FC<{
|
||||
output: string;
|
||||
part: ToolPartType;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
input: Record<string, unknown> | undefined;
|
||||
isStreaming?: boolean;
|
||||
}> = ({ output, part, metadata, input, isStreaming = false }) => {
|
||||
type JsonOutputResult = ReturnType<typeof tryParseJsonOutput>;
|
||||
|
||||
const JsonToolOutput: React.FC<{
|
||||
jsonResult: JsonOutputResult;
|
||||
renderedOutput: string;
|
||||
}> = ({ jsonResult, renderedOutput }) => {
|
||||
const { t } = useI18n();
|
||||
const renderedOutput = getToolOutputText(output, part, metadata);
|
||||
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
|
||||
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
|
||||
const [jsonViewMode, setJsonViewMode] = React.useState<'summary' | 'formatted' | 'raw'>('summary');
|
||||
const jsonViewMode = useUIStore((state) => state.toolJsonViewMode);
|
||||
const [copiedJson, setCopiedJson] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setJsonViewMode('summary');
|
||||
setCopiedJson(false);
|
||||
}, [renderedOutput]);
|
||||
|
||||
const handleJsonViewChange = React.useCallback((view: 'summary' | 'formatted' | 'raw', event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleJsonViewChange = React.useCallback((view: ToolJsonViewMode, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setJsonViewMode(view);
|
||||
useUIStore.getState().setToolJsonViewMode(view);
|
||||
}, []);
|
||||
|
||||
const handleCopyOutput = React.useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -688,6 +685,88 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
}
|
||||
}, [renderedOutput, t]);
|
||||
|
||||
return (
|
||||
<div className="tool-output-surface relative p-2 rounded-xl w-full min-w-0">
|
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'summary' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('summary', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showNavigableJson')}
|
||||
title={t('chat.toolPart.showNavigableJson')}
|
||||
>
|
||||
<Icon name="list-unordered" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'formatted' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('formatted', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showFormattedJson')}
|
||||
title={t('chat.toolPart.showFormattedJson')}
|
||||
>
|
||||
<Icon name="node-tree" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'raw' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('raw', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showRawJson')}
|
||||
title={t('chat.toolPart.showRawJson')}
|
||||
>
|
||||
<Icon name="code-box" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md bg-[var(--surface-elevated)]/80 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCopyOutput}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={copiedJson ? t('chat.toolPart.copiedOutput') : t('chat.toolPart.copyOutput')}
|
||||
title={copiedJson ? t('chat.toolPart.copiedOutput') : t('chat.toolPart.copyOutput')}
|
||||
>
|
||||
<Icon name={copiedJson ? 'check' : 'file-copy'} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{jsonViewMode === 'summary' ? (
|
||||
<JsonSummaryView data={jsonResult.data} />
|
||||
) : jsonViewMode === 'formatted' ? (
|
||||
<JsonTreeViewer
|
||||
data={jsonResult.data}
|
||||
initiallyExpandedDepth={1}
|
||||
maxHeight="400px"
|
||||
/>
|
||||
) : (
|
||||
<div className="typography-code pr-12 text-muted-foreground/90">
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
code={renderedOutput}
|
||||
style={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeStyle={CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolScrollableTextOutput: React.FC<{
|
||||
output: string;
|
||||
part: ToolPartType;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
input: Record<string, unknown> | undefined;
|
||||
isStreaming?: boolean;
|
||||
}> = ({ output, part, metadata, input, isStreaming = false }) => {
|
||||
const renderedOutput = getToolOutputText(output, part, metadata);
|
||||
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
|
||||
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
|
||||
|
||||
if (part.tool === 'bash' && isStreaming) {
|
||||
return (
|
||||
<div className="typography-code text-muted-foreground/90">
|
||||
@@ -697,75 +776,7 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
}
|
||||
|
||||
if (jsonResult.isJson) {
|
||||
return (
|
||||
<div className="tool-output-surface relative p-2 rounded-xl w-full min-w-0">
|
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'summary' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('summary', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showNavigableJson')}
|
||||
title={t('chat.toolPart.showNavigableJson')}
|
||||
>
|
||||
<Icon name="list-unordered" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'formatted' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('formatted', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showFormattedJson')}
|
||||
title={t('chat.toolPart.showFormattedJson')}
|
||||
>
|
||||
<Icon name="node-tree" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'raw' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('raw', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showRawJson')}
|
||||
title={t('chat.toolPart.showRawJson')}
|
||||
>
|
||||
<Icon name="code-box" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md bg-[var(--surface-elevated)]/80 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCopyOutput}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={copiedJson ? t('chat.toolPart.copiedOutput') : t('chat.toolPart.copyOutput')}
|
||||
title={copiedJson ? t('chat.toolPart.copiedOutput') : t('chat.toolPart.copyOutput')}
|
||||
>
|
||||
<Icon name={copiedJson ? 'check' : 'file-copy'} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{jsonViewMode === 'summary' ? (
|
||||
<JsonSummaryView data={jsonResult.data} />
|
||||
) : jsonViewMode === 'formatted' ? (
|
||||
<JsonTreeViewer
|
||||
data={jsonResult.data}
|
||||
initiallyExpandedDepth={1}
|
||||
maxHeight="400px"
|
||||
/>
|
||||
) : (
|
||||
<div className="typography-code pr-12 text-muted-foreground/90">
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
code={renderedOutput}
|
||||
style={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeStyle={CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <JsonToolOutput jsonResult={jsonResult} renderedOutput={renderedOutput} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user