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`.
|
- `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`.
|
- 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.
|
- 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.
|
- `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 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.
|
- 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 { ApplyPatchFileButtons } from './ApplyPatchFileButtons';
|
||||||
import { openApplyPatchFileInEditor } from './applyPatchEditorAction';
|
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_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_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);
|
||||||
@@ -651,28 +653,23 @@ const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToolScrollableTextOutput: React.FC<{
|
type JsonOutputResult = ReturnType<typeof tryParseJsonOutput>;
|
||||||
output: string;
|
|
||||||
part: ToolPartType;
|
const JsonToolOutput: React.FC<{
|
||||||
metadata: Record<string, unknown> | undefined;
|
jsonResult: JsonOutputResult;
|
||||||
input: Record<string, unknown> | undefined;
|
renderedOutput: string;
|
||||||
isStreaming?: boolean;
|
}> = ({ jsonResult, renderedOutput }) => {
|
||||||
}> = ({ output, part, metadata, input, isStreaming = false }) => {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const renderedOutput = getToolOutputText(output, part, metadata);
|
const jsonViewMode = useUIStore((state) => state.toolJsonViewMode);
|
||||||
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
|
|
||||||
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
|
|
||||||
const [jsonViewMode, setJsonViewMode] = React.useState<'summary' | 'formatted' | 'raw'>('summary');
|
|
||||||
const [copiedJson, setCopiedJson] = React.useState(false);
|
const [copiedJson, setCopiedJson] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setJsonViewMode('summary');
|
|
||||||
setCopiedJson(false);
|
setCopiedJson(false);
|
||||||
}, [renderedOutput]);
|
}, [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();
|
event.stopPropagation();
|
||||||
setJsonViewMode(view);
|
useUIStore.getState().setToolJsonViewMode(view);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopyOutput = React.useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleCopyOutput = React.useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
@@ -688,6 +685,88 @@ const ToolScrollableTextOutput: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [renderedOutput, t]);
|
}, [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) {
|
if (part.tool === 'bash' && isStreaming) {
|
||||||
return (
|
return (
|
||||||
<div className="typography-code text-muted-foreground/90">
|
<div className="typography-code text-muted-foreground/90">
|
||||||
@@ -697,75 +776,7 @@ const ToolScrollableTextOutput: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (jsonResult.isJson) {
|
if (jsonResult.isJson) {
|
||||||
return (
|
return <JsonToolOutput jsonResult={jsonResult} renderedOutput={renderedOutput} />;
|
||||||
<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 (
|
return (
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ export interface SettingsPayload {
|
|||||||
shortcutOverrides?: Record<string, string>;
|
shortcutOverrides?: Record<string, string>;
|
||||||
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
gitChangesViewMode?: 'flat' | 'tree';
|
gitChangesViewMode?: 'flat' | 'tree';
|
||||||
|
toolJsonViewMode?: 'summary' | 'formatted' | 'raw';
|
||||||
directoryShowHidden?: boolean;
|
directoryShowHidden?: boolean;
|
||||||
filesViewShowGitignored?: boolean;
|
filesViewShowGitignored?: boolean;
|
||||||
openInAppId?: string;
|
openInAppId?: string;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type AppearanceSlice = {
|
|||||||
mobileKeyboardMode: MobileKeyboardMode;
|
mobileKeyboardMode: MobileKeyboardMode;
|
||||||
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
gitChangesViewMode: 'flat' | 'tree';
|
gitChangesViewMode: 'flat' | 'tree';
|
||||||
|
toolJsonViewMode: 'summary' | 'formatted' | 'raw';
|
||||||
};
|
};
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
@@ -101,6 +102,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
mobileKeyboardMode: useUIStore.getState().mobileKeyboardMode,
|
mobileKeyboardMode: useUIStore.getState().mobileKeyboardMode,
|
||||||
diffLayoutPreference: useUIStore.getState().diffLayoutPreference,
|
diffLayoutPreference: useUIStore.getState().diffLayoutPreference,
|
||||||
gitChangesViewMode: useUIStore.getState().gitChangesViewMode,
|
gitChangesViewMode: useUIStore.getState().gitChangesViewMode,
|
||||||
|
toolJsonViewMode: useUIStore.getState().toolJsonViewMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
useUIStore.subscribe((state) => {
|
useUIStore.subscribe((state) => {
|
||||||
@@ -144,6 +146,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
mobileKeyboardMode: state.mobileKeyboardMode,
|
mobileKeyboardMode: state.mobileKeyboardMode,
|
||||||
diffLayoutPreference: state.diffLayoutPreference,
|
diffLayoutPreference: state.diffLayoutPreference,
|
||||||
gitChangesViewMode: state.gitChangesViewMode,
|
gitChangesViewMode: state.gitChangesViewMode,
|
||||||
|
toolJsonViewMode: state.toolJsonViewMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
const diff: Partial<DesktopSettings> = {};
|
const diff: Partial<DesktopSettings> = {};
|
||||||
@@ -267,6 +270,9 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
if (current.gitChangesViewMode !== previous.gitChangesViewMode) {
|
if (current.gitChangesViewMode !== previous.gitChangesViewMode) {
|
||||||
diff.gitChangesViewMode = current.gitChangesViewMode;
|
diff.gitChangesViewMode = current.gitChangesViewMode;
|
||||||
}
|
}
|
||||||
|
if (current.toolJsonViewMode !== previous.toolJsonViewMode) {
|
||||||
|
diff.toolJsonViewMode = current.toolJsonViewMode;
|
||||||
|
}
|
||||||
|
|
||||||
previous = current;
|
previous = current;
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export type DesktopSettings = {
|
|||||||
recentEfforts?: Record<string, string[]>;
|
recentEfforts?: Record<string, string[]>;
|
||||||
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
gitChangesViewMode?: 'flat' | 'tree';
|
gitChangesViewMode?: 'flat' | 'tree';
|
||||||
|
toolJsonViewMode?: 'summary' | 'formatted' | 'raw';
|
||||||
directoryShowHidden?: boolean;
|
directoryShowHidden?: boolean;
|
||||||
filesViewShowGitignored?: boolean;
|
filesViewShowGitignored?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ describe('updateDesktopSettings', () => {
|
|||||||
showReasoningTraces: false,
|
showReasoningTraces: false,
|
||||||
terminalShell: 'fish',
|
terminalShell: 'fish',
|
||||||
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }],
|
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }],
|
||||||
|
toolJsonViewMode: 'raw',
|
||||||
followUpBehavior: 'steer',
|
followUpBehavior: 'steer',
|
||||||
draftStarters: [{ type: 'command', name: 'runtime-a' }],
|
draftStarters: [{ type: 'command', name: 'runtime-a' }],
|
||||||
draftStartersVisible: false,
|
draftStartersVisible: false,
|
||||||
@@ -413,6 +414,7 @@ describe('updateDesktopSettings', () => {
|
|||||||
expect(useUIStore.getState().showReasoningTraces).toBe(false);
|
expect(useUIStore.getState().showReasoningTraces).toBe(false);
|
||||||
expect(useUIStore.getState().terminalShell).toBe('fish');
|
expect(useUIStore.getState().terminalShell).toBe('fish');
|
||||||
expect(useUIStore.getState().favoriteModels).toHaveLength(1);
|
expect(useUIStore.getState().favoriteModels).toHaveLength(1);
|
||||||
|
expect(useUIStore.getState().toolJsonViewMode).toBe('raw');
|
||||||
expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]);
|
expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]);
|
||||||
expect(useUIStore.getState().draftStartersVisible).toBe(false);
|
expect(useUIStore.getState().draftStartersVisible).toBe(false);
|
||||||
expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer');
|
expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer');
|
||||||
@@ -427,6 +429,7 @@ describe('updateDesktopSettings', () => {
|
|||||||
expect(useUIStore.getState().showReasoningTraces).toBe(true);
|
expect(useUIStore.getState().showReasoningTraces).toBe(true);
|
||||||
expect(useUIStore.getState().terminalShell).toBe('auto');
|
expect(useUIStore.getState().terminalShell).toBe('auto');
|
||||||
expect(useUIStore.getState().favoriteModels).toEqual([]);
|
expect(useUIStore.getState().favoriteModels).toEqual([]);
|
||||||
|
expect(useUIStore.getState().toolJsonViewMode).toBe('summary');
|
||||||
expect(useUIStore.getState().globalDraftStarters).toBeNull();
|
expect(useUIStore.getState().globalDraftStarters).toBeNull();
|
||||||
expect(useUIStore.getState().draftStartersVisible).toBe(true);
|
expect(useUIStore.getState().draftStartersVisible).toBe(true);
|
||||||
expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue');
|
expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue');
|
||||||
@@ -445,6 +448,18 @@ describe('updateDesktopSettings', () => {
|
|||||||
expect(localStorage.getItem('selectedThemeId')).toBe('existing-theme');
|
expect(localStorage.getItem('selectedThemeId')).toBe('existing-theme');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ignores an invalid JSON view mode in a settings save response', async () => {
|
||||||
|
getWindow();
|
||||||
|
useUIStore.getState().setToolJsonViewMode('formatted');
|
||||||
|
const invalidSettings: SettingsPayload = {};
|
||||||
|
Object.defineProperty(invalidSettings, 'toolJsonViewMode', { value: 'invalid', enumerable: true });
|
||||||
|
registerSettingsSave(async () => invalidSettings);
|
||||||
|
|
||||||
|
await updateDesktopSettings({ showReasoningTraces: false });
|
||||||
|
|
||||||
|
expect(useUIStore.getState().toolJsonViewMode).toBe('formatted');
|
||||||
|
});
|
||||||
|
|
||||||
test('applies authoritative shared sidebar preferences without replacing local-only sidebar state', async () => {
|
test('applies authoritative shared sidebar preferences without replacing local-only sidebar state', async () => {
|
||||||
getWindow();
|
getWindow();
|
||||||
useSessionDisplayStore.setState({
|
useSessionDisplayStore.setState({
|
||||||
@@ -756,7 +771,7 @@ describe('updateDesktopSettings', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('autosaves terminal shell changes to shared settings', async () => {
|
test('autosaves appearance preferences to shared settings', async () => {
|
||||||
getWindow();
|
getWindow();
|
||||||
useUIStore.getState().setTerminalShell('auto');
|
useUIStore.getState().setTerminalShell('auto');
|
||||||
useUIStore.getState().setTerminalLoginShells([]);
|
useUIStore.getState().setTerminalLoginShells([]);
|
||||||
@@ -769,10 +784,12 @@ describe('updateDesktopSettings', () => {
|
|||||||
|
|
||||||
useUIStore.getState().setTerminalShell('zsh');
|
useUIStore.getState().setTerminalShell('zsh');
|
||||||
useUIStore.getState().setTerminalLoginShells(['zsh']);
|
useUIStore.getState().setTerminalLoginShells(['zsh']);
|
||||||
|
useUIStore.getState().setToolJsonViewMode('formatted');
|
||||||
await delay(500);
|
await delay(500);
|
||||||
|
|
||||||
expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true);
|
expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true);
|
||||||
expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('zsh'))).toBe(true);
|
expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('zsh'))).toBe(true);
|
||||||
|
expect(saveCalls.some((changes) => changes.toolJsonViewMode === 'formatted')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('applies persisted autoSaveEnabled from server settings', async () => {
|
test('applies persisted autoSaveEnabled from server settings', async () => {
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
|
|||||||
recentEfforts: defaults.recentEfforts,
|
recentEfforts: defaults.recentEfforts,
|
||||||
diffLayoutPreference: defaults.diffLayoutPreference,
|
diffLayoutPreference: defaults.diffLayoutPreference,
|
||||||
gitChangesViewMode: defaults.gitChangesViewMode,
|
gitChangesViewMode: defaults.gitChangesViewMode,
|
||||||
|
toolJsonViewMode: defaults.toolJsonViewMode,
|
||||||
directoryShowHidden: true,
|
directoryShowHidden: true,
|
||||||
filesViewShowGitignored: false,
|
filesViewShowGitignored: false,
|
||||||
dictationEnabled: true,
|
dictationEnabled: true,
|
||||||
@@ -1044,6 +1045,15 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
store.setGitChangesViewMode(settings.gitChangesViewMode);
|
store.setGitChangesViewMode(settings.gitChangesViewMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
switch (settings.toolJsonViewMode) {
|
||||||
|
case 'summary':
|
||||||
|
case 'formatted':
|
||||||
|
case 'raw':
|
||||||
|
if (settings.toolJsonViewMode !== store.toolJsonViewMode) {
|
||||||
|
store.setToolJsonViewMode(settings.toolJsonViewMode);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (typeof settings.directoryShowHidden === 'boolean') {
|
if (typeof settings.directoryShowHidden === 'boolean') {
|
||||||
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
|
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
|
||||||
}
|
}
|
||||||
@@ -1619,6 +1629,13 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
) {
|
) {
|
||||||
result.gitChangesViewMode = candidate.gitChangesViewMode;
|
result.gitChangesViewMode = candidate.gitChangesViewMode;
|
||||||
}
|
}
|
||||||
|
switch (candidate.toolJsonViewMode) {
|
||||||
|
case 'summary':
|
||||||
|
case 'formatted':
|
||||||
|
case 'raw':
|
||||||
|
result.toolJsonViewMode = candidate.toolJsonViewMode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (typeof candidate.directoryShowHidden === 'boolean') {
|
if (typeof candidate.directoryShowHidden === 'boolean') {
|
||||||
result.directoryShowHidden = candidate.directoryShowHidden;
|
result.directoryShowHidden = candidate.directoryShowHidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -875,6 +875,7 @@ interface UIStore {
|
|||||||
/** Width of the walkthrough table of contents, in pixels. */
|
/** Width of the walkthrough table of contents, in pixels. */
|
||||||
walkthroughTocWidth: number;
|
walkthroughTocWidth: number;
|
||||||
gitChangesViewMode: 'flat' | 'tree';
|
gitChangesViewMode: 'flat' | 'tree';
|
||||||
|
toolJsonViewMode: 'summary' | 'formatted' | 'raw';
|
||||||
linearIssueListStatus: LinearIssueListStatus;
|
linearIssueListStatus: LinearIssueListStatus;
|
||||||
linearIssueListAssignee: LinearIssueListAssignee;
|
linearIssueListAssignee: LinearIssueListAssignee;
|
||||||
/**
|
/**
|
||||||
@@ -1086,6 +1087,7 @@ interface UIStore {
|
|||||||
setDiffWrapLines: (wrap: boolean) => void;
|
setDiffWrapLines: (wrap: boolean) => void;
|
||||||
setWalkthroughTocWidth: (width: number) => void;
|
setWalkthroughTocWidth: (width: number) => void;
|
||||||
setGitChangesViewMode: (mode: 'flat' | 'tree') => void;
|
setGitChangesViewMode: (mode: 'flat' | 'tree') => void;
|
||||||
|
setToolJsonViewMode: (mode: 'summary' | 'formatted' | 'raw') => void;
|
||||||
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
|
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
|
||||||
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
|
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
|
||||||
setLinearIssueListTeamId: (teamId: string) => void;
|
setLinearIssueListTeamId: (teamId: string) => void;
|
||||||
@@ -1251,6 +1253,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
diffWrapLines: false,
|
diffWrapLines: false,
|
||||||
walkthroughTocWidth: 224,
|
walkthroughTocWidth: 224,
|
||||||
gitChangesViewMode: 'flat',
|
gitChangesViewMode: 'flat',
|
||||||
|
toolJsonViewMode: 'summary',
|
||||||
linearIssueListStatus: 'all',
|
linearIssueListStatus: 'all',
|
||||||
linearIssueListAssignee: 'any',
|
linearIssueListAssignee: 'any',
|
||||||
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||||
@@ -2197,6 +2200,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ gitChangesViewMode: mode });
|
set({ gitChangesViewMode: mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setToolJsonViewMode: (mode) => {
|
||||||
|
set({ toolJsonViewMode: mode });
|
||||||
|
},
|
||||||
|
|
||||||
setLinearIssueListStatus: (status) => {
|
setLinearIssueListStatus: (status) => {
|
||||||
set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) });
|
set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) });
|
||||||
},
|
},
|
||||||
@@ -2243,7 +2250,6 @@ export const useUIStore = create<UIStore>()(
|
|||||||
const trimmed = identifier?.trim() ?? '';
|
const trimmed = identifier?.trim() ?? '';
|
||||||
set({ linearIssueFocus: trimmed || null });
|
set({ linearIssueFocus: trimmed || null });
|
||||||
},
|
},
|
||||||
|
|
||||||
setInputBarOffset: (offset) => {
|
setInputBarOffset: (offset) => {
|
||||||
set({ inputBarOffset: offset });
|
set({ inputBarOffset: offset });
|
||||||
},
|
},
|
||||||
@@ -2912,6 +2918,12 @@ export const useUIStore = create<UIStore>()(
|
|||||||
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
|
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
|
||||||
state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior);
|
state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior);
|
||||||
|
|
||||||
|
if (state.toolJsonViewMode !== 'summary'
|
||||||
|
&& state.toolJsonViewMode !== 'formatted'
|
||||||
|
&& state.toolJsonViewMode !== 'raw') {
|
||||||
|
state.toolJsonViewMode = 'summary';
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof state.autoSaveEnabled !== 'boolean') {
|
if (typeof state.autoSaveEnabled !== 'boolean') {
|
||||||
state.autoSaveEnabled = true;
|
state.autoSaveEnabled = true;
|
||||||
}
|
}
|
||||||
@@ -2986,6 +2998,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
diffWrapLines: state.diffWrapLines,
|
diffWrapLines: state.diffWrapLines,
|
||||||
walkthroughTocWidth: state.walkthroughTocWidth,
|
walkthroughTocWidth: state.walkthroughTocWidth,
|
||||||
gitChangesViewMode: state.gitChangesViewMode,
|
gitChangesViewMode: state.gitChangesViewMode,
|
||||||
|
toolJsonViewMode: state.toolJsonViewMode,
|
||||||
linearIssueListStatus: state.linearIssueListStatus,
|
linearIssueListStatus: state.linearIssueListStatus,
|
||||||
linearIssueListAssignee: state.linearIssueListAssignee,
|
linearIssueListAssignee: state.linearIssueListAssignee,
|
||||||
linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime,
|
linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime,
|
||||||
|
|||||||
@@ -687,6 +687,13 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
result.gitChangesViewMode = mode;
|
result.gitChangesViewMode = mode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
switch (candidate.toolJsonViewMode) {
|
||||||
|
case 'summary':
|
||||||
|
case 'formatted':
|
||||||
|
case 'raw':
|
||||||
|
result.toolJsonViewMode = candidate.toolJsonViewMode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (typeof candidate.directoryShowHidden === 'boolean') {
|
if (typeof candidate.directoryShowHidden === 'boolean') {
|
||||||
result.directoryShowHidden = candidate.directoryShowHidden;
|
result.directoryShowHidden = candidate.directoryShowHidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ describe('settings helpers', () => {
|
|||||||
})).toEqual({});
|
})).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists valid tool JSON view modes', () => {
|
||||||
|
const helpers = createTestHelpers();
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'summary' })).toEqual({ toolJsonViewMode: 'summary' });
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'formatted' })).toEqual({ toolJsonViewMode: 'formatted' });
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'raw' })).toEqual({ toolJsonViewMode: 'raw' });
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'unknown' })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts only booleans for wide chat layout', () => {
|
it('accepts only booleans for wide chat layout', () => {
|
||||||
const helpers = createTestHelpers();
|
const helpers = createTestHelpers();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user