feat(frontend): Add expandable view for block output (#10773)

### Need for these changes 💥


https://github.com/user-attachments/assets/5b9007a1-0c49-44c6-9e8b-52bf23eec72c


Users currently cannot view the full output result from a block when
inspecting the Output Data History panel or node previews, as the
content is clipped. This makes debugging and analysis of complex outputs
difficult, forcing users to copy data to external editors. This feature
improves developer efficiency and user experience, especially for blocks
with large or nested responses, and reintroduces a highly requested
functionality that existed previously.

### Changes 🏗️

* **New `ExpandableOutputDialog` component:** Introduced a reusable
modal dialog (`ExpandableOutputDialog.tsx`) designed to display
complete, untruncated output data.
* **`DataTable.tsx` enhancement:** Added an "Expand" button (Maximize2
icon) to each data entry in the Output Data History panel. This button
appears on hover and opens the `ExpandableOutputDialog` for a full view
of the data.
* **`NodeOutputs.tsx` enhancement:** Integrated the "Expand" button into
node output previews, allowing users to view full output data directly
from the node details.
* The `ExpandableOutputDialog` provides a large, scrollable content
area, displaying individual items in organized cards, with options to
copy individual items or all data, along with execution ID and pin name
metadata.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Navigate to an agent session with executed blocks.
  - [x] Open the Output Data History panel.
  - [x] Hover over a data entry to reveal the "Expand" button.
- [x] Click the "Expand" button and verify the `ExpandableOutputDialog`
opens, displaying the full, untruncated content.
  - [x] Verify scrolling works for large outputs within the dialog.
  - [x] Test "Copy Item" and "Copy All" buttons within the dialog.
  - [x] Navigate to a custom node in the graph.
  - [x] Inspect a node's output (if applicable).
  - [x] Hover over the output data to reveal the "Expand" button.
- [x] Click the "Expand" button and verify the `ExpandableOutputDialog`
opens, displaying the full content.

---
Linear Issue:
[OPEN-2593](https://linear.app/autogpt/issue/OPEN-2593/add-expandable-view-for-full-block-output-preview)

<a
href="https://cursor.com/background-agent?bcId=bc-27badeb8-2b49-4286-aa16-8245dfd33bfc">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-cursor-dark.svg">
<source media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-cursor-light.svg">
<img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg">
  </picture>
</a>
<a
href="https://cursor.com/agents?id=bc-27badeb8-2b49-4286-aa16-8245dfd33bfc">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-web-dark.svg">
<source media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-web-light.svg">
    <img alt="Open in Web" src="https://cursor.com/open-in-web.svg">
  </picture>
</a>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <ntindle@users.noreply.github.com>
This commit is contained in:
Nicholas Tindle
2025-09-15 09:19:40 -05:00
committed by GitHub
parent 339ec733cb
commit 5a6978b07d
3 changed files with 371 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
import { beautifyString } from "@/lib/utils";
import { Clipboard } from "lucide-react";
import React from "react";
import { Clipboard, Maximize2 } from "lucide-react";
import React, { useState } from "react";
import { Button } from "./ui/button";
import { ContentRenderer } from "./ui/render";
import {
@@ -12,6 +12,7 @@ import {
TableRow,
} from "./ui/table";
import { useToast } from "./molecules/Toast/use-toast";
import ExpandableOutputDialog from "./ExpandableOutputDialog";
type DataTableProps = {
title?: string;
@@ -25,6 +26,12 @@ export default function DataTable({
data,
}: DataTableProps) {
const { toast } = useToast();
const [expandedDialog, setExpandedDialog] = useState<{
isOpen: boolean;
execId: string;
pinName: string;
data: any[];
} | null>(null);
const copyData = (pin: string, data: string) => {
navigator.clipboard.writeText(data).then(() => {
@@ -35,6 +42,19 @@ export default function DataTable({
});
};
const openExpandedView = (pinName: string, pinData: any[]) => {
setExpandedDialog({
isOpen: true,
execId: title || "Unknown Execution",
pinName,
data: pinData,
});
};
const closeExpandedView = () => {
setExpandedDialog(null);
};
return (
<>
{title && <strong className="mt-2 flex justify-center">{title}</strong>}
@@ -53,26 +73,35 @@ export default function DataTable({
</TableCell>
<TableCell className="cursor-text">
<div className="flex min-h-9 items-center whitespace-pre-wrap">
<Button
className="absolute right-1 top-auto m-1 hidden p-2 group-hover:block"
variant="outline"
size="icon"
onClick={() =>
copyData(
beautifyString(key),
value
.map((i) =>
typeof i === "object"
? JSON.stringify(i, null, 2)
: String(i),
)
.join(", "),
)
}
title="Copy Data"
>
<Clipboard size={18} />
</Button>
<div className="absolute right-1 top-auto m-1 hidden gap-1 group-hover:flex">
<Button
variant="outline"
size="icon"
onClick={() => openExpandedView(key, value)}
title="Expand Full View"
>
<Maximize2 size={18} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() =>
copyData(
beautifyString(key),
value
.map((i) =>
typeof i === "object"
? JSON.stringify(i, null, 2)
: String(i),
)
.join(", "),
)
}
title="Copy Data"
>
<Clipboard size={18} />
</Button>
</div>
{value.map((item, index) => (
<React.Fragment key={index}>
<ContentRenderer
@@ -88,6 +117,16 @@ export default function DataTable({
))}
</TableBody>
</Table>
{expandedDialog && (
<ExpandableOutputDialog
isOpen={expandedDialog.isOpen}
onClose={closeExpandedView}
execId={expandedDialog.execId}
pinName={expandedDialog.pinName}
data={expandedDialog.data}
/>
)}
</>
);
}

View File

@@ -0,0 +1,259 @@
import React, { FC, useMemo, useState } from "react";
import { Button } from "./ui/button";
import { ContentRenderer } from "./ui/render";
import { beautifyString } from "@/lib/utils";
import { Clipboard, Maximize2 } from "lucide-react";
import { useToast } from "./molecules/Toast/use-toast";
import { Switch } from "./atoms/Switch/Switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
import { Separator } from "./ui/separator";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import {
globalRegistry,
OutputItem,
OutputActions,
} from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/OutputRenderers";
import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/OutputRenderers";
interface ExpandableOutputDialogProps {
isOpen: boolean;
onClose: () => void;
execId: string;
pinName: string;
data: any[];
}
const ExpandableOutputDialog: FC<ExpandableOutputDialogProps> = ({
isOpen,
onClose,
execId,
pinName,
data,
}) => {
const { toast } = useToast();
const enableEnhancedOutputHandling = useGetFlag(
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
);
const [useEnhancedRenderer, setUseEnhancedRenderer] = useState(false);
// Prepare items for the enhanced renderer system
const outputItems = useMemo(() => {
if (!data || !useEnhancedRenderer) return [];
const items: Array<{
key: string;
label: string;
value: unknown;
metadata?: OutputMetadata;
renderer: any;
}> = [];
data.forEach((value, index) => {
const metadata: OutputMetadata = {};
// Extract metadata from the value if it's an object
if (
typeof value === "object" &&
value !== null &&
!React.isValidElement(value)
) {
const objValue = value as any;
if (objValue.type) metadata.type = objValue.type;
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
if (objValue.filename) metadata.filename = objValue.filename;
if (objValue.language) metadata.language = objValue.language;
}
const renderer = globalRegistry.getRenderer(value, metadata);
if (renderer) {
items.push({
key: `item-${index}`,
label: index === 0 ? beautifyString(pinName) : "",
value,
metadata,
renderer,
});
} else {
// Fallback to text renderer
const textRenderer = globalRegistry
.getAllRenderers()
.find((r) => r.name === "TextRenderer");
if (textRenderer) {
items.push({
key: `item-${index}`,
label: index === 0 ? beautifyString(pinName) : "",
value:
typeof value === "string"
? value
: JSON.stringify(value, null, 2),
metadata,
renderer: textRenderer,
});
}
}
});
return items;
}, [data, useEnhancedRenderer, pinName]);
const copyData = () => {
const formattedData = data
.map((item) =>
typeof item === "object" ? JSON.stringify(item, null, 2) : String(item),
)
.join("\n\n");
navigator.clipboard.writeText(formattedData).then(() => {
toast({
title: `"${beautifyString(pinName)}" data copied to clipboard!`,
duration: 2000,
});
});
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] w-[90vw] max-w-4xl flex-col">
<DialogHeader>
<DialogTitle className="flex items-center justify-between pr-8">
<div className="flex items-center gap-2">
<Maximize2 size={20} />
Full Output Preview
</div>
{enableEnhancedOutputHandling && (
<div className="flex items-center gap-3">
<label
htmlFor="enhanced-rendering-toggle"
className="cursor-pointer select-none text-sm font-normal text-gray-600"
>
Enhanced Rendering
</label>
<Switch
id="enhanced-rendering-toggle"
checked={useEnhancedRenderer}
onCheckedChange={setUseEnhancedRenderer}
/>
</div>
)}
</DialogTitle>
<DialogDescription>
Execution ID: <span className="font-mono text-xs">{execId}</span>
<br />
Pin:{" "}
<span className="font-semibold">{beautifyString(pinName)}</span>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
{useEnhancedRenderer && outputItems.length > 0 && (
<div className="border-b px-4 py-2">
<OutputActions
items={outputItems.map((item) => ({
value: item.value,
metadata: item.metadata,
renderer: item.renderer,
}))}
/>
</div>
)}
<ScrollArea className="h-full">
<div className="p-4">
{data.length > 0 ? (
useEnhancedRenderer ? (
<div className="space-y-4">
{outputItems.map((item) => (
<OutputItem
key={item.key}
value={item.value}
metadata={item.metadata}
renderer={item.renderer}
label={item.label}
/>
))}
</div>
) : (
<div className="space-y-4">
{data.map((item, index) => (
<div
key={index}
className="rounded-lg border bg-gray-50 p-4"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-600">
Item {index + 1} of {data.length}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
const itemData =
typeof item === "object"
? JSON.stringify(item, null, 2)
: String(item);
navigator.clipboard
.writeText(itemData)
.then(() => {
toast({
title: `Item ${index + 1} copied to clipboard!`,
duration: 2000,
});
});
}}
className="flex items-center gap-1"
>
<Clipboard size={14} />
Copy Item
</Button>
</div>
<Separator className="mb-3" />
<div className="whitespace-pre-wrap break-words font-mono text-sm">
<ContentRenderer
value={item}
truncateLongData={false}
/>
</div>
</div>
))}
</div>
)
) : (
<div className="py-8 text-center text-gray-500">
No data available
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter className="flex justify-between">
<div className="text-sm text-gray-600">
{data.length} item{data.length !== 1 ? "s" : ""} total
</div>
<div className="flex gap-2">
{!useEnhancedRenderer && (
<Button
variant="outline"
onClick={copyData}
className="flex items-center gap-1"
>
<Clipboard size={16} />
Copy All
</Button>
)}
<Button onClick={onClose}>Close</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ExpandableOutputDialog;

View File

@@ -1,7 +1,10 @@
import React from "react";
import React, { useState } from "react";
import { ContentRenderer } from "./ui/render";
import { beautifyString } from "@/lib/utils";
import { Maximize2 } from "lucide-react";
import { Button } from "./ui/button";
import * as Separator from "@radix-ui/react-separator";
import ExpandableOutputDialog from "./ExpandableOutputDialog";
type NodeOutputsProps = {
title?: string;
@@ -14,14 +17,47 @@ export default function NodeOutputs({
truncateLongData,
data,
}: NodeOutputsProps) {
const [expandedDialog, setExpandedDialog] = useState<{
isOpen: boolean;
execId: string;
pinName: string;
data: any[];
} | null>(null);
const openExpandedView = (pinName: string, pinData: any[]) => {
setExpandedDialog({
isOpen: true,
execId: title || "Node Output",
pinName,
data: pinData,
});
};
const closeExpandedView = () => {
setExpandedDialog(null);
};
return (
<div className="m-4 space-y-4">
{title && <strong className="mt-2flex">{title}</strong>}
{Object.entries(data).map(([pin, dataArray]) => (
<div key={pin} className="">
<div className="flex items-center">
<strong className="mr-2">Pin:</strong>
<span>{beautifyString(pin)}</span>
<div key={pin} className="group">
<div className="flex items-center justify-between">
<div className="flex items-center">
<strong className="mr-2">Pin:</strong>
<span>{beautifyString(pin)}</span>
</div>
{(truncateLongData || dataArray.length > 10) && (
<Button
variant="outline"
size="sm"
onClick={() => openExpandedView(pin, dataArray)}
className="hidden items-center gap-1 group-hover:flex"
title="Expand Full View"
>
<Maximize2 size={14} />
Expand
</Button>
)}
</div>
<div className="mt-2">
<strong className="mr-2">Data:</strong>
@@ -48,6 +84,16 @@ export default function NodeOutputs({
</div>
</div>
))}
{expandedDialog && (
<ExpandableOutputDialog
isOpen={expandedDialog.isOpen}
onClose={closeExpandedView}
execId={expandedDialog.execId}
pinName={expandedDialog.pinName}
data={expandedDialog.data}
/>
)}
</div>
);
}