-
-
-
- {expanded && command ? (
-
{command}
- ) : null}
-
+
+
+ {expanded && command ? (
+
+
+ bash
+
+
+ {command}
+
+
+ ) : null}
);
}
@@ -305,38 +411,6 @@ function ThinkingRow() {
);
}
-const AGENT_MARKDOWN_CLASSES =
- "max-w-none [&_h1]:mb-1.5 [&_h1]:mt-1 [&_h1]:text-base [&_h1]:font-semibold [&_h1:first-child]:mt-0 " +
- "[&_h2]:mb-1 [&_h2]:mt-1 [&_h2]:text-sm [&_h2]:font-semibold [&_h2:first-child]:mt-0 " +
- "[&_h3]:mb-0.5 [&_h3]:mt-1 [&_h3]:text-sm [&_h3]:font-semibold [&_h3:first-child]:mt-0 " +
- "[&_p]:mb-2 [&_p]:leading-relaxed [&_p:last-child]:mb-0 " +
- "[&_ol]:mb-2 [&_ol]:ml-5 [&_ol]:list-decimal [&_ul]:mb-2 [&_ul]:ml-5 [&_ul]:list-disc [&_li]:mb-0.5 " +
- "[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_blockquote]:pl-3 " +
- "[&_code]:rounded [&_code]:bg-slate-200/70 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs " +
- "[&_pre]:my-2 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-slate-200/70 [&_pre]:p-2 " +
- "[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
- "[&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-current";
-
-function AgentMarkdown({ content }: { content: string }) {
- if (!content) return null;
- return (
-
- );
-}
-
function statusLabel(status: string): string {
switch (status) {
case "streaming":
diff --git a/web_src/src/components/AgentSidebar/widgets/BannerWidget.tsx b/web_src/src/components/AgentSidebar/widgets/BannerWidget.tsx
new file mode 100644
index 0000000000..4b68e02da1
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/BannerWidget.tsx
@@ -0,0 +1,28 @@
+import { CheckCircle2, XCircle } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface BannerWidgetProps {
+ variant: "success" | "error";
+ content: string;
+}
+
+export function BannerWidget({ variant, content }: BannerWidgetProps) {
+ const isSuccess = variant === "success";
+ return (
+
+ {isSuccess ? (
+
+ ) : (
+
+ )}
+
{content}
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/ButtonsWidget.tsx b/web_src/src/components/AgentSidebar/widgets/ButtonsWidget.tsx
new file mode 100644
index 0000000000..522bcebc74
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/ButtonsWidget.tsx
@@ -0,0 +1,35 @@
+import { Button } from "@/components/ui/button";
+
+interface ButtonsWidgetProps {
+ prompt: string;
+ items: string[];
+ onAction?: (text: string) => void;
+}
+
+export function ButtonsWidget({ prompt, items, onAction }: ButtonsWidgetProps) {
+ return (
+
+ {prompt && (
+
+ )}
+
+ {items.map((item, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/ChartWidget.stories.tsx b/web_src/src/components/AgentSidebar/widgets/ChartWidget.stories.tsx
new file mode 100644
index 0000000000..daa23b139e
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/ChartWidget.stories.tsx
@@ -0,0 +1,115 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ChartWidget } from "./ChartWidget";
+
+const meta: Meta
= {
+ title: "AgentSidebar/Charts",
+ component: ChartWidget,
+ parameters: {
+ layout: "padded",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const RunSuccessRate: Story = {
+ args: {
+ config: {
+ type: "line",
+ title: "Run Success Rate (Last 7 Days)",
+ x: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
+ series: [
+ { name: "Successful", data: [12, 15, 11, 14, 13, 8, 10], color: "#22c55e" },
+ { name: "Failed", data: [2, 1, 3, 0, 2, 1, 0], color: "#ef4444" },
+ ],
+ },
+ },
+};
+
+export const ExecutionsPerNode: Story = {
+ args: {
+ config: {
+ type: "bar",
+ title: "Executions Per Node (Last 24h)",
+ x: ["Webhook", "API Call", "Check Status", "Notify OK", "Notify Fail"],
+ series: [{ name: "Executions", data: [48, 48, 48, 41, 7], color: "#8b5cf6" }],
+ },
+ },
+};
+
+export const RunOutcomes: Story = {
+ args: {
+ config: {
+ type: "pie",
+ title: "Run Outcomes (Last 30 Days)",
+ data: [
+ { name: "Success", value: 312, color: "#22c55e" },
+ { name: "Failed", value: 28, color: "#ef4444" },
+ { name: "Timed Out", value: 8, color: "#f59e0b" },
+ { name: "Cancelled", value: 3, color: "#94a3b8" },
+ ],
+ },
+ },
+};
+
+export const LatencyOverTime: Story = {
+ args: {
+ config: {
+ type: "area",
+ title: "Avg Execution Latency (ms)",
+ x: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00", "24:00"],
+ series: [
+ { name: "P50", data: [120, 115, 180, 220, 195, 160, 130], color: "#8b5cf6" },
+ { name: "P95", data: [450, 420, 680, 890, 750, 520, 480], color: "#f59e0b" },
+ { name: "P99", data: [1200, 980, 1500, 2100, 1800, 1100, 950], color: "#ef4444" },
+ ],
+ },
+ },
+};
+
+export const WeeklyRunVolume: Story = {
+ args: {
+ config: {
+ type: "bar",
+ title: "Weekly Run Volume (4 Weeks)",
+ x: ["Week 1", "Week 2", "Week 3", "Week 4"],
+ series: [
+ { name: "Success", data: [72, 85, 81, 90], color: "#22c55e" },
+ { name: "Failed", data: [6, 7, 7, 3], color: "#ef4444" },
+ ],
+ },
+ },
+};
+
+export const NodeFailureRate: Story = {
+ args: {
+ config: {
+ type: "bar",
+ title: "Node Failure Rate (%)",
+ x: ["SSH Deploy", "API Health", "Slack Notify", "DB Backup", "DNS Update"],
+ series: [{ name: "Failure %", data: [12.5, 4.2, 1.1, 8.7, 0.3], color: "#ef4444" }],
+ },
+ },
+};
+
+export const MultiSeriesLine: Story = {
+ args: {
+ config: {
+ type: "line",
+ title: "Canvas Activity (Last 14 Days)",
+ x: ["D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D13", "D14"],
+ series: [
+ { name: "Triggers", data: [24, 31, 28, 35, 42, 38, 22, 19, 33, 41, 45, 39, 37, 44], color: "#8b5cf6" },
+ { name: "Actions", data: [72, 93, 84, 105, 126, 114, 66, 57, 99, 123, 135, 117, 111, 132], color: "#06b6d4" },
+ { name: "Failures", data: [3, 2, 5, 1, 4, 2, 1, 0, 3, 2, 6, 1, 2, 3], color: "#ef4444" },
+ ],
+ },
+ },
+};
diff --git a/web_src/src/components/AgentSidebar/widgets/ChartWidget.tsx b/web_src/src/components/AgentSidebar/widgets/ChartWidget.tsx
new file mode 100644
index 0000000000..f6a5f585f6
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/ChartWidget.tsx
@@ -0,0 +1,146 @@
+import {
+ Bar,
+ BarChart,
+ Line,
+ LineChart,
+ Area,
+ AreaChart,
+ Pie,
+ PieChart,
+ Cell,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+} from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ type ChartConfig as ShadcnChartConfig,
+} from "@/components/ui/chart";
+import type { ChartConfig } from "./parser";
+
+const DEFAULT_COLORS = ["#8b5cf6", "#06b6d4", "#22c55e", "#f59e0b", "#ef4444", "#ec4899"];
+
+interface ChartWidgetProps {
+ config: ChartConfig;
+}
+
+export function ChartWidget({ config }: ChartWidgetProps) {
+ if (config.type === "pie") {
+ return ;
+ }
+ return ;
+}
+
+function XYChartWidget({ config }: { config: ChartConfig }) {
+ const { type, title, x, series } = config;
+ if (!x || !series?.length) {
+ return Chart: missing data
;
+ }
+
+ const data = x.map((label, i) => {
+ const point: Record = { x: label };
+ for (const s of series) {
+ point[s.name] = s.data[i] ?? 0;
+ }
+ return point;
+ });
+
+ const chartConfig: ShadcnChartConfig = {};
+ series.forEach((s, i) => {
+ chartConfig[s.name] = {
+ label: s.name,
+ color: s.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length],
+ };
+ });
+
+ const ChartComponent = type === "bar" ? BarChart : type === "area" ? AreaChart : LineChart;
+
+ return (
+
+ {title &&
{title}
}
+
+
+
+
+
+ } />
+ } />
+ {series.map((s, i) => {
+ const color = s.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length];
+ if (type === "bar") {
+ return ;
+ }
+ if (type === "area") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+function PieChartWidget({ config }: { config: ChartConfig }) {
+ const { title, data } = config;
+ if (!data?.length) {
+ return Pie chart: missing data
;
+ }
+
+ const chartConfig: ShadcnChartConfig = {};
+ data.forEach((d, i) => {
+ chartConfig[d.name] = {
+ label: d.name,
+ color: d.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length],
+ };
+ });
+
+ return (
+
+ {title &&
{title}
}
+
+
+ } />
+ `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
+ labelLine={{ strokeWidth: 1 }}
+ fontSize={11}
+ >
+ {data.map((entry, i) => (
+ |
+ ))}
+
+
+
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/CodeBlockWidget.tsx b/web_src/src/components/AgentSidebar/widgets/CodeBlockWidget.tsx
new file mode 100644
index 0000000000..dc5c54340f
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/CodeBlockWidget.tsx
@@ -0,0 +1,135 @@
+import { Check, Copy, Maximize2 } from "lucide-react";
+import { useCallback, useState } from "react";
+import Editor from "@monaco-editor/react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
+interface CodeBlockWidgetProps {
+ code: string;
+ language?: string;
+}
+
+const MONACO_OPTIONS = {
+ readOnly: true,
+ minimap: { enabled: false },
+ fontSize: 12,
+ lineNumbers: "off" as const,
+ wordWrap: "on" as const,
+ folding: true,
+ scrollBeyondLastLine: false,
+ renderWhitespace: "none" as const,
+ contextmenu: false,
+ cursorStyle: "line" as const,
+ scrollbar: {
+ vertical: "auto" as const,
+ horizontal: "auto" as const,
+ },
+ padding: { top: 8, bottom: 8 },
+ overviewRulerLanes: 0,
+ hideCursorInOverviewRuler: true,
+ overviewRulerBorder: false,
+ guides: { indentation: false },
+ renderLineHighlight: "none" as const,
+};
+
+function mapLanguage(lang?: string): string {
+ const map: Record = {
+ yml: "yaml",
+ sh: "shell",
+ bash: "shell",
+ zsh: "shell",
+ js: "javascript",
+ ts: "typescript",
+ py: "python",
+ rb: "ruby",
+ dockerfile: "dockerfile",
+ tf: "hcl",
+ };
+ if (!lang) return "plaintext";
+ return map[lang.toLowerCase()] || lang.toLowerCase();
+}
+
+function calcHeight(code: string, maxPx = 250): number {
+ const lineCount = code.split("\n").length;
+ const lineHeight = 19;
+ return Math.min(Math.max(lineCount * lineHeight + 16, 60), maxPx);
+}
+
+export function CodeBlockWidget({ code, language }: CodeBlockWidgetProps) {
+ const [copied, setCopied] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const monacoLang = mapLanguage(language);
+
+ const handleCopy = useCallback(async () => {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }, [code]);
+
+ const height = calcHeight(code);
+
+ return (
+ <>
+
+
+
{language || "code"}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/CollapseWidget.tsx b/web_src/src/components/AgentSidebar/widgets/CollapseWidget.tsx
new file mode 100644
index 0000000000..aac1c6015c
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/CollapseWidget.tsx
@@ -0,0 +1,30 @@
+import { ChevronRight } from "lucide-react";
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+
+interface CollapseWidgetProps {
+ title: string;
+ content: string;
+}
+
+export function CollapseWidget({ title, content }: CollapseWidgetProps) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+ {open && (
+
+ )}
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/ConfirmWidget.tsx b/web_src/src/components/AgentSidebar/widgets/ConfirmWidget.tsx
new file mode 100644
index 0000000000..0f2318ab2f
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/ConfirmWidget.tsx
@@ -0,0 +1,28 @@
+import { AlertTriangle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface ConfirmWidgetProps {
+ message: string;
+ yes: string;
+ no: string;
+ onAction?: (text: string) => void;
+}
+
+export function ConfirmWidget({ message, yes, no, onAction }: ConfirmWidgetProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/MermaidWidget.stories.tsx b/web_src/src/components/AgentSidebar/widgets/MermaidWidget.stories.tsx
new file mode 100644
index 0000000000..70e480ec6c
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/MermaidWidget.stories.tsx
@@ -0,0 +1,185 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { MermaidWidget } from "./MermaidWidget";
+
+const meta: Meta = {
+ title: "AgentSidebar/Mermaid",
+ component: MermaidWidget,
+ parameters: {
+ layout: "padded",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const CanvasFlowchart: Story = {
+ args: {
+ content: `flowchart LR
+ A[Webhook Trigger] --> B[Call API]
+ B --> C{Check Status}
+ C -->|200 OK| D[Notify Success]
+ C -->|Error| E[Notify Failure]`,
+ },
+};
+
+export const DeployPipeline: Story = {
+ args: {
+ content: `flowchart TD
+ A[GitHub Push] --> B[Build Image]
+ B --> C[Run Tests]
+ C -->|Pass| D[Deploy to Staging]
+ C -->|Fail| E[Notify Team]
+ D --> F[Run Smoke Tests]
+ F -->|Pass| G[Deploy to Production]
+ F -->|Fail| H[Rollback]`,
+ },
+};
+
+export const SequenceDiagram: Story = {
+ args: {
+ content: `sequenceDiagram
+ participant User
+ participant SuperPlane
+ participant API
+ participant Slack
+
+ User->>SuperPlane: Trigger Webhook
+ SuperPlane->>API: GET /health
+ API-->>SuperPlane: 200 OK
+ SuperPlane->>Slack: Post Success Message
+ Slack-->>User: Notification`,
+ },
+};
+
+export const NodeStateDiagram: Story = {
+ args: {
+ content: `stateDiagram-v2
+ [*] --> Pending
+ Pending --> Running: Trigger Received
+ Running --> Success: Execution Complete
+ Running --> Failed: Error Occurred
+ Failed --> Running: Retry
+ Success --> [*]
+ Failed --> [*]: Max Retries`,
+ },
+};
+
+export const CanvasTopology: Story = {
+ args: {
+ content: `flowchart LR
+ subgraph Triggers
+ T1[Schedule: Every 5min]
+ T2[Webhook: /deploy]
+ end
+ subgraph Actions
+ A1[HTTP: Health Check]
+ A2[SSH: Deploy Script]
+ A3[If: Status == 200]
+ end
+ subgraph Notifications
+ N1[Slack: #ops]
+ N2[Email: on-call@team.com]
+ end
+
+ T1 --> A1
+ T2 --> A2
+ A1 --> A3
+ A3 -->|true| N1
+ A3 -->|false| N2
+ A2 --> N1`,
+ },
+};
+
+export const GitGraph: Story = {
+ args: {
+ content: `gitGraph
+ commit id: "init"
+ commit id: "add trigger"
+ branch feature/api-node
+ checkout feature/api-node
+ commit id: "add HTTP node"
+ commit id: "add error handling"
+ checkout main
+ merge feature/api-node
+ commit id: "add notifications"
+ branch fix/timeout
+ checkout fix/timeout
+ commit id: "bump timeout to 30s"
+ checkout main
+ merge fix/timeout
+ commit id: "v1.0 release" tag: "v1.0"`,
+ },
+};
+
+export const GanttChart: Story = {
+ args: {
+ content: `gantt
+ title Canvas Build Timeline
+ dateFormat YYYY-MM-DD
+ axisFormat %b %d
+
+ section Setup
+ Install CLI :done, setup1, 2026-05-01, 1d
+ Connect to API :done, setup2, after setup1, 1d
+
+ section Build
+ Create trigger node :done, build1, after setup2, 2d
+ Add API health check :done, build2, after build1, 2d
+ Add branching logic :active, build3, after build2, 2d
+ Add notifications :build4, after build3, 3d
+
+ section Deploy
+ Staging deploy :deploy1, after build4, 1d
+ Production deploy :deploy2, after deploy1, 1d`,
+ },
+};
+
+export const XYChart: Story = {
+ args: {
+ content: `xychart-beta
+ title "Run Duration by Node (ms)"
+ x-axis ["Webhook", "API Call", "If Check", "SSH Deploy", "Notify"]
+ y-axis "Duration (ms)" 0 --> 5000
+ bar [120, 2400, 50, 4200, 350]
+ line [120, 2400, 50, 4200, 350]`,
+ },
+};
+
+export const Timeline: Story = {
+ args: {
+ content: `timeline
+ title Canvas Evolution
+ section v0.1 - MVP
+ Webhook trigger : Basic HTTP listener
+ Single API call : GET health endpoint
+ section v0.2 - Branching
+ If node added : Status code routing
+ Success path : Slack notification
+ Failure path : Email alert
+ section v0.3 - Reliability
+ Retry logic : 3 attempts with backoff
+ Timeout config : 30s per node
+ SSH deploy : Remote script execution
+ section v1.0 - Production
+ Monitoring : Run analytics dashboard
+ Approvals : Manual gate before deploy
+ Scheduling : Cron-based triggers`,
+ },
+};
+
+export const PieChart: Story = {
+ args: {
+ content: `pie title Run Outcomes (Last 7 Days)
+ "Success" : 312
+ "Failed" : 28
+ "Timed Out" : 8
+ "Cancelled" : 3`,
+ },
+};
diff --git a/web_src/src/components/AgentSidebar/widgets/MermaidWidget.tsx b/web_src/src/components/AgentSidebar/widgets/MermaidWidget.tsx
new file mode 100644
index 0000000000..06e00b40ad
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/MermaidWidget.tsx
@@ -0,0 +1,194 @@
+import { useEffect, useId, useRef, useState, useCallback } from "react";
+import mermaid from "mermaid";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
+mermaid.initialize({
+ startOnLoad: false,
+ theme: "base",
+ securityLevel: "strict",
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
+ themeVariables: {
+ primaryColor: "#ede9fe",
+ primaryTextColor: "#4c1d95",
+ primaryBorderColor: "#8b5cf6",
+ secondaryColor: "#ecfeff",
+ secondaryTextColor: "#164e63",
+ secondaryBorderColor: "#06b6d4",
+ tertiaryColor: "#fffbeb",
+ tertiaryTextColor: "#78350f",
+ tertiaryBorderColor: "#f59e0b",
+ lineColor: "#94a3b8",
+ textColor: "#334155",
+ nodeBorder: "#8b5cf6",
+ nodeTextColor: "#1e293b",
+ clusterBkg: "#f8fafc",
+ clusterBorder: "#e2e8f0",
+ defaultLinkColor: "#8b5cf6",
+ fontSize: "13px",
+ },
+});
+
+interface MermaidWidgetProps {
+ content: string;
+}
+
+export function MermaidWidget({ content }: MermaidWidgetProps) {
+ const id = useId().replace(/:/g, "m");
+ const containerRef = useRef(null);
+ const [svg, setSvg] = useState(null);
+ const [error, setError] = useState(null);
+ const [expanded, setExpanded] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function render() {
+ try {
+ const { svg: rendered } = await mermaid.render(`mermaid-${id}`, content.trim());
+ if (!cancelled) {
+ setSvg(rendered);
+ setError(null);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : "Failed to render diagram");
+ setSvg(null);
+ }
+ document.getElementById(`dmermaid-${id}`)?.remove();
+ }
+ }
+
+ render();
+ return () => {
+ cancelled = true;
+ };
+ }, [content, id]);
+
+ if (error) {
+ return (
+
+
Diagram error
+
{content.trim()}
+
+ );
+ }
+
+ if (!svg) {
+ return (
+ Rendering diagram...
+ );
+ }
+
+ return (
+ <>
+ setExpanded(true)}
+ className="my-4 rounded-lg border border-violet-200 bg-white p-3 shadow-sm overflow-x-auto cursor-pointer hover:border-violet-300 transition-colors [&_svg]:max-w-full [&_svg]:h-auto [&_svg]:mx-auto"
+ >
+
+
+
+
+ >
+ );
+}
+
+function MermaidPanZoom({ svg }: { svg: string }) {
+ const containerRef = useRef(null);
+ const [scale, setScale] = useState(2.5);
+ const [translate, setTranslate] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<{ startX: number; startY: number; startTx: number; startTy: number } | null>(null);
+
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
+ setScale((prev) => Math.min(Math.max(prev * delta, 0.2), 5));
+ }, []);
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ dragRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ startTx: translate.x,
+ startTy: translate.y,
+ };
+ },
+ [translate],
+ );
+
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
+ if (!dragRef.current) return;
+ setTranslate({
+ x: dragRef.current.startTx + (e.clientX - dragRef.current.startX),
+ y: dragRef.current.startTy + (e.clientY - dragRef.current.startY),
+ });
+ }, []);
+
+ const handleMouseUp = useCallback(() => {
+ dragRef.current = null;
+ }, []);
+
+ const resetView = useCallback(() => {
+ setScale(2.5);
+ setTranslate({ x: 0, y: 0 });
+ }, []);
+
+ return (
+
+
+
+
+
+ {Math.round(scale * 100)}%
+
+
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/NodeChip.stories.tsx b/web_src/src/components/AgentSidebar/widgets/NodeChip.stories.tsx
new file mode 100644
index 0000000000..63ec22a79a
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/NodeChip.stories.tsx
@@ -0,0 +1,138 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { MemoryRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { RichMessage } from "./RichMessage";
+import { canvasKeys } from "@/hooks/useCanvasData";
+import type { CanvasesCanvas } from "@/api-client";
+
+const ORG_ID = "1e880270-cb0b-4310-9479-3e01c14938aa";
+const CANVAS_ID = "05bb8e74-6f11-4d1c-bbfd-75d4a28303d6";
+
+const mockCanvas: CanvasesCanvas = {
+ spec: {
+ nodes: [
+ {
+ id: "webhook-trigger",
+ name: "Webhook Trigger",
+ type: "TYPE_TRIGGER",
+ component: "webhook",
+ configuration: { authentication: "none" },
+ },
+ {
+ id: "call-api",
+ name: "Call Target API",
+ type: "TYPE_ACTION",
+ component: "http",
+ configuration: { method: "GET", url: "https://httpbin.org/status/200" },
+ },
+ {
+ id: "check-result",
+ name: "Check API Result",
+ type: "TYPE_ACTION",
+ component: "if",
+ configuration: { expression: "{{ previous().data.statusCode >= 200 && previous().data.statusCode < 300 }}" },
+ },
+ {
+ id: "random-wait",
+ name: "Random Wait",
+ type: "TYPE_ACTION",
+ component: "wait",
+ configuration: { duration: "30" },
+ },
+ {
+ id: "notify-success",
+ name: "Notify Success",
+ type: "TYPE_ACTION",
+ component: "http",
+ configuration: { method: "POST", url: "https://httpbin.org/post" },
+ },
+ {
+ id: "ssh-deploy",
+ name: "SSH Deploy",
+ type: "TYPE_ACTION",
+ component: "ssh",
+ configuration: { host: "192.168.1.1", username: "ubuntu" },
+ },
+ ],
+ edges: [
+ { sourceId: "webhook-trigger", targetId: "call-api", channel: "default" },
+ { sourceId: "call-api", targetId: "check-result", channel: "success" },
+ { sourceId: "check-result", targetId: "notify-success", channel: "true" },
+ ],
+ },
+};
+
+function createSeededClient() {
+ const qc = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } });
+ qc.setQueryData(canvasKeys.detail(ORG_ID, CANVAS_ID), mockCanvas);
+ return qc;
+}
+
+const meta: Meta = {
+ title: "AgentSidebar/NodeChips",
+ component: RichMessage,
+ parameters: { layout: "padded" },
+ decorators: [
+ (Story) => {
+ const qc = createSeededClient();
+ return (
+
+
+
+
+
+ );
+ },
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const NodeReferences: Story = {
+ args: {
+ content: `Check the [Webhook Trigger](node:webhook-trigger) and the [Call Target API](node:call-api) node.`,
+ canvasId: CANVAS_ID,
+ organizationId: ORG_ID,
+ },
+};
+
+export const AllComponentTypes: Story = {
+ args: {
+ content: `Node types:
+
+- Trigger: [Webhook Trigger](node:webhook-trigger)
+- HTTP: [Call Target API](node:call-api)
+- If: [Check API Result](node:check-result)
+- Wait: [Random Wait](node:random-wait)
+- SSH: [SSH Deploy](node:ssh-deploy)
+- Notify: [Notify Success](node:notify-success)`,
+ canvasId: CANVAS_ID,
+ organizationId: ORG_ID,
+ },
+};
+
+export const NodesInTable: Story = {
+ args: {
+ content: `| Node | Component | Notes |
+|------|-----------|-------|
+| [Webhook Trigger](node:webhook-trigger) | webhook | Entry point |
+| [Call Target API](node:call-api) | http | GET request |
+| [Check API Result](node:check-result) | if | Status check |
+| [Random Wait](node:random-wait) | wait | 30s delay |`,
+ canvasId: CANVAS_ID,
+ organizationId: ORG_ID,
+ },
+};
+
+export const UnknownNode: Story = {
+ args: {
+ content: `This references a [Missing Node](node:does-not-exist) that doesn't exist on the canvas.`,
+ canvasId: CANVAS_ID,
+ organizationId: ORG_ID,
+ },
+};
diff --git a/web_src/src/components/AgentSidebar/widgets/NodeChip.tsx b/web_src/src/components/AgentSidebar/widgets/NodeChip.tsx
new file mode 100644
index 0000000000..13d421a6d8
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/NodeChip.tsx
@@ -0,0 +1,183 @@
+import { useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { useQueryClient } from "@tanstack/react-query";
+import { cn } from "@/lib/utils";
+import { canvasKeys } from "@/hooks/useCanvasData";
+import { getHeaderIconSrc } from "@/ui/componentSidebar/integrationIcons";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
+import { Clock, Filter, Globe, Hand, Merge, Play, Split, Terminal, Webhook, type LucideIcon } from "lucide-react";
+import type { CanvasesCanvas, SuperplaneComponentsNode } from "@/api-client";
+
+const COMPONENT_ICONS: Record = {
+ http: Globe,
+ wait: Clock,
+ webhook: Webhook,
+ start: Play,
+ if: Split,
+ filter: Filter,
+ ssh: Terminal,
+ approval: Hand,
+ merge: Merge,
+ schedule: Clock,
+};
+
+interface NodeChipProps {
+ nodeId: string;
+ label: string;
+ canvasId: string;
+ organizationId: string;
+}
+
+export function NodeChipFromLink({
+ nodeId,
+ rawLabel,
+ canvasId,
+ organizationId,
+}: {
+ nodeId: string;
+ rawLabel?: string;
+ canvasId: string;
+ organizationId: string;
+}) {
+ const label = rawLabel && rawLabel !== "node" ? rawLabel : nodeId;
+ return ;
+}
+
+function getChipStyle(node?: SuperplaneComponentsNode) {
+ if (!node) return "bg-slate-100 text-slate-600 ring-slate-300";
+ return node.type === "TYPE_TRIGGER"
+ ? "bg-purple-100 text-purple-700 ring-purple-300 hover:bg-purple-200"
+ : "bg-blue-100 text-blue-700 ring-blue-300 hover:bg-blue-200";
+}
+
+function NodeIconInline({ component, isTrigger }: { component?: string; isTrigger: boolean }) {
+ const iconSrc = component ? getHeaderIconSrc(component) : undefined;
+ const Icon = component ? COMPONENT_ICONS[component] : undefined;
+ if (iconSrc) return
;
+ if (Icon) return ;
+ return ;
+}
+
+export function NodeChip({ nodeId, label, canvasId, organizationId }: NodeChipProps) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const canvas = queryClient.getQueryData(canvasKeys.detail(organizationId, canvasId));
+ const node = canvas?.spec?.nodes?.find((n) => n.id === nodeId);
+ const edges = canvas?.spec?.edges ?? [];
+ const isTrigger = node?.type === "TYPE_TRIGGER";
+
+ const handleClick = useCallback(() => {
+ navigate(`/${organizationId}/canvases/${canvasId}?sidebar=1&node=${nodeId}`);
+ window.dispatchEvent(new CustomEvent("agent:focus-node", { detail: { nodeId } }));
+ }, [navigate, organizationId, canvasId, nodeId]);
+
+ return (
+
+
+
+
+ {node && (
+
+
+
+ )}
+
+ );
+}
+
+function NodeHoverContent({
+ node,
+ edges,
+}: {
+ node: SuperplaneComponentsNode;
+ edges: NonNullable["edges"];
+}) {
+ const isTrigger = node.type === "TYPE_TRIGGER";
+ const iconSrc = node.component ? getHeaderIconSrc(node.component) : undefined;
+ const NodeIcon = node.component ? COMPONENT_ICONS[node.component] : undefined;
+ const config = node.configuration ?? {};
+
+ // Count connections
+ const incoming = (edges ?? []).filter((e) => e.targetId === node.id).length;
+ const outgoing = (edges ?? []).filter((e) => e.sourceId === node.id).length;
+
+ // Extract key config summary
+ const summary = getConfigSummary(node.component, config);
+
+ return (
+
+ {/* Header */}
+
+ {iconSrc ? (
+

+ ) : NodeIcon ? (
+
+ ) : (
+
+ )}
+
+
{node.name || node.id}
+
+ {node.component} · {isTrigger ? "Trigger" : "Action"}
+
+
+
+
+ {/* Config summary */}
+ {summary && (
+
+ )}
+
+ {/* Connections */}
+
+ {incoming} incoming
+ ·
+ {outgoing} outgoing
+
+
+ {/* Error/warning */}
+ {node.errorMessage && (
+
+ ⚠ {node.errorMessage}
+
+ )}
+
+ );
+}
+
+const CONFIG_SUMMARIZERS: Record) => string> = {
+ http: (c) => `${c.method || "GET"} ${c.url || ""}`,
+ ssh: (c) => `${c.username || "root"}@${c.host || ""}`,
+ if: (c) => String(c.expression || ""),
+ filter: (c) => String(c.expression || ""),
+ wait: (c) => `Wait: ${c.duration || c.waitFor || ""}`,
+ webhook: (c) => `Auth: ${c.authentication || "none"}`,
+ schedule: (c) => `Cron: ${c.cron || ""}`,
+ approval: (c) => String(c.message || "Approval required"),
+};
+
+function getConfigSummary(component?: string, config?: Record): string | null {
+ if (!component || !config) return null;
+ const summarizer = CONFIG_SUMMARIZERS[component];
+ return summarizer ? summarizer(config) : null;
+}
+// already at end of file
diff --git a/web_src/src/components/AgentSidebar/widgets/RichMessage.stories.tsx b/web_src/src/components/AgentSidebar/widgets/RichMessage.stories.tsx
new file mode 100644
index 0000000000..1a77846500
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/RichMessage.stories.tsx
@@ -0,0 +1,379 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { RichMessage } from "./RichMessage";
+
+const meta: Meta = {
+ title: "AgentSidebar/RichMessage",
+ component: RichMessage,
+ parameters: {
+ layout: "padded",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const PureMarkdown: Story = {
+ args: {
+ content: `## Canvas Deployed!
+
+Here's what was built:
+
+- **5 nodes** configured
+- **4 edges** connecting them
+- Webhook trigger → API call → branch → notifications
+
+\`\`\`yaml
+apiVersion: v1
+kind: Canvas
+metadata:
+ name: api-health-check
+\`\`\`
+
+> Note: The canvas is now live and accepting webhook events.`,
+ },
+};
+
+export const Buttons: Story = {
+ args: {
+ content: `I can set up a few different workflow patterns for you.
+
+:::buttons
+Which pattern would you like?
+- Webhook → API → Notify
+- Schedule → Health Check → Alert
+- GitHub Push → Deploy → Verify
+- Custom (describe your workflow)
+:::`,
+ },
+};
+
+export const YAMLCodeBlock: Story = {
+ args: {
+ content: `Here's the canvas configuration:
+
+\`\`\`yaml
+apiVersion: v1
+kind: Canvas
+metadata:
+ name: api-health-check
+ id: 05bb8e74-6f11-4d1c-bbfd-75d4a28303d6
+spec:
+ nodes:
+ - id: webhook-trigger
+ name: Receive Request
+ type: TYPE_TRIGGER
+ component: webhook
+ configuration:
+ authentication: "none"
+ - id: call-api
+ name: Call Target API
+ type: TYPE_ACTION
+ component: http
+ configuration:
+ method: GET
+ url: "https://api.example.com/health"
+ json: ""
+ successCodes: "200"
+ timeoutSeconds: 30
+\`\`\``,
+ },
+};
+
+export const BashCodeBlock: Story = {
+ args: {
+ content: `Run these commands to set up:
+
+\`\`\`bash
+curl -fsSL https://install.superplane.com/install.sh | sh
+export PATH="$HOME/.local/bin:$PATH"
+superplane connect https://app.superplane.com your-token-here
+superplane canvases list
+\`\`\`
+
+The CLI should now be ready to use.`,
+ },
+};
+
+export const JSONCodeBlock: Story = {
+ args: {
+ content: `The webhook payload looks like this:
+
+\`\`\`json
+{
+ "event": "push",
+ "repository": {
+ "full_name": "superplanehq/superplane",
+ "default_branch": "main"
+ },
+ "ref": "refs/heads/main",
+ "commits": [
+ {
+ "id": "abc123",
+ "message": "fix: resolve timeout issue",
+ "author": { "name": "Alex" }
+ }
+ ]
+}
+\`\`\``,
+ },
+};
+
+export const DeployButtons: Story = {
+ args: {
+ content: `I've prepared the canvas. Ready to deploy.
+
+:::buttons
+Where should this be deployed?
+- DigitalOcean
+- Google Cloud
+- Hetzner
+- AWS
+:::`,
+ },
+};
+
+export const Confirmation: Story = {
+ args: {
+ content: `I'm about to overwrite the existing canvas configuration.
+
+:::confirm
+message: This will replace all 5 existing nodes with the new pipeline. This action cannot be undone.
+yes: Overwrite Canvas
+no: Cancel
+:::`,
+ },
+};
+
+export const LineChart: Story = {
+ args: {
+ content: `Here's your run success rate for the past week:
+
+:::chart
+type: line
+title: Run Success Rate (Last 7 Days)
+x: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
+series:
+ - name: Successful
+ data: [12, 15, 11, 14, 13, 8, 10]
+ color: "#22c55e"
+ - name: Failed
+ data: [2, 1, 3, 0, 2, 1, 0]
+ color: "#ef4444"
+:::
+
+Overall success rate: **90.4%** — 3 failures on Wednesday were due to a timeout in the API call node.`,
+ },
+};
+
+export const BarChart: Story = {
+ args: {
+ content: `Execution count by node:
+
+:::chart
+type: bar
+title: Executions Per Node (Last 24h)
+x: ["Webhook", "API Call", "Check Status", "Notify OK", "Notify Fail"]
+series:
+ - name: Executions
+ data: [48, 48, 48, 41, 7]
+ color: "#8b5cf6"
+:::`,
+ },
+};
+
+export const PieChart: Story = {
+ args: {
+ content: `Run outcome breakdown:
+
+:::chart
+type: pie
+title: Run Outcomes (Last 30 Days)
+data:
+ - name: Success
+ value: 312
+ color: "#22c55e"
+ - name: Failed
+ value: 28
+ color: "#ef4444"
+ - name: Timed Out
+ value: 8
+ color: "#f59e0b"
+ - name: Cancelled
+ value: 3
+ color: "#94a3b8"
+:::`,
+ },
+};
+
+export const Steps: Story = {
+ args: {
+ content: `Setting up your canvas:
+
+:::steps
+- [x] Install CLI
+- [x] Connect to SuperPlane API
+- [x] Write canvas YAML
+- [ ] Deploy canvas
+- [ ] Verify node configuration
+:::`,
+ },
+};
+
+export const SuccessBanner: Story = {
+ args: {
+ content: `:::success
+Canvas "api-health-check-with-alerts" deployed successfully! 5 nodes, 4 edges, all validations passed.
+:::
+
+The webhook URL is: \`https://app.superplane.com/hooks/05bb8e74-6f11-4d1c-bbfd-75d4a28303d6\``,
+ },
+};
+
+export const ErrorBanner: Story = {
+ args: {
+ content: `:::error
+Deployment failed: Node "call-api" has validation error — json field is required for HTTP actions.
+:::
+
+I'll fix this and retry. The issue is that GET requests still need an empty \`json: ""\` field.`,
+ },
+};
+
+export const CollapsibleSection: Story = {
+ args: {
+ content: `Canvas deployed. Here's the full YAML if you want to review:
+
+:::collapse title="Full Canvas YAML"
+apiVersion: v1
+kind: Canvas
+metadata:
+ name: api-health-check-with-alerts
+ id: 05bb8e74-6f11-4d1c-bbfd-75d4a28303d6
+spec:
+ nodes:
+ - id: webhook-trigger
+ name: Receive Request
+ type: TYPE_TRIGGER
+ component: webhook
+ configuration:
+ authentication: "none"
+ - id: call-api
+ name: Call Target API
+ type: TYPE_ACTION
+ component: http
+ configuration:
+ method: GET
+ url: "https://api.example.com/health"
+ json: ""
+ successCodes: "200"
+ timeoutSeconds: 30
+:::`,
+ },
+};
+
+export const SimpleTable: Story = {
+ args: {
+ content: `Here are the nodes in your canvas:
+
+| Node | Type | Component | Status |
+|------|------|-----------|--------|
+| Webhook Trigger | Trigger | webhook | Active |
+| Call API | Action | http | Active |
+| Check Status | Action | if | Active |
+| Notify Success | Action | http | Active |
+| Notify Failure | Action | http | Active |`,
+ },
+};
+
+export const RunHistoryTable: Story = {
+ args: {
+ content: `## Recent Runs
+
+Last 5 runs for this canvas:
+
+| Run ID | Started | Duration | Status | Trigger |
+|--------|---------|----------|--------|---------|
+| #1247 | 2 min ago | 3.2s | ✅ Success | Webhook |
+| #1246 | 17 min ago | 2.8s | ✅ Success | Webhook |
+| #1245 | 32 min ago | 12.1s | ❌ Failed | Webhook |
+| #1244 | 1h ago | 3.0s | ✅ Success | Schedule |
+| #1243 | 1h 15m ago | 2.9s | ✅ Success | Schedule |
+
+Run #1245 failed at the **Call API** node with a timeout error.`,
+ },
+};
+
+export const NodeComparisonTable: Story = {
+ args: {
+ content: `### Node Performance Comparison
+
+| Node | Avg Duration | Success Rate | Executions |
+|------|-------------|-------------|------------|
+| Webhook Trigger | 12ms | 100% | 1,247 |
+| Call API | 2.4s | 95.8% | 1,247 |
+| Check Status | 8ms | 100% | 1,195 |
+| Notify Success | 340ms | 99.2% | 1,142 |
+| Notify Failure | 380ms | 98.1% | 53 |
+
+The **Call API** node has the lowest success rate — consider adding retry logic.`,
+ },
+};
+
+export const MermaidDiagram: Story = {
+ args: {
+ content: `Here's the flow for your canvas:
+
+\`\`\`mermaid
+flowchart LR
+ A[Webhook Trigger] --> B[Call API]
+ B --> C{Check Status}
+ C -->|200 OK| D[Notify Success]
+ C -->|Error| E[Notify Failure]
+\`\`\`
+
+The webhook will accept incoming requests and route them through the API health check before notifying via the appropriate channel.`,
+ },
+};
+
+export const MixedContent: Story = {
+ args: {
+ content: `## Analysis Complete
+
+I analyzed 351 runs from the past 30 days. Here's what I found:
+
+:::chart
+type: line
+title: Daily Run Volume
+x: ["W1", "W2", "W3", "W4"]
+series:
+ - name: Runs
+ data: [78, 92, 88, 93]
+ color: "#8b5cf6"
+:::
+
+:::success
+Overall health is excellent — 96% success rate.
+:::
+
+### Recommendations
+
+1. The "Check Status" node has the highest failure rate (4.2%)
+2. Consider adding retry logic to the API call
+3. Timeout could be increased from 10s to 15s
+
+:::buttons
+What would you like to do?
+- Add retry to API call
+- Increase timeout
+- Show me the failing runs
+- Do nothing
+:::`,
+ },
+};
diff --git a/web_src/src/components/AgentSidebar/widgets/RichMessage.tsx b/web_src/src/components/AgentSidebar/widgets/RichMessage.tsx
new file mode 100644
index 0000000000..634c9e01d0
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/RichMessage.tsx
@@ -0,0 +1,155 @@
+import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
+import remarkBreaks from "remark-breaks";
+import remarkGfm from "remark-gfm";
+import { parseAgentContent, type Segment } from "./parser";
+import { ButtonsWidget } from "./ButtonsWidget";
+import { ConfirmWidget } from "./ConfirmWidget";
+import { ChartWidget } from "./ChartWidget";
+import { CollapseWidget } from "./CollapseWidget";
+import { StepsWidget } from "./StepsWidget";
+import { BannerWidget } from "./BannerWidget";
+import { MermaidWidget } from "./MermaidWidget";
+import { CodeBlockWidget } from "./CodeBlockWidget";
+import { RunChipFromLink } from "./RunChip";
+import { NodeChipFromLink } from "./NodeChip";
+
+const MARKDOWN_CLASSES =
+ "max-w-none [&_h1]:mb-1.5 [&_h1]:mt-1 [&_h1]:text-base [&_h1]:font-semibold [&_h1:first-child]:mt-0 " +
+ "[&_h2]:mb-1 [&_h2]:mt-1 [&_h2]:text-sm [&_h2]:font-semibold [&_h2:first-child]:mt-0 " +
+ "[&_h3]:mb-0.5 [&_h3]:mt-1 [&_h3]:text-sm [&_h3]:font-semibold [&_h3:first-child]:mt-0 " +
+ "[&_p]:mb-2 [&_p]:leading-relaxed [&_p:last-child]:mb-0 " +
+ "[&_ol]:mb-2 [&_ol]:ml-5 [&_ol]:list-decimal [&_ul]:mb-2 [&_ul]:ml-5 [&_ul]:list-disc [&_li]:mb-0.5 " +
+ "[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_blockquote]:pl-3 " +
+ "[&_code]:rounded [&_code]:bg-slate-200/70 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs " +
+ "[&_pre]:my-2 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-slate-200/70 [&_pre]:p-2 " +
+ "[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
+ "[&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-current " +
+ "[&_table]:w-full [&_table]:text-xs [&_table]:border-collapse " +
+ "[&_thead]:bg-slate-50 [&_th]:px-3 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold [&_th]:text-slate-700 " +
+ "[&_th]:border-b [&_th]:border-slate-200 " +
+ "[&_td]:px-3 [&_td]:py-1.5 [&_td]:text-slate-600 [&_td]:border-b [&_td]:border-slate-100 " +
+ "[&_tbody_tr:nth-child(even)]:bg-slate-50/60 " +
+ "[&_tr:last-child_td]:border-b-0 [&_tr:hover]:bg-violet-50/50";
+
+interface RichMessageProps {
+ content: string;
+ onAction?: (text: string) => void;
+ canvasId?: string;
+ organizationId?: string;
+}
+
+export function RichMessage({ content, onAction, canvasId, organizationId }: RichMessageProps) {
+ const segments = parseAgentContent(content);
+
+ return (
+
+ {segments.map((segment, i) => (
+
+ ))}
+
+ );
+}
+
+function SegmentRenderer({
+ segment,
+ onAction,
+ canvasId,
+ organizationId,
+}: {
+ segment: Segment;
+ onAction?: (text: string) => void;
+ canvasId?: string;
+ organizationId?: string;
+}) {
+ switch (segment.type) {
+ case "markdown":
+ return (
+
+
(url.startsWith("run:") || url.startsWith("node:") ? url : defaultUrlTransform(url))}
+ components={{
+ a: ({ children, href }) => {
+ const runMatch = href?.match(/^run:([0-9a-f-]{36})(?:~(.+))?/);
+ if (runMatch && canvasId && organizationId) {
+ const label = typeof children === "string" ? children : undefined;
+ return (
+
+ );
+ }
+
+ const nodeMatch = href?.match(/^node:(.+)$/);
+ if (nodeMatch && canvasId && organizationId) {
+ const label = typeof children === "string" ? children : undefined;
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+ code: ({ className, children, ...props }) => {
+ const match = /language-(\w+)/.exec(className || "");
+ const codeStr = String(children).replace(/\n$/, "");
+ // Only use CodeBlockWidget for fenced code blocks (with language class)
+ if (match) {
+ return ;
+ }
+ // Inline code
+ return (
+
+ {children}
+
+ );
+ },
+ pre: ({ children }) => <>{children}>,
+ table: ({ children, ...props }) => (
+
+ ),
+ }}
+ >
+ {segment.content}
+
+
+ );
+ case "buttons":
+ return ;
+ case "confirm":
+ return ;
+ case "chart":
+ return ;
+ case "collapse":
+ return ;
+ case "mermaid":
+ return ;
+ case "steps":
+ return ;
+ case "success":
+ return ;
+ case "error":
+ return ;
+ }
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/RunChip.stories.tsx b/web_src/src/components/AgentSidebar/widgets/RunChip.stories.tsx
new file mode 100644
index 0000000000..0a330e789d
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/RunChip.stories.tsx
@@ -0,0 +1,59 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { MemoryRouter } from "react-router-dom";
+import { RichMessage } from "./RichMessage";
+
+const meta: Meta = {
+ title: "AgentSidebar/RunChips",
+ component: RichMessage,
+ parameters: {
+ layout: "padded",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const AllStatuses: Story = {
+ args: {
+ content: `Run status examples:
+
+- [Health check OK](run:78848cb6-0c52-4c69-8e47-b6631bd703ec~passed) — 45s
+- [API timeout](run:2999a5f1-1234-5678-9abc-def012345678~failed) — node 3 timed out
+- [Rolling deploy](run:366b0a12-1111-2222-3333-444455556666~running) — in progress
+- [User aborted](run:e63e35a0-5555-6666-7777-888899990000~cancelled) — stopped manually`,
+ canvasId: "05bb8e74-6f11-4d1c-bbfd-75d4a28303d6",
+ organizationId: "1e880270-cb0b-4310-9479-3e01c14938aa",
+ },
+};
+
+export const RunsInTable: Story = {
+ args: {
+ content: `| Run | Duration | Result |
+|-----|----------|--------|
+| [Health check OK](run:78848cb6-0c52-4c69-8e47-b6631bd703ec~passed) | 45s | All nodes passed |
+| [API timeout](run:2999a5f1-1234-5678-9abc-def012345678~failed) | 0s | Node 3 timed out |
+| [Deploy staging](run:1e8cf8a2-abcd-ef01-2345-678901234567~passed) | 36s | Clean deploy |
+| [In progress](run:366b0a12-1111-2222-3333-444455556666~running) | — | Waiting |`,
+ canvasId: "05bb8e74-6f11-4d1c-bbfd-75d4a28303d6",
+ organizationId: "1e880270-cb0b-4310-9479-3e01c14938aa",
+ },
+};
+
+export const InlineText: Story = {
+ args: {
+ content: `The latest run [Health check OK](run:78848cb6-0c52-4c69-8e47-b6631bd703ec~passed) completed in 45s. The previous run [API timeout](run:2999a5f1-1234-5678-9abc-def012345678~failed) failed due to a timeout at the SSH node.`,
+ canvasId: "05bb8e74-6f11-4d1c-bbfd-75d4a28303d6",
+ organizationId: "1e880270-cb0b-4310-9479-3e01c14938aa",
+ },
+};
diff --git a/web_src/src/components/AgentSidebar/widgets/RunChip.tsx b/web_src/src/components/AgentSidebar/widgets/RunChip.tsx
new file mode 100644
index 0000000000..93cd94e97a
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/RunChip.tsx
@@ -0,0 +1,60 @@
+import { Rabbit } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { cn } from "@/lib/utils";
+import { RUN_STATUS_META, type RunStatusKey } from "@/ui/Runs/runPresentation";
+
+interface RunChipProps {
+ runId: string;
+ label: string;
+ status: RunStatusKey;
+ canvasId: string;
+ organizationId: string;
+}
+
+function parseStatus(raw?: string): RunStatusKey {
+ if (!raw) return "unknown";
+ const s = raw.toLowerCase();
+ if (s === "passed" || s === "success") return "passed";
+ if (s === "failed" || s === "error" || s === "failure") return "failed";
+ if (s === "running" || s === "started") return "running";
+ if (s === "cancelled") return "cancelled";
+ return "unknown";
+}
+
+export function RunChipFromLink({
+ runId,
+ rawLabel,
+ rawStatus,
+ canvasId,
+ organizationId,
+}: {
+ runId: string;
+ rawLabel?: string;
+ rawStatus?: string;
+ canvasId: string;
+ organizationId: string;
+}) {
+ const status = parseStatus(rawStatus);
+ const label = rawLabel && rawLabel !== "run" ? rawLabel : `#${runId.substring(0, 8)}`;
+ return ;
+}
+
+export function RunChip({ runId, label, status, canvasId, organizationId }: RunChipProps) {
+ const navigate = useNavigate();
+ const meta = RUN_STATUS_META[status];
+
+ return (
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/StepsWidget.tsx b/web_src/src/components/AgentSidebar/widgets/StepsWidget.tsx
new file mode 100644
index 0000000000..50bff2c3f1
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/StepsWidget.tsx
@@ -0,0 +1,33 @@
+import { Check, Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { StepItem } from "./parser";
+
+interface StepsWidgetProps {
+ items: StepItem[];
+}
+
+export function StepsWidget({ items }: StepsWidgetProps) {
+ const firstPending = items.findIndex((i) => !i.done);
+
+ return (
+
+ {items.map((item, i) => {
+ const isActive = i === firstPending;
+ return (
+
+ {item.done ? (
+
+ ) : isActive ? (
+
+ ) : (
+
+ )}
+
+ {item.text}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/web_src/src/components/AgentSidebar/widgets/parser.test.ts b/web_src/src/components/AgentSidebar/widgets/parser.test.ts
new file mode 100644
index 0000000000..3ed8b27547
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/parser.test.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect } from "vitest";
+import { parseAgentContent } from "./parser";
+
+describe("parseAgentContent", () => {
+ it("parses pure markdown", () => {
+ const content = "Hello **world**";
+ const segments = parseAgentContent(content);
+ expect(segments).toEqual([{ type: "markdown", content: "Hello **world**" }]);
+ });
+
+ it("parses buttons block", () => {
+ const content = `:::buttons
+- Option A
+- Option B
+:::`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(1);
+ expect(segments[0]).toEqual({
+ type: "buttons",
+ prompt: "",
+ items: ["Option A", "Option B"],
+ });
+ });
+
+ it("parses confirm block", () => {
+ const content = `:::confirm
+message: Are you sure?
+yes: Yes
+no: No
+:::`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(1);
+ expect(segments[0]).toEqual({
+ type: "confirm",
+ message: "Are you sure?",
+ yes: "Yes",
+ no: "No",
+ });
+ });
+
+ it("parses steps block", () => {
+ const content = `:::steps
+- [x] Done step
+- [ ] Pending step
+:::`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(1);
+ expect(segments[0]).toEqual({
+ type: "steps",
+ items: [
+ { done: true, text: "Done step" },
+ { done: false, text: "Pending step" },
+ ],
+ });
+ });
+
+ it("parses success banner", () => {
+ const content = `:::success
+All good!
+:::`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(1);
+ expect(segments[0]).toEqual({
+ type: "success",
+ content: "All good!",
+ });
+ });
+
+ it("parses mixed content", () => {
+ const content = `Here is some text.
+
+:::buttons
+- Button 1
+- Button 2
+:::
+
+More text here.`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(3);
+ expect(segments[0].type).toBe("markdown");
+ expect(segments[1].type).toBe("buttons");
+ expect(segments[2].type).toBe("markdown");
+ });
+
+ it("handles chart JSON", () => {
+ const content = `:::chart
+{"type":"line","data":[{"x":1,"y":2}],"xKey":"x","yKeys":["y"]}
+:::`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(1);
+ expect(segments[0]).toEqual({
+ type: "chart",
+ config: {
+ type: "line",
+ data: [{ x: 1, y: 2 }],
+ xKey: "x",
+ yKeys: ["y"],
+ },
+ });
+ });
+
+ it("handles collapse block", () => {
+ const content = `:::collapse title="Click to expand"
+Hidden content
+:::`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(1);
+ expect(segments[0]).toEqual({
+ type: "collapse",
+ title: "Click to expand",
+ content: "Hidden content",
+ });
+ });
+
+ it("handles empty content", () => {
+ const segments = parseAgentContent("");
+ expect(segments).toEqual([]);
+ });
+
+ it("handles block at start", () => {
+ const content = `:::success
+Great!
+:::
+Some text.`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(2);
+ expect(segments[0].type).toBe("success");
+ expect(segments[1].type).toBe("markdown");
+ });
+
+ it("handles block at end", () => {
+ const content = `Some text.
+:::error
+Oops!
+:::`;
+ const segments = parseAgentContent(content);
+ expect(segments).toHaveLength(2);
+ expect(segments[0].type).toBe("markdown");
+ expect(segments[1].type).toBe("error");
+ });
+});
diff --git a/web_src/src/components/AgentSidebar/widgets/parser.ts b/web_src/src/components/AgentSidebar/widgets/parser.ts
new file mode 100644
index 0000000000..9ccd8f568a
--- /dev/null
+++ b/web_src/src/components/AgentSidebar/widgets/parser.ts
@@ -0,0 +1,201 @@
+import YAML from "js-yaml";
+
+// --- Types ---
+
+export type MarkdownSegment = { type: "markdown"; content: string };
+export type ButtonsSegment = { type: "buttons"; prompt: string; items: string[] };
+export type ConfirmSegment = { type: "confirm"; message: string; yes: string; no: string };
+export type ChartSegment = { type: "chart"; config: ChartConfig };
+export type CollapseSegment = { type: "collapse"; title: string; content: string };
+export type MermaidSegment = { type: "mermaid"; content: string };
+export type StepsSegment = { type: "steps"; items: StepItem[] };
+export type SuccessSegment = { type: "success"; content: string };
+export type ErrorSegment = { type: "error"; content: string };
+
+export type Segment =
+ | MarkdownSegment
+ | ButtonsSegment
+ | ConfirmSegment
+ | ChartSegment
+ | CollapseSegment
+ | MermaidSegment
+ | StepsSegment
+ | SuccessSegment
+ | ErrorSegment;
+
+export type StepItem = { done: boolean; text: string };
+
+export type ChartConfig = {
+ type: "line" | "bar" | "area" | "pie";
+ title?: string;
+ x?: string[];
+ series?: { name: string; data: number[]; color?: string }[];
+ data?: { name: string; value: number; color?: string }[];
+};
+
+// --- Parser ---
+
+const BLOCK_RE = /^\s*:::(\w+)(?:\s+(.*))?$/;
+const BLOCK_END_RE = /^\s*:::$/;
+const MERMAID_FENCE_START = /^\s*```mermaid\s*$/;
+const FENCE_END = /^\s*```\s*$/;
+
+export function parseAgentContent(content: string): Segment[] {
+ if (!content) return [];
+
+ const lines = content.split("\n");
+ const segments: Segment[] = [];
+ let markdownBuffer: string[] = [];
+ let blockType: string | null = null;
+ let blockMeta = "";
+ let blockLines: string[] = [];
+
+ function flushMarkdown() {
+ const text = markdownBuffer.join("\n").trim();
+ if (text) {
+ segments.push({ type: "markdown", content: text });
+ }
+ markdownBuffer = [];
+ }
+
+ function flushBlock() {
+ if (!blockType) return;
+ const raw = blockLines.join("\n");
+ const segment = parseBlock(blockType, blockMeta, raw);
+ if (segment) {
+ segments.push(segment);
+ }
+ blockType = null;
+ blockMeta = "";
+ blockLines = [];
+ }
+
+ let inMermaidFence = false;
+ let mermaidLines: string[] = [];
+
+ for (const line of lines) {
+ // Handle ```mermaid fenced code blocks
+ if (inMermaidFence) {
+ if (FENCE_END.test(line.trim())) {
+ flushMarkdown();
+ segments.push({ type: "mermaid", content: mermaidLines.join("\n") });
+ mermaidLines = [];
+ inMermaidFence = false;
+ } else {
+ mermaidLines.push(line);
+ }
+ continue;
+ }
+
+ if (blockType) {
+ if (BLOCK_END_RE.test(line.trim())) {
+ flushBlock();
+ } else {
+ blockLines.push(line);
+ }
+ } else if (MERMAID_FENCE_START.test(line.trim())) {
+ flushMarkdown();
+ inMermaidFence = true;
+ mermaidLines = [];
+ } else {
+ const match = line.match(BLOCK_RE);
+ if (match) {
+ flushMarkdown();
+ blockType = match[1];
+ blockMeta = match[2] || "";
+ } else {
+ markdownBuffer.push(line);
+ }
+ }
+ }
+
+ // Handle unclosed blocks gracefully
+ if (blockType) {
+ flushBlock();
+ }
+ flushMarkdown();
+
+ return segments;
+}
+
+function parseBlock(type: string, meta: string, raw: string): Segment | null {
+ switch (type) {
+ case "buttons":
+ return parseButtons(raw);
+ case "confirm":
+ return parseConfirm(raw);
+ case "chart":
+ return parseChart(raw);
+ case "collapse":
+ return parseCollapse(meta, raw);
+ case "steps":
+ return parseSteps(raw);
+ case "success":
+ return { type: "success", content: raw.trim() };
+ case "error":
+ return { type: "error", content: raw.trim() };
+ default:
+ return { type: "markdown", content: `:::${type} ${meta}\n${raw}\n:::` };
+ }
+}
+
+function parseButtons(raw: string): ButtonsSegment {
+ const lines = raw.split("\n").filter((l) => l.trim());
+ const items: string[] = [];
+ const promptLines: string[] = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (/^[-*]\s/.test(trimmed)) {
+ items.push(trimmed.replace(/^[-*]\s*/, "").trim());
+ } else {
+ promptLines.push(trimmed);
+ }
+ }
+
+ return { type: "buttons", prompt: promptLines.join("\n"), items };
+}
+
+function parseConfirm(raw: string): ConfirmSegment {
+ try {
+ const parsed = YAML.load(raw) as Record;
+ return {
+ type: "confirm",
+ message: parsed.message || raw.trim(),
+ yes: parsed.yes || "Yes",
+ no: parsed.no || "No",
+ };
+ } catch {
+ return { type: "confirm", message: raw.trim(), yes: "Yes", no: "No" };
+ }
+}
+
+function parseChart(raw: string): ChartSegment {
+ try {
+ const config = YAML.load(raw) as ChartConfig;
+ return { type: "chart", config };
+ } catch {
+ return { type: "chart", config: { type: "bar", title: "Parse Error" } };
+ }
+}
+
+function parseCollapse(meta: string, raw: string): CollapseSegment {
+ const titleMatch = meta.match(/title="([^"]+)"/);
+ return {
+ type: "collapse",
+ title: titleMatch ? titleMatch[1] : "Details",
+ content: raw,
+ };
+}
+
+function parseSteps(raw: string): StepsSegment {
+ const items = raw
+ .split("\n")
+ .filter((l) => l.trim().startsWith("- ["))
+ .map((l) => {
+ const done = l.includes("[x]") || l.includes("[X]");
+ const text = l.replace(/^[-*]\s*\[[ xX]\]\s*/, "").trim();
+ return { done, text };
+ });
+ return { type: "steps", items };
+}
diff --git a/web_src/src/components/ui/chart.tsx b/web_src/src/components/ui/chart.tsx
new file mode 100644
index 0000000000..178dd05eab
--- /dev/null
+++ b/web_src/src/components/ui/chart.tsx
@@ -0,0 +1,306 @@
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+import type { TooltipValueType } from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
+type TooltipNameType = number | string;
+
+export type ChartConfig = Record<
+ string,
+ {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & ({ color?: string; theme?: never } | { color?: never; theme: Record })
+>;
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ initialDimension = INITIAL_DIMENSION,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps["children"];
+ initialDimension?: {
+ width: number;
+ height: number;
+ };
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+