diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index 8ad99303..091b4730 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -20,10 +20,12 @@ import { renderWebSearchOutput, formatInputForDisplay, parseReadToolOutput, + tryParseJsonOutput, } from './toolRenderers'; import type { ToolPopupContent, DiffViewMode } from './types'; import { DiffViewToggle } from './DiffViewToggle'; import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock'; +import { JsonTreeView } from '@/components/ui/JsonTreeView'; interface ToolOutputDialogProps { popup: ToolPopupContent; @@ -1184,6 +1186,18 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange return ; } + // JSON tree viewer for generic JSON outputs + const jsonResult = popup.content ? tryParseJsonOutput(popup.content) : { data: null, isJson: false }; + if (jsonResult.isJson) { + return ( + + ); + } + return ( = ({ output, part, metadata, input, syntaxTheme }) => { const renderedOutput = getToolOutputText(output, part, metadata); const outputLanguage = getToolOutputLanguage(output, part, metadata, input); + const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]); + + if (jsonResult.isJson) { + return ( +
+ +
+ ); + } return (
diff --git a/packages/ui/src/components/chat/message/toolRenderers.tsx b/packages/ui/src/components/chat/message/toolRenderers.tsx index ed6fc995..f234a714 100644 --- a/packages/ui/src/components/chat/message/toolRenderers.tsx +++ b/packages/ui/src/components/chat/message/toolRenderers.tsx @@ -28,6 +28,36 @@ const formatInputForDisplay = (input: Record, toolName?: string return formatToolInput(input, toolName || ''); }; +export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boolean } => { + if (!output || typeof output !== 'string') { + return { data: null, isJson: false }; + } + + const trimmed = output.trim(); + + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return { data: null, isJson: false }; + } + + if (!trimmed.endsWith('}') && !trimmed.endsWith(']')) { + return { data: null, isJson: false }; + } + + if (trimmed.length < 2) { + return { data: null, isJson: false }; + } + + try { + const parsed = JSON.parse(trimmed); + if (parsed !== null && typeof parsed === 'object') { + return { data: parsed, isJson: true }; + } + return { data: null, isJson: false }; + } catch { + return { data: null, isJson: false }; + } +}; + export const formatEditOutput = (output: string, toolName: string, metadata?: Record): string => { let cleaned = cleanOutput(output); diff --git a/packages/ui/src/components/ui/JsonTreeView.tsx b/packages/ui/src/components/ui/JsonTreeView.tsx new file mode 100644 index 00000000..4fc65b77 --- /dev/null +++ b/packages/ui/src/components/ui/JsonTreeView.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'; + +import { Button } from '@/components/ui/button'; +import { JsonTreeViewer } from './JsonTreeViewer'; + +interface JsonTreeViewProps { + jsonString: string; + className?: string; + maxHeight?: string; + initiallyExpandedDepth?: number; +} + +const JsonTreeView = React.memo(function JsonTreeView({ + jsonString, + className, + maxHeight = '100%', + initiallyExpandedDepth = 2, +}: JsonTreeViewProps) { + const viewerRef = React.useRef<{ expandAll: () => void; collapseAll: () => void }>(null); + const [parseError, setParseError] = React.useState(null); + + const parsedData = React.useMemo(() => { + try { + const trimmed = jsonString.trim(); + if (!trimmed) { + setParseError('Empty JSON content'); + return null; + } + const parsed = JSON.parse(trimmed); + setParseError(null); + return parsed; + } catch (err) { + setParseError(err instanceof Error ? err.message : 'Invalid JSON'); + return null; + } + }, [jsonString]); + + const handleExpandAll = React.useCallback(() => { + viewerRef.current?.expandAll(); + }, []); + + const handleCollapseAll = React.useCallback(() => { + viewerRef.current?.collapseAll(); + }, []); + + if (parseError) { + return ( +
+
+
Invalid JSON
+
{parseError}
+
+
+ ); + } + + if (parsedData === null) { + return null; + } + + return ( +
+
+ + +
+
+ +
+
+ ); +}); + +export { JsonTreeView }; +export type { JsonTreeViewProps }; diff --git a/packages/ui/src/components/ui/JsonTreeViewer.tsx b/packages/ui/src/components/ui/JsonTreeViewer.tsx new file mode 100644 index 00000000..e67f2c20 --- /dev/null +++ b/packages/ui/src/components/ui/JsonTreeViewer.tsx @@ -0,0 +1,304 @@ +import React from 'react'; +import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'; +import { useVirtualizer } from '@tanstack/react-virtual'; + +import { + parseJsonToTree, + flattenTree, + getAllExpandableIds, + getExpandableIdsAboveDepth, + type JsonTreeNode, + type FlatJsonNode, +} from '@/lib/jsonTreeUtils'; + +interface JsonTreeViewerProps { + data: unknown; + className?: string; + maxHeight?: string; + initiallyExpandedDepth?: number; + onCopyPath?: (path: string) => void; +} + +const RAINBOW_COLORS = [ + 'var(--syntax-key)', + 'color-mix(in oklch, var(--syntax-key) 85%, var(--syntax-string))', + 'color-mix(in oklch, var(--syntax-key) 70%, var(--syntax-number))', + 'color-mix(in oklch, var(--syntax-key) 55%, var(--syntax-function))', + 'color-mix(in oklch, var(--syntax-key) 40%, var(--syntax-type))', + 'color-mix(in oklch, var(--syntax-key) 30%, var(--syntax-keyword))', +]; + +function getKeyColor(depth: number): string { + return RAINBOW_COLORS[depth % RAINBOW_COLORS.length]; +} + +function getValueColor(node: JsonTreeNode): string { + switch (node.type) { + case 'string': + return 'var(--syntax-string)'; + case 'number': + return 'var(--syntax-number)'; + case 'boolean': + return 'var(--syntax-keyword)'; + case 'null': + return 'var(--syntax-comment)'; + default: + return 'var(--surface-foreground)'; + } +} + +function formatValuePreview(node: JsonTreeNode): string { + switch (node.type) { + case 'string': { + const str = node.value as string; + if (str.length > 60) return `"${str.slice(0, 57)}..."`; + return `"${str}"`; + } + case 'number': + case 'boolean': + return String(node.value); + case 'null': + return 'null'; + case 'object': + return `{${node.childCount ?? 0} ${node.childCount === 1 ? 'item' : 'items'}}`; + case 'array': + return `[${node.childCount ?? 0}]`; + default: + return String(node.value); + } +} + +function getCollapsedPreview(node: JsonTreeNode): string { + if (node.type === 'object') { + const keys = node.children?.map((c) => c.key).slice(0, 3) ?? []; + const suffix = (node.childCount ?? 0) > 3 ? ', ...' : ''; + return `{ ${keys.map((k) => `"${k}": ...`).join(', ')}${suffix} }`; + } + if (node.type === 'array') { + const count = node.childCount ?? 0; + if (count <= 3) { + const items = node.children?.map((c) => formatValuePreview(c)).join(', ') ?? ''; + return `[${items}]`; + } + return `[${count} items]`; + } + return formatValuePreview(node); +} + +const JsonRow = React.memo( + ({ + flatNode, + onToggle, + onCopyPath, + }: { + flatNode: FlatJsonNode; + onToggle: (id: string) => void; + onCopyPath?: (path: string) => void; + }) => { + const { node, isExpanded } = flatNode; + const indent = node.depth * 20; + + const handleToggle = React.useCallback(() => { + onToggle(node.id); + }, [onToggle, node.id]); + + const handleContextMenu = React.useCallback( + (e: React.MouseEvent) => { + if (onCopyPath) { + e.preventDefault(); + onCopyPath(node.id); + } + }, + [onCopyPath, node.id], + ); + + const keyColor = getKeyColor(node.depth); + const valueColor = getValueColor(node); + const isCollapsible = node.isExpandable && node.children && node.children.length > 0; + + return ( +
+ {isCollapsible ? ( + + ) : ( + + )} + + {node.key !== 'root' && ( + <> + + {/^\d+$/.test(node.key) ? node.key : `"${node.key}"`} + + : + + )} + + {node.isExpandable ? ( + isExpanded ? ( + + {node.type === 'object' ? '{' : '['} + + ) : ( + + {getCollapsedPreview(node)} + + ) + ) : ( + {formatValuePreview(node)} + )} +
+ ); + }, +); + +JsonRow.displayName = 'JsonRow'; + +const VIRTUALIZE_THRESHOLD = 200; +const ROW_HEIGHT = 22; + +const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () => void }, JsonTreeViewerProps>( + function JsonTreeViewer( + { data, className, maxHeight = '100%', initiallyExpandedDepth = 2, onCopyPath }, + ref, + ) { + const jsonString = React.useMemo(() => { + try { + return JSON.stringify(data); + } catch { + return null; + } + }, [data]); + + const treeRoot = React.useMemo(() => { + if (!jsonString) return null; + return parseJsonToTree(jsonString); + }, [jsonString]); + + const [collapsedPaths, setCollapsedPaths] = React.useState>(() => { + if (!treeRoot) return new Set(); + return new Set(getExpandableIdsAboveDepth(treeRoot, initiallyExpandedDepth)); + }); + + const flatNodes = React.useMemo( + () => flattenTree(treeRoot, collapsedPaths), + [treeRoot, collapsedPaths], + ); + + const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD; + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: flatNodes.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + enabled: shouldVirtualize, + }); + + const handleToggle = React.useCallback((id: string) => { + setCollapsedPaths((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const expandAll = React.useCallback(() => { + setCollapsedPaths(new Set()); + }, []); + + const collapseAll = React.useCallback(() => { + if (!treeRoot) return; + setCollapsedPaths(new Set(getAllExpandableIds(treeRoot))); + }, [treeRoot]); + + React.useImperativeHandle(ref, () => ({ expandAll, collapseAll }), [expandAll, collapseAll]); + + if (!treeRoot) { + return null; + } + + if (shouldVirtualize) { + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const flatNode = flatNodes[virtualRow.index]; + if (!flatNode) return null; + return ( +
+ +
+ ); + })} +
+
+ ); + } + + return ( +
+ {flatNodes.map((flatNode) => ( + + ))} +
+ ); + }, +); + +export { JsonTreeViewer }; +export type { JsonTreeViewerProps }; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 9b0ae0da..92bf646a 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -24,6 +24,8 @@ import { RiEditLine, RiFileCopyLine, RiFileTransferLine, + RiCodeSSlashLine, + RiNodeTree, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; @@ -40,6 +42,7 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { PreviewToggleButton } from './PreviewToggleButton'; +import { JsonTreeView } from '@/components/ui/JsonTreeView'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension'; import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; @@ -263,6 +266,12 @@ const isMarkdownFile = (path: string): boolean => { return ext === 'md' || ext === 'markdown'; }; +const isJsonFile = (path: string): boolean => { + if (!path) return false; + const ext = path.toLowerCase().split('.').pop(); + return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson'; +}; + const isHtmlFile = (path: string): boolean => { if (!path) return false; const ext = path.toLowerCase().split('.').pop(); @@ -472,6 +481,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit'); const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit'); + const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree'); const [htmlViewMode, setHtmlViewMode] = React.useState<'preview' | 'edit'>('edit'); const lightTheme = React.useMemo( @@ -1639,6 +1649,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path)); + const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path)); const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path)); const isTextFile = Boolean(selectedFile && !isSelectedImage); const canUseShikiFileView = isTextFile && !isMarkdown && !(isHtml && htmlViewMode === 'preview'); @@ -1708,6 +1719,21 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return mdViewMode; }, [mdViewMode]); + const JSON_VIEWER_MODE_KEY = 'openchamber:files:json-viewer-mode'; + + React.useEffect(() => { + try { + const stored = localStorage.getItem(JSON_VIEWER_MODE_KEY); + if (stored === 'tree') { + setJsonViewMode('tree'); + } else if (stored === 'text') { + setJsonViewMode('text'); + } + } catch { + // Ignore localStorage errors + } + }, []); + const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode'; React.useEffect(() => { @@ -1723,6 +1749,15 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } }, []); + const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => { + setJsonViewMode(mode); + try { + localStorage.setItem(JSON_VIEWER_MODE_KEY, mode); + } catch { + // Ignore localStorage errors + } + }, []); + const saveHtmlViewMode = React.useCallback((mode: 'preview' | 'edit') => { setHtmlViewMode(mode); try { @@ -1735,7 +1770,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const getHtmlViewMode = React.useCallback((): 'preview' | 'edit' => { return htmlViewMode; }, [htmlViewMode]); - React.useEffect(() => { if (!pendingFileNavigation || !root) { return; @@ -2285,6 +2319,22 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { /> )} + {isJson && ( + + )} + {canCopy && (
+ ) : selectedFile && isJson && jsonViewMode === 'tree' ? ( + +
JSON viewer unavailable
+
+ Switch to text mode to view raw content. +
+ + } + > +
+ +
+
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
{fileContent.length > 500 * 1024 && ( diff --git a/packages/ui/src/lib/jsonTreeUtils.ts b/packages/ui/src/lib/jsonTreeUtils.ts new file mode 100644 index 00000000..64849671 --- /dev/null +++ b/packages/ui/src/lib/jsonTreeUtils.ts @@ -0,0 +1,222 @@ +/** + * JSON Tree utilities for interactive JSON viewing. + * Provides parsing, tree building, flattening, and path utilities. + */ + +export type JsonTreeNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'; + +export interface JsonTreeNode { + id: string; + key: string; + value: unknown; + type: JsonTreeNodeType; + depth: number; + children?: JsonTreeNode[]; + path: string[]; + isExpandable: boolean; + childCount?: number; +} + +export interface FlatJsonNode { + node: JsonTreeNode; + isExpanded: boolean; +} + +export interface JsonTreeOptions { + maxDepth?: number; + maxNodes?: number; + initiallyExpandedDepth?: number; +} + +const DEFAULT_OPTIONS: Required = { + maxDepth: 50, + maxNodes: 100_000, + initiallyExpandedDepth: 2, +}; + +function getType(value: unknown): JsonTreeNodeType { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'object') return 'object'; + if (typeof value === 'string') return 'string'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'boolean') return 'boolean'; + return 'null'; +} + +export function getNodePath(pathSegments: string[]): string { + if (pathSegments.length === 0) return 'root'; + let result = 'root'; + for (const segment of pathSegments) { + if (/^\d+$/.test(segment)) { + result += `[${segment}]`; + } else { + result += `.${segment}`; + } + } + return result; +} + +export function parseNodePath(pathKey: string): string[] { + if (pathKey === 'root' || pathKey === '') return []; + const withoutRoot = pathKey.startsWith('root.') ? pathKey.slice(5) : pathKey.startsWith('root[') ? pathKey.slice(4) : pathKey; + const segments: string[] = []; + let current = ''; + let i = 0; + while (i < withoutRoot.length) { + const ch = withoutRoot[i]; + if (ch === '.') { + if (current) segments.push(current); + current = ''; + i++; + } else if (ch === '[') { + if (current) segments.push(current); + current = ''; + i++; + let bracket = ''; + while (i < withoutRoot.length && withoutRoot[i] !== ']') { + bracket += withoutRoot[i]; + i++; + } + segments.push(bracket); + i++; + } else { + current += ch; + i++; + } + } + if (current) segments.push(current); + return segments; +} + +let nodeCount = 0; + +function buildTreeNode( + value: unknown, + key: string, + path: string[], + depth: number, + options: Required, +): JsonTreeNode | null { + if (nodeCount >= options.maxNodes) return null; + nodeCount++; + + const type = getType(value); + const id = getNodePath(path); + const isExpandable = type === 'object' || type === 'array'; + + const node: JsonTreeNode = { + id, + key, + value, + type, + depth, + path, + isExpandable, + }; + + if (isExpandable && depth < options.maxDepth) { + const entries = type === 'array' + ? (value as unknown[]).map((v, i) => [String(i), v] as const) + : Object.entries(value as Record); + + node.childCount = entries.length; + node.children = []; + for (const [childKey, childValue] of entries) { + const childPath = [...path, childKey]; + const child = buildTreeNode(childValue, childKey, childPath, depth + 1, options); + if (child) node.children.push(child); + } + } else if (isExpandable) { + node.childCount = type === 'array' + ? (value as unknown[]).length + : Object.keys(value as Record).length; + } + + return node; +} + +export function parseJsonToTree(text: string, options?: JsonTreeOptions): JsonTreeNode | null { + const opts = { ...DEFAULT_OPTIONS, ...options }; + nodeCount = 0; + + try { + const trimmed = text.trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed); + return buildTreeNode(parsed, 'root', [], 0, opts); + } catch { + return null; + } +} + +export function flattenTree(root: JsonTreeNode | null, collapsedPaths: Set): FlatJsonNode[] { + if (!root) return []; + + const result: FlatJsonNode[] = []; + + function walk(node: JsonTreeNode) { + const isExpanded = !collapsedPaths.has(node.id); + result.push({ node, isExpanded }); + + if (node.isExpandable && isExpanded && node.children) { + for (const child of node.children) { + walk(child); + } + } + } + + walk(root); + return result; +} + +export function getAllExpandableIds(root: JsonTreeNode | null): string[] { + if (!root) return []; + const ids: string[] = []; + + function walk(node: JsonTreeNode) { + if (node.isExpandable) { + ids.push(node.id); + if (node.children) { + for (const child of node.children) { + walk(child); + } + } + } + } + + walk(root); + return ids; +} + +export function getExpandableIdsAboveDepth(root: JsonTreeNode | null, maxDepth: number): string[] { + if (!root) return []; + const ids: string[] = []; + + function walk(node: JsonTreeNode) { + if (node.isExpandable && node.depth >= maxDepth) { + ids.push(node.id); + } + if (node.children) { + for (const child of node.children) { + walk(child); + } + } + } + + walk(root); + return ids; +} + +export function isJsonParseable(text: string): boolean { + if (!text || typeof text !== 'string') return false; + const trimmed = text.trim(); + if (trimmed.length < 2) return false; + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false; + try { + JSON.parse(trimmed); + return true; + } catch { + return false; + } +}