Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 158 additions & 29 deletions agentex-ui/components/agentex/json-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
'use client';

import { useState } from 'react';
import { useMemo, useEffect, useState, useCallback } from 'react';

import { cva } from 'class-variance-authority';
import { ChevronDown, ChevronRight } from 'lucide-react';
import {
ChevronDown,
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
} from 'lucide-react';

import { CopyButton } from '@/components/agentex/copy-button';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

type JsonValue =
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };

const URL_REGEX =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;

const valueStyles = cva('', {
variants: {
type: {
Expand All @@ -39,27 +48,80 @@ function serializeValue(data: JsonValue): string {
return String(data);
}

function LinkifiedString({ value }: { value: string }) {
const parts: (string | React.ReactElement)[] = [];
let lastIndex = 0;

const matches = value.matchAll(URL_REGEX);

for (const match of matches) {
if (match.index !== undefined && match.index > lastIndex) {
parts.push(value.substring(lastIndex, match.index));
}

const url = match[0];
parts.push(
<a
key={match.index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="underline"
onClick={e => e.stopPropagation()}
>
{url}
</a>
);

lastIndex = (match.index ?? 0) + url.length;
}

if (lastIndex < value.length) {
parts.push(value.substring(lastIndex));
}

if (parts.length === 0) {
return <>{value}</>;
}

return <>{parts}</>;
}

interface JsonCollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
copyContent: string;
collapsedContent: React.ReactNode;
expandedContent: React.ReactNode;
defaultExpanded?: boolean;
shouldBeExpanded?: boolean;
forceExpandState?: boolean | null;
keyName?: string | undefined;
extraButtons?: React.ReactNode;
showCopyButton?: boolean;
}

function JsonCollapsible({
copyContent,
collapsedContent,
expandedContent,
defaultExpanded = false,
shouldBeExpanded = false,
forceExpandState = null,
keyName,
extraButtons,
showCopyButton = true,
...props
}: JsonCollapsibleProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [isExpanded, setIsExpanded] = useState(() =>
forceExpandState !== null ? forceExpandState : shouldBeExpanded
Comment thread
declan-scale marked this conversation as resolved.
);

useEffect(() => {
if (forceExpandState !== null) {
setIsExpanded(forceExpandState);
}
}, [forceExpandState]);

return (
<div {...props}>
<div className="hover:bg-accent/50 group/line flex items-center gap-2 rounded px-2 py-0.5">
<div className="hover:bg-accent/50 group/line flex items-center gap-2 rounded px-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex flex-1 items-center gap-1 font-mono text-sm"
Expand All @@ -76,8 +138,13 @@ function JsonCollapsible({
)}
<span className="text-muted-foreground">{collapsedContent}</span>
</button>
<div className="opacity-0 transition-opacity group-hover/line:opacity-100">
<CopyButton content={copyContent} />
<div className="flex items-center gap-2">
{extraButtons}
{showCopyButton && (
<div className="opacity-0 transition-opacity group-hover/line:opacity-100">
<CopyButton content={copyContent} />
</div>
)}
</div>
</div>
{isExpanded && <div>{expandedContent}</div>}
Expand All @@ -89,26 +156,29 @@ interface JsonNodeProps {
data: JsonValue;
keyName?: string;
level?: number;
defaultExpanded?: boolean;
currentDepth?: number;
maxOpenDepth?: number;
forceExpandState?: boolean | null;
extraButtons?: React.ReactNode;
}

function JsonNode({
data,
keyName,
level = 0,
defaultExpanded = false,
currentDepth = 0,
maxOpenDepth = 0,
forceExpandState = null,
extraButtons,
}: JsonNodeProps) {
// Try to parse JSON strings
let parsedData = data;
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
if (typeof parsed === 'object' && parsed !== null) {
parsedData = parsed;
}
} catch {
// Not valid JSON, keep as string
}
} catch {}
}

const copyContent = keyName
Expand All @@ -117,8 +187,9 @@ function JsonNode({

const indentClassName = level > 0 ? 'ml-4' : '';
const [isExpanded, setIsExpanded] = useState(false);
const shouldExpand = maxOpenDepth < 0 || currentDepth < maxOpenDepth;

let content = null;
let content: React.ReactNode = null;
let dataType:
| 'string'
| 'number'
Expand All @@ -128,7 +199,6 @@ function JsonNode({
| 'array'
| null = null;

// Render arrays
if (Array.isArray(parsedData) && parsedData.length > 0) {
return (
<JsonCollapsible
Expand All @@ -140,16 +210,20 @@ function JsonNode({
key={index}
data={item}
level={level + 1}
defaultExpanded={defaultExpanded}
currentDepth={currentDepth + 1}
maxOpenDepth={maxOpenDepth}
forceExpandState={forceExpandState}
/>
))}
defaultExpanded={defaultExpanded}
shouldBeExpanded={shouldExpand}
forceExpandState={forceExpandState}
extraButtons={extraButtons}
showCopyButton={!extraButtons}
className={indentClassName}
/>
);
}

// Render objects
if (
typeof parsedData === 'object' &&
parsedData !== null &&
Expand All @@ -174,23 +248,33 @@ function JsonNode({
data={value}
keyName={key}
level={level + 1}
defaultExpanded={defaultExpanded}
currentDepth={currentDepth + 1}
maxOpenDepth={maxOpenDepth}
forceExpandState={forceExpandState}
/>
))}
defaultExpanded={defaultExpanded}
shouldBeExpanded={shouldExpand}
forceExpandState={forceExpandState}
extraButtons={extraButtons}
showCopyButton={!extraButtons}
className={indentClassName}
/>
);
}

// Check if string is long (more than 6 lines worth of characters, ~80 chars per line)
const isLongString =
typeof parsedData === 'string' && parsedData.length > 480;

switch (typeof parsedData) {
case 'string':
dataType = 'string';
content = `"${parsedData}"`;
content = (
<>
&quot;
<LinkifiedString value={parsedData} />
&quot;
</>
);
break;
case 'number':
dataType = 'number';
Expand Down Expand Up @@ -220,7 +304,7 @@ function JsonNode({
return (
<div
className={cn(
'hover:bg-accent/50 group/line flex items-center gap-2 rounded px-2 py-0.5',
'hover:bg-accent/50 group/line flex items-center gap-2 rounded px-2',
indentClassName
)}
>
Expand All @@ -231,7 +315,6 @@ function JsonNode({
)}
onClick={e => {
if (isLongString) {
// Only toggle if not clicking the copy button
const target = e.target as HTMLElement;
if (!target.closest('button')) {
setIsExpanded(!isExpanded);
Expand Down Expand Up @@ -259,23 +342,69 @@ function JsonNode({

interface JsonViewerProps {
data: JsonValue;
defaultExpanded?: boolean;
defaultOpenDepth?: number;
className?: string;
}

export function JsonViewer({
data,
defaultExpanded = false,
defaultOpenDepth = 0,
className,
}: JsonViewerProps) {
const [forceExpandState, setForceExpandState] = useState<boolean | null>(
null
);

const shouldShowExpand = useMemo(() => {
return forceExpandState === null
? defaultOpenDepth === 0
: !forceExpandState;
}, [forceExpandState, defaultOpenDepth]);

const toggleForceExpandState = useCallback(() => {
setForceExpandState(shouldShowExpand);
}, [shouldShowExpand]);

const extraButtons = useMemo(() => {
return (
<>
<Button
variant="ghost"
size="sm"
onClick={toggleForceExpandState}
className="text-muted-foreground hover:text-foreground h-6 gap-1.5 text-xs"
>
{shouldShowExpand ? (
<>
<ChevronsUpDown className="h-3 w-3" />
Expand All
</>
) : (
<>
<ChevronsDownUp className="h-3 w-3" />
Collapse All
</>
)}
</Button>
<CopyButton content={JSON.stringify(data, null, 2)} />
</>
);
}, [data, shouldShowExpand, toggleForceExpandState]);

return (
<div
className={cn(
'bg-muted/30 overflow-auto rounded-md border p-3',
className
)}
>
<JsonNode data={data} defaultExpanded={defaultExpanded} />
<JsonNode
data={data}
currentDepth={0}
maxOpenDepth={defaultOpenDepth}
forceExpandState={forceExpandState}
extraButtons={extraButtons}
/>
</div>
);
}
14 changes: 6 additions & 8 deletions agentex-ui/components/agentex/task-message-data-content.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useMemo } from 'react';

import { cva } from 'class-variance-authority';

import { CodeBlock } from '@/components/ai-elements/code-block';
import { JsonViewer, type JsonValue } from '@/components/agentex/json-viewer';
import { cn } from '@/lib/utils';

import type { DataContent } from 'agentex/resources';
Expand All @@ -25,13 +23,13 @@ function TaskMessageDataContentComponent({
content,
key,
}: TaskMessageDataContentComponentProps) {
const dataString = useMemo(
() => JSON.stringify(content.data, null, 2),
[content.data]
);
return (
<div className={cn(variants({ author: content.author }))}>
<CodeBlock key={key} language="json" code={dataString} />
<JsonViewer
key={key}
data={content.data as JsonValue}
defaultOpenDepth={1}
/>
</div>
);
}
Expand Down
Loading